feat(plugin-workflow): add delay node type (#532)
* feat(plugin-workflow): add delay node type * fix(plugin-workflow): fix test and add cases * fix(plugin-workflow): fix processor.saveJob()
This commit is contained in:
		
							parent
							
								
									3dce31f6a1
								
							
						
					
					
						commit
						ef939b4277
					
				| @ -553,6 +553,13 @@ export default { | |||||||
|   'Continue after all branches succeeded': '全部分支都成功后才能继续', |   'Continue after all branches succeeded': '全部分支都成功后才能继续', | ||||||
|   'Continue after any branch succeeded': '任意分支成功后就继续', |   'Continue after any branch succeeded': '任意分支成功后就继续', | ||||||
| 
 | 
 | ||||||
|  |   'Delay': '延时', | ||||||
|  |   'Duration': '时长', | ||||||
|  |   'End Status': '到时状态', | ||||||
|  |   'Select status': '选择状态', | ||||||
|  |   'Succeed and continue': '通过并继续', | ||||||
|  |   'Fail and exit': '失败并退出', | ||||||
|  | 
 | ||||||
|   'Create record': '新增数据', |   'Create record': '新增数据', | ||||||
|   'Update record': '更新数据', |   'Update record': '更新数据', | ||||||
|   'Query record': '查询数据', |   'Query record': '查询数据', | ||||||
|  | |||||||
| @ -270,14 +270,6 @@ export function Operand({ | |||||||
|       display: flex; |       display: flex; | ||||||
|       gap: .5em; |       gap: .5em; | ||||||
|       align-items: center; |       align-items: center; | ||||||
| 
 |  | ||||||
|       .ant-select, |  | ||||||
|       .ant-cascader-picker, |  | ||||||
|       .ant-picker, |  | ||||||
|       .ant-input-number, |  | ||||||
|       .ant-input-affix-wrapper{ |  | ||||||
|         width: auto; |  | ||||||
|       } |  | ||||||
|     `}>
 |     `}>
 | ||||||
|       <Cascader |       <Cascader | ||||||
|         allowClear={false} |         allowClear={false} | ||||||
|  | |||||||
							
								
								
									
										36
									
								
								packages/core/client/src/workflow/components/Duration.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/core/client/src/workflow/components/Duration.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,36 @@ | |||||||
|  | import React from "react"; | ||||||
|  | import { InputNumber, Select } from "antd"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import { css } from "@emotion/css"; | ||||||
|  | 
 | ||||||
|  | const UnitOptions = [ | ||||||
|  |   { value: 1_000, label: 'Seconds' }, | ||||||
|  |   { value: 60_000, label: 'Minutes' }, | ||||||
|  |   { value: 3600_000, label: 'Hours' }, | ||||||
|  |   { value: 86400_000, label: 'Days' }, | ||||||
|  |   { value: 604800_000, label: 'Weeks' }, | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | function getNumberOption(v) { | ||||||
|  |   return UnitOptions.slice().reverse().find(item => !(v % item.value)); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default function ({ value = 60000, onChange }) { | ||||||
|  |   const { t } = useTranslation(); | ||||||
|  |   const option = getNumberOption(value); | ||||||
|  |   const quantity = Math.round(value / option.value); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <fieldset className={css` | ||||||
|  |       display: flex; | ||||||
|  |       gap: .5em; | ||||||
|  |     `}>
 | ||||||
|  |       <InputNumber min={1} value={quantity} onChange={(v) => onChange(Math.round(v * option.value))}/> | ||||||
|  |       <Select value={option.value} onChange={unit => onChange(Math.round(quantity * unit))}> | ||||||
|  |         {UnitOptions.map(item => ( | ||||||
|  |           <Select.Option key={item.value} value={item.value}>{t(item.label)}</Select.Option> | ||||||
|  |         ))} | ||||||
|  |       </Select> | ||||||
|  |     </fieldset> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								packages/core/client/src/workflow/nodes/delay.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								packages/core/client/src/workflow/nodes/delay.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | import Duration from "../components/Duration"; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   title: '{{t("Delay")}}', | ||||||
|  |   type: 'delay', | ||||||
|  |   group: 'control', | ||||||
|  |   fieldset: { | ||||||
|  |     'config.duration': { | ||||||
|  |       type: 'number', | ||||||
|  |       name: 'config.duration', | ||||||
|  |       title: '{{t("Duration")}}', | ||||||
|  |       'x-decorator': 'FormItem', | ||||||
|  |       'x-component': 'Duration', | ||||||
|  |     }, | ||||||
|  |     'config.endStatus': { | ||||||
|  |       type: 'number', | ||||||
|  |       name: 'config.endStatus', | ||||||
|  |       title: '{{t("End Status")}}', | ||||||
|  |       'x-decorator': 'FormItem', | ||||||
|  |       'x-component': 'Select', | ||||||
|  |       'x-component-props': { | ||||||
|  |         placeholder: '{{t("Select status")}}', | ||||||
|  |       }, | ||||||
|  |       enum: [ | ||||||
|  |         { label: '{{t("Succeed and continue")}}', value: 1 }, | ||||||
|  |         { label: '{{t("Fail and exit")}}', value: -1 }, | ||||||
|  |       ] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   view: { | ||||||
|  | 
 | ||||||
|  |   }, | ||||||
|  |   scope: { | ||||||
|  |   }, | ||||||
|  |   components: { | ||||||
|  |     Duration | ||||||
|  |   } | ||||||
|  | }; | ||||||
| @ -11,11 +11,13 @@ import { AddButton, useFlowContext } from '../WorkflowCanvas'; | |||||||
| 
 | 
 | ||||||
| import calculation from './calculation'; | import calculation from './calculation'; | ||||||
| import condition from './condition'; | import condition from './condition'; | ||||||
| import create from './create'; |  | ||||||
| import destroy from './destroy'; |  | ||||||
| import parallel from './parallel'; | import parallel from './parallel'; | ||||||
|  | import delay from './delay'; | ||||||
|  | 
 | ||||||
| import query from './query'; | import query from './query'; | ||||||
|  | import create from './create'; | ||||||
| import update from './update'; | import update from './update'; | ||||||
|  | import destroy from './destroy'; | ||||||
| 
 | 
 | ||||||
| export interface Instruction { | export interface Instruction { | ||||||
|   title: string; |   title: string; | ||||||
| @ -33,13 +35,15 @@ export interface Instruction { | |||||||
| 
 | 
 | ||||||
| export const instructions = new Registry<Instruction>(); | export const instructions = new Registry<Instruction>(); | ||||||
| 
 | 
 | ||||||
|  | instructions.register('condition', condition); | ||||||
|  | instructions.register('parallel', parallel); | ||||||
|  | instructions.register('calculation', calculation); | ||||||
|  | instructions.register('delay', delay); | ||||||
|  | 
 | ||||||
| instructions.register('query', query); | instructions.register('query', query); | ||||||
| instructions.register('create', create); | instructions.register('create', create); | ||||||
| instructions.register('update', update); | instructions.register('update', update); | ||||||
| instructions.register('destroy', destroy); | instructions.register('destroy', destroy); | ||||||
| instructions.register('condition', condition); |  | ||||||
| instructions.register('parallel', parallel); |  | ||||||
| instructions.register('calculation', calculation); |  | ||||||
| 
 | 
 | ||||||
| function useUpdateAction() { | function useUpdateAction() { | ||||||
|   const { t } = useTranslation(); |   const { t } = useTranslation(); | ||||||
| @ -220,7 +224,11 @@ export function NodeDefaultView(props) { | |||||||
|                         'x-component-props': { |                         'x-component-props': { | ||||||
|                           disabled: workflow.executed, |                           disabled: workflow.executed, | ||||||
|                           className: css` |                           className: css` | ||||||
|                             .ant-select{ |                             .ant-select, | ||||||
|  |                             .ant-cascader-picker, | ||||||
|  |                             .ant-picker, | ||||||
|  |                             .ant-input-number, | ||||||
|  |                             .ant-input-affix-wrapper{ | ||||||
|                               width: auto; |                               width: auto; | ||||||
|                             } |                             } | ||||||
|                           ` |                           ` | ||||||
|  | |||||||
| @ -94,8 +94,17 @@ export default class WorkflowPlugin extends Plugin { | |||||||
|         this.toggle(workflow); |         this.toggle(workflow); | ||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
|     // [Life Cycle]: initialize all necessary seed data
 | 
 | ||||||
|     // this.app.on('db.init', async () => {});
 |     this.app.on('beforeStop', async () => { | ||||||
|  |       const collection = db.getCollection('workflows'); | ||||||
|  |       const workflows = await collection.repository.find({ | ||||||
|  |         filter: { enabled: true }, | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       workflows.forEach((workflow: WorkflowModel) => { | ||||||
|  |         this.toggle(workflow, false); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   toggle(workflow: WorkflowModel, enable?: boolean) { |   toggle(workflow: WorkflowModel, enable?: boolean) { | ||||||
|  | |||||||
| @ -148,7 +148,7 @@ export default class Processor { | |||||||
|     let job; |     let job; | ||||||
|     try { |     try { | ||||||
|       // call instruction to get result and status
 |       // call instruction to get result and status
 | ||||||
|       job = await instruction.call(node, prevJob, this); |       job = await instruction(node, prevJob, this); | ||||||
|       if (!job) { |       if (!job) { | ||||||
|         return null; |         return null; | ||||||
|       } |       } | ||||||
| @ -233,13 +233,23 @@ export default class Processor { | |||||||
|   async saveJob(payload) { |   async saveJob(payload) { | ||||||
|     const { database } = <typeof ExecutionModel>this.execution.constructor; |     const { database } = <typeof ExecutionModel>this.execution.constructor; | ||||||
|     const { model } = database.getCollection('jobs'); |     const { model } = database.getCollection('jobs'); | ||||||
|     const [job] = (await model.upsert( |     let job; | ||||||
|       { |     if (payload instanceof model) { | ||||||
|  |       job = await payload.save({ transaction: this.transaction }); | ||||||
|  |     } else if (payload.id) { | ||||||
|  |       [job] = await model.update(payload, { | ||||||
|  |         where: { id: payload.id }, | ||||||
|  |         returning: true, | ||||||
|  |         transaction: this.transaction | ||||||
|  |       }); | ||||||
|  |     } else { | ||||||
|  |       job = await model.create({ | ||||||
|         ...payload, |         ...payload, | ||||||
|         executionId: this.execution.id, |         executionId: this.execution.id, | ||||||
|       }, |       }, { | ||||||
|       { transaction: this.transaction }, |         transaction: this.transaction | ||||||
|     )) as unknown as [JobModel, boolean | null]; |       }); | ||||||
|  |     } | ||||||
|     this.jobsMap.set(job.id, job); |     this.jobsMap.set(job.id, job); | ||||||
|     this.jobsMapByNodeId[job.nodeId] = job.result; |     this.jobsMapByNodeId[job.nodeId] = job.result; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ import { MockServer, mockServer } from '@nocobase/test'; | |||||||
| import Plugin from '..'; | import Plugin from '..'; | ||||||
| import { JOB_STATUS } from '../constants'; | import { JOB_STATUS } from '../constants'; | ||||||
| import calculators from '../calculators'; | import calculators from '../calculators'; | ||||||
|  | import { ApplicationOptions } from '@nocobase/server'; | ||||||
| 
 | 
 | ||||||
| export function sleep(ms: number) { | export function sleep(ms: number) { | ||||||
|   return new Promise(resolve => { |   return new Promise(resolve => { | ||||||
| @ -11,13 +12,17 @@ export function sleep(ms: number) { | |||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getApp(options = {}): Promise<MockServer> { | interface MockAppOptions extends ApplicationOptions { | ||||||
|  |   manual?: boolean; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function getApp({ manual, ...options }: MockAppOptions = {}): Promise<MockServer> { | ||||||
|   const app = mockServer(options); |   const app = mockServer(options); | ||||||
| 
 | 
 | ||||||
|   app.plugin(Plugin, { |   app.plugin(Plugin, { | ||||||
|     instructions: { |     instructions: { | ||||||
|       echo: { |       echo: { | ||||||
|         run({ result }, execution) { |         run(node, { result }, execution) { | ||||||
|           return { |           return { | ||||||
|             status: JOB_STATUS.RESOLVED, |             status: JOB_STATUS.RESOLVED, | ||||||
|             result |             result | ||||||
| @ -26,18 +31,18 @@ export async function getApp(options = {}): Promise<MockServer> { | |||||||
|       }, |       }, | ||||||
| 
 | 
 | ||||||
|       error: { |       error: { | ||||||
|         run(input, execution) { |         run(node, input, execution) { | ||||||
|           throw new Error('definite error'); |           throw new Error('definite error'); | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
| 
 | 
 | ||||||
|       'prompt->error': { |       'prompt->error': { | ||||||
|         run(this, input, execution) { |         run(node, input, execution) { | ||||||
|           return { |           return { | ||||||
|             status: JOB_STATUS.PENDING |             status: JOB_STATUS.PENDING | ||||||
|           }; |           }; | ||||||
|         }, |         }, | ||||||
|         resume(this, input, execution) { |         resume(node, input, execution) { | ||||||
|           throw new Error('input failed'); |           throw new Error('input failed'); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| @ -60,7 +65,9 @@ export async function getApp(options = {}): Promise<MockServer> { | |||||||
|     console.error(error); |     console.error(error); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   await app.start(); |   if (!manual) { | ||||||
|  |     await app.start(); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   return app; |   return app; | ||||||
| } | } | ||||||
|  | |||||||
| @ -0,0 +1,135 @@ | |||||||
|  | import { Application } from '@nocobase/server'; | ||||||
|  | import Database from '@nocobase/database'; | ||||||
|  | import { getApp, sleep } from '..'; | ||||||
|  | import { EXECUTION_STATUS, JOB_STATUS } from '../../constants'; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | describe('workflow > instructions > delay', () => { | ||||||
|  |   let app: Application; | ||||||
|  |   let db: Database; | ||||||
|  |   let PostRepo; | ||||||
|  |   let WorkflowModel; | ||||||
|  |   let workflow; | ||||||
|  | 
 | ||||||
|  |   beforeEach(async () => { | ||||||
|  |     app = await getApp(); | ||||||
|  | 
 | ||||||
|  |     db = app.db; | ||||||
|  |     WorkflowModel = db.getCollection('workflows').model; | ||||||
|  |     PostRepo = db.getCollection('posts').repository; | ||||||
|  | 
 | ||||||
|  |     workflow = await WorkflowModel.create({ | ||||||
|  |       enabled: true, | ||||||
|  |       type: 'collection', | ||||||
|  |       config: { | ||||||
|  |         mode: 1, | ||||||
|  |         collection: 'posts' | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => app.stop()); | ||||||
|  | 
 | ||||||
|  |   describe('runtime', () => { | ||||||
|  |     it('delay to resolved', async () => { | ||||||
|  |       const n1 = await workflow.createNode({ | ||||||
|  |         type: 'delay', | ||||||
|  |         config: { | ||||||
|  |           duration: 1000, | ||||||
|  |           endStatus: JOB_STATUS.RESOLVED | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       const post = await PostRepo.create({ values: { title: 't1' } }); | ||||||
|  | 
 | ||||||
|  |       const [e1] = await workflow.getExecutions(); | ||||||
|  |       expect(e1.status).toEqual(EXECUTION_STATUS.STARTED); | ||||||
|  |       const [j1] = await e1.getJobs(); | ||||||
|  |       expect(j1.status).toBe(JOB_STATUS.PENDING); | ||||||
|  | 
 | ||||||
|  |       await sleep(2000); | ||||||
|  | 
 | ||||||
|  |       const [e2] = await workflow.getExecutions(); | ||||||
|  |       expect(e2.status).toEqual(EXECUTION_STATUS.RESOLVED); | ||||||
|  |       const [j2] = await e2.getJobs(); | ||||||
|  |       expect(j2.status).toBe(JOB_STATUS.RESOLVED); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('delay to reject', async () => { | ||||||
|  |       const n1 = await workflow.createNode({ | ||||||
|  |         type: 'delay', | ||||||
|  |         config: { | ||||||
|  |           duration: 1000, | ||||||
|  |           endStatus: JOB_STATUS.REJECTED | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       const post = await PostRepo.create({ values: { title: 't1' } }); | ||||||
|  | 
 | ||||||
|  |       const [e1] = await workflow.getExecutions(); | ||||||
|  |       expect(e1.status).toEqual(EXECUTION_STATUS.STARTED); | ||||||
|  |       const [j1] = await e1.getJobs(); | ||||||
|  |       expect(j1.status).toBe(JOB_STATUS.PENDING); | ||||||
|  | 
 | ||||||
|  |       await sleep(2000); | ||||||
|  | 
 | ||||||
|  |       const [e2] = await workflow.getExecutions(); | ||||||
|  |       expect(e2.status).toEqual(EXECUTION_STATUS.REJECTED); | ||||||
|  |       const [j2] = await e2.getJobs(); | ||||||
|  |       expect(j2.status).toBe(JOB_STATUS.REJECTED); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('app lifecycle', () => { | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       await workflow.createNode({ | ||||||
|  |         type: 'delay', | ||||||
|  |         config: { | ||||||
|  |           duration: 2000, | ||||||
|  |           endStatus: JOB_STATUS.RESOLVED | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('restart app should trigger delayed job', async () => { | ||||||
|  |       const post = await PostRepo.create({ values: { title: 't1' } }); | ||||||
|  | 
 | ||||||
|  |       const [e1] = await workflow.getExecutions(); | ||||||
|  |       expect(e1.status).toEqual(EXECUTION_STATUS.STARTED); | ||||||
|  |       const [j1] = await e1.getJobs(); | ||||||
|  |       expect(j1.status).toBe(JOB_STATUS.PENDING); | ||||||
|  | 
 | ||||||
|  |       await app.stop(); | ||||||
|  |       await sleep(500); | ||||||
|  | 
 | ||||||
|  |       await app.start(); | ||||||
|  |       await sleep(2000); | ||||||
|  | 
 | ||||||
|  |       const [e2] = await workflow.getExecutions(); | ||||||
|  |       expect(e2.status).toEqual(EXECUTION_STATUS.RESOLVED); | ||||||
|  |       const [j2] = await e2.getJobs(); | ||||||
|  |       expect(j2.status).toBe(JOB_STATUS.RESOLVED); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('restart app should trigger missed delayed job', async () => { | ||||||
|  |       const post = await PostRepo.create({ values: { title: 't1' } }); | ||||||
|  | 
 | ||||||
|  |       const [e1] = await workflow.getExecutions(); | ||||||
|  |       expect(e1.status).toEqual(EXECUTION_STATUS.STARTED); | ||||||
|  |       const [j1] = await e1.getJobs(); | ||||||
|  |       expect(j1.status).toBe(JOB_STATUS.PENDING); | ||||||
|  | 
 | ||||||
|  |       await app.stop(); | ||||||
|  |       await sleep(2000); | ||||||
|  | 
 | ||||||
|  |       await app.start(); | ||||||
|  |       await sleep(500); | ||||||
|  | 
 | ||||||
|  |       const [e2] = await workflow.getExecutions(); | ||||||
|  |       expect(e2.status).toEqual(EXECUTION_STATUS.RESOLVED); | ||||||
|  |       const [j2] = await e2.getJobs(); | ||||||
|  |       expect(j2.status).toBe(JOB_STATUS.RESOLVED); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -30,8 +30,8 @@ import { calculate } from "../calculators"; | |||||||
| // }
 | // }
 | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this: FlowNodeModel, prevJob, processor) { |   async run(node: FlowNodeModel, prevJob, processor) { | ||||||
|     const { calculation } = this.config || {}; |     const { calculation } = node.config || {}; | ||||||
| 
 | 
 | ||||||
|     const result = calculation |     const result = calculation | ||||||
|       ? calculate({ |       ? calculate({ | ||||||
|  | |||||||
| @ -73,10 +73,10 @@ function logicCalculate(calculation, input, processor) { | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this, prevJob, processor) { |   async run(node, prevJob, processor) { | ||||||
|     // TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor
 |     // TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor
 | ||||||
|     // const jobs = await processor.getJobs();
 |     // const jobs = await processor.getJobs();
 | ||||||
|     const { calculation, rejectOnFalse } = this.config || {}; |     const { calculation, rejectOnFalse } = node.config || {}; | ||||||
|     const result = logicCalculate(calculation, prevJob, processor); |     const result = logicCalculate(calculation, prevJob, processor); | ||||||
| 
 | 
 | ||||||
|     if (!result && rejectOnFalse) { |     if (!result && rejectOnFalse) { | ||||||
| @ -90,12 +90,12 @@ export default { | |||||||
|       status: JOB_STATUS.RESOLVED, |       status: JOB_STATUS.RESOLVED, | ||||||
|       result, |       result, | ||||||
|       // TODO(optimize): try unify the building of job
 |       // TODO(optimize): try unify the building of job
 | ||||||
|       nodeId: this.id, |       nodeId: node.id, | ||||||
|       upstreamId: prevJob && prevJob.id || null |       upstreamId: prevJob && prevJob.id || null | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const branchNode = processor.nodes |     const branchNode = processor.nodes | ||||||
|       .find(item => item.upstream === this && Boolean(item.branchIndex) === result); |       .find(item => item.upstream === node && Boolean(item.branchIndex) === result); | ||||||
| 
 | 
 | ||||||
|     if (!branchNode) { |     if (!branchNode) { | ||||||
|       return job; |       return job; | ||||||
| @ -106,13 +106,13 @@ export default { | |||||||
|     return processor.run(branchNode, savedJob); |     return processor.run(branchNode, savedJob); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async resume(this, branchJob, processor) { |   async resume(node, branchJob, processor) { | ||||||
|     if (branchJob.status === JOB_STATUS.RESOLVED) { |     if (branchJob.status === JOB_STATUS.RESOLVED) { | ||||||
|       // return to continue this.downstream
 |       // return to continue node.downstream
 | ||||||
|       return branchJob; |       return branchJob; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // pass control to upper scope by ending current scope
 |     // pass control to upper scope by ending current scope
 | ||||||
|     return processor.end(this, branchJob); |     return processor.end(node, branchJob); | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -2,13 +2,13 @@ import { JOB_STATUS } from "../constants"; | |||||||
| import FlowNodeModel from "../models/FlowNode"; | import FlowNodeModel from "../models/FlowNode"; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this: FlowNodeModel, input, processor) { |   async run(node: FlowNodeModel, input, processor) { | ||||||
|     const { |     const { | ||||||
|       collection, |       collection, | ||||||
|       params = {} |       params = {} | ||||||
|     } = this.config; |     } = node.config; | ||||||
| 
 | 
 | ||||||
|     const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); |     const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection); | ||||||
|     const options = processor.getParsedValue(params); |     const options = processor.getParsedValue(params); | ||||||
|     const result = await repo.create({ |     const result = await repo.create({ | ||||||
|       ...options, |       ...options, | ||||||
|  | |||||||
							
								
								
									
										92
									
								
								packages/plugins/workflow/src/instructions/delay.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/plugins/workflow/src/instructions/delay.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,92 @@ | |||||||
|  | import Plugin from '..'; | ||||||
|  | import { JOB_STATUS } from "../constants"; | ||||||
|  | import ExecutionModel from '../models/Execution'; | ||||||
|  | import JobModel from '../models/Job'; | ||||||
|  | import Processor from '../Processor'; | ||||||
|  | 
 | ||||||
|  | type ValueOf<T> = T[keyof T]; | ||||||
|  | 
 | ||||||
|  | interface DelayConfig { | ||||||
|  |   endStatus: ValueOf<typeof JOB_STATUS>; | ||||||
|  |   duration: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default class { | ||||||
|  |   timers: Map<number, NodeJS.Timeout> = new Map(); | ||||||
|  | 
 | ||||||
|  |   constructor(protected plugin: Plugin) { | ||||||
|  |     plugin.app.on('beforeStart', () => this.load()); | ||||||
|  |     plugin.app.on('beforeStop', () => this.unload()) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async load() { | ||||||
|  |     const { model } = this.plugin.db.getCollection('jobs'); | ||||||
|  |     const jobs = await model.findAll({ | ||||||
|  |       where: { | ||||||
|  |         status: JOB_STATUS.PENDING | ||||||
|  |       }, | ||||||
|  |       include: [ | ||||||
|  |         { | ||||||
|  |           association: 'execution' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           association: 'node', | ||||||
|  |           where: { | ||||||
|  |             type: 'delay' | ||||||
|  |           }, | ||||||
|  |           required: true | ||||||
|  |         } | ||||||
|  |       ] | ||||||
|  |     }) as JobModel[]; | ||||||
|  | 
 | ||||||
|  |     jobs.forEach(job => { | ||||||
|  |       this.schedule(job, job.node.config.duration); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   unload() { | ||||||
|  |     for (const timer of this.timers.values()) { | ||||||
|  |       clearTimeout(timer); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.timers = new Map(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   schedule(job, duration: number) { | ||||||
|  |     const now = new Date(); | ||||||
|  |     const createdAt = Date.parse(job.createdAt); | ||||||
|  |     const delay = createdAt + duration - now.getTime(); | ||||||
|  |     const trigger = this.trigger.bind(this, job); | ||||||
|  |     this.timers.set(job.id, setTimeout(trigger, Math.max(0, delay))); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async trigger(job) { | ||||||
|  |     const { execution = await job.getExecution() as ExecutionModel } = job; | ||||||
|  |     const processor = this.plugin.createProcessor(execution); | ||||||
|  |     await processor.resume(job); | ||||||
|  |     if (this.timers.get(job.id)) { | ||||||
|  |       this.timers.delete(job.id); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   run = async (node, prevJob, processor: Processor) => { | ||||||
|  |     const job = await processor.saveJob({ | ||||||
|  |       status: JOB_STATUS.PENDING, | ||||||
|  |       result: null, | ||||||
|  |       nodeId: node.id, | ||||||
|  |       upstreamId: prevJob?.id ?? null | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const { duration } = node.config as DelayConfig; | ||||||
|  |     // add to schedule
 | ||||||
|  |     this.schedule(job, duration); | ||||||
|  | 
 | ||||||
|  |     return processor.end(node, job); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   resume = async (node, prevJob, processor: Processor) => { | ||||||
|  |     const { endStatus } = node.config as DelayConfig; | ||||||
|  |     prevJob.set('status', endStatus); | ||||||
|  |     return prevJob; | ||||||
|  |   }; | ||||||
|  | } | ||||||
| @ -2,13 +2,13 @@ import { JOB_STATUS } from "../constants"; | |||||||
| import FlowNodeModel from "../models/FlowNode"; | import FlowNodeModel from "../models/FlowNode"; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this: FlowNodeModel, input, processor) { |   async run(node: FlowNodeModel, input, processor) { | ||||||
|     const { |     const { | ||||||
|       collection, |       collection, | ||||||
|       params = {} |       params = {} | ||||||
|     } = this.config; |     } = node.config; | ||||||
| 
 | 
 | ||||||
|     const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); |     const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection); | ||||||
|     const options = processor.getParsedValue(params); |     const options = processor.getParsedValue(params); | ||||||
|     const result = await repo.destroy({ |     const result = await repo.destroy({ | ||||||
|       ...options, |       ...options, | ||||||
|  | |||||||
| @ -1,3 +1,7 @@ | |||||||
|  | import path from 'path'; | ||||||
|  | 
 | ||||||
|  | import { requireModule } from '@nocobase/utils'; | ||||||
|  | 
 | ||||||
| import FlowNodeModel from '../models/FlowNode'; | import FlowNodeModel from '../models/FlowNode'; | ||||||
| 
 | 
 | ||||||
| import Plugin from '..'; | import Plugin from '..'; | ||||||
| @ -24,7 +28,7 @@ export type InstructionResult = Job | Promise<Job>; | |||||||
| // - base on input and context, do any calculations or system call (io), and produce a result or pending.
 | // - base on input and context, do any calculations or system call (io), and produce a result or pending.
 | ||||||
| export interface Instruction { | export interface Instruction { | ||||||
|   run( |   run( | ||||||
|     this: FlowNodeModel, |     node: FlowNodeModel, | ||||||
|     // what should input to be?
 |     // what should input to be?
 | ||||||
|     // - just use previously output result for convenience?
 |     // - just use previously output result for convenience?
 | ||||||
|     input: any, |     input: any, | ||||||
| @ -35,7 +39,7 @@ export interface Instruction { | |||||||
| 
 | 
 | ||||||
|   // for start node in main flow (or branch) to resume when manual sub branch triggered
 |   // for start node in main flow (or branch) to resume when manual sub branch triggered
 | ||||||
|   resume?( |   resume?( | ||||||
|     this: FlowNodeModel, |     node: FlowNodeModel, | ||||||
|     input: any, |     input: any, | ||||||
|     processor: Processor |     processor: Processor | ||||||
|   ): InstructionResult |   ): InstructionResult | ||||||
| @ -56,6 +60,8 @@ export default function<T extends Instruction>( | |||||||
|   instructions.register('update', update); |   instructions.register('update', update); | ||||||
|   instructions.register('destroy', destroy); |   instructions.register('destroy', destroy); | ||||||
| 
 | 
 | ||||||
|  |   instructions.register('delay', new (requireModule(path.join(__dirname, 'delay')))(plugin)); | ||||||
|  | 
 | ||||||
|   for (const [name, instruction] of Object.entries(more)) { |   for (const [name, instruction] of Object.entries(more)) { | ||||||
|     instructions.register(name, typeof instruction === 'function' ? new instruction(plugin) : instruction); |     instructions.register(name, typeof instruction === 'function' ? new instruction(plugin) : instruction); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -40,15 +40,15 @@ const StatusGetters = { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this: FlowNodeModel, prevJob: JobModel, processor: Processor) { |   async run(node: FlowNodeModel, prevJob: JobModel, processor: Processor) { | ||||||
|     const branches = processor.nodes |     const branches = processor.nodes | ||||||
|       .filter(item => item.upstream === this && item.branchIndex !== null) |       .filter(item => item.upstream === node && item.branchIndex !== null) | ||||||
|       .sort((a, b) => a.branchIndex - b.branchIndex); |       .sort((a, b) => a.branchIndex - b.branchIndex); | ||||||
| 
 | 
 | ||||||
|     const job = await processor.saveJob({ |     const job = await processor.saveJob({ | ||||||
|       status: JOB_STATUS.PENDING, |       status: JOB_STATUS.PENDING, | ||||||
|       result: Array(branches.length).fill(null), |       result: Array(branches.length).fill(null), | ||||||
|       nodeId: this.id, |       nodeId: node.id, | ||||||
|       upstreamId: prevJob?.id ?? null |       upstreamId: prevJob?.id ?? null | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| @ -59,11 +59,11 @@ export default { | |||||||
|     // another benifit of this is, it could handle sequenced branches in future.
 |     // another benifit of this is, it could handle sequenced branches in future.
 | ||||||
|     await branches.reduce((promise: Promise<any>, branch) => promise.then(() => processor.run(branch, job)), Promise.resolve()); |     await branches.reduce((promise: Promise<any>, branch) => promise.then(() => processor.run(branch, job)), Promise.resolve()); | ||||||
| 
 | 
 | ||||||
|     return processor.end(this, job); |     return processor.end(node, job); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async resume(this, branchJob, processor: Processor) { |   async resume(node: FlowNodeModel, branchJob, processor: Processor) { | ||||||
|     const job = processor.findBranchParentJob(branchJob, this); |     const job = processor.findBranchParentJob(branchJob, node); | ||||||
| 
 | 
 | ||||||
|     const { result, status } = job; |     const { result, status } = job; | ||||||
|     // if parallel has been done (resolved / rejected), do not care newly executed branch jobs.
 |     // if parallel has been done (resolved / rejected), do not care newly executed branch jobs.
 | ||||||
| @ -74,7 +74,7 @@ export default { | |||||||
|     // find the index of the node which start the branch
 |     // find the index of the node which start the branch
 | ||||||
|     const jobNode = processor.nodesMap.get(branchJob.nodeId); |     const jobNode = processor.nodesMap.get(branchJob.nodeId); | ||||||
|     const { branchIndex } = processor.findBranchStartNode(jobNode); |     const { branchIndex } = processor.findBranchStartNode(jobNode); | ||||||
|     const { mode = PARALLEL_MODE.ALL } = this.config || {}; |     const { mode = PARALLEL_MODE.ALL } = node.config || {}; | ||||||
| 
 | 
 | ||||||
|     const newResult = [...result.slice(0, branchIndex), branchJob.get(), ...result.slice(branchIndex + 1)]; |     const newResult = [...result.slice(0, branchIndex), branchJob.get(), ...result.slice(branchIndex + 1)]; | ||||||
|     job.set({ |     job.set({ | ||||||
| @ -84,7 +84,7 @@ export default { | |||||||
| 
 | 
 | ||||||
|     if (job.status === JOB_STATUS.PENDING) { |     if (job.status === JOB_STATUS.PENDING) { | ||||||
|       await job.save({ transaction: processor.transaction }); |       await job.save({ transaction: processor.transaction }); | ||||||
|       return processor.end(this, job); |       return processor.end(node, job); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return job; |     return job; | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| import { JOB_STATUS } from "../constants"; | import { JOB_STATUS } from "../constants"; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   run(this, input, processor) { |   run(node, input, processor) { | ||||||
|     return { |     return { | ||||||
|       status: JOB_STATUS.PENDING |       status: JOB_STATUS.PENDING | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   resume(this, job, processor) { |   resume(node, job, processor) { | ||||||
|     job.set('status', JOB_STATUS.RESOLVED); |     job.set('status', JOB_STATUS.RESOLVED); | ||||||
|     return job; |     return job; | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -3,14 +3,14 @@ import Processor from "../Processor"; | |||||||
| import { JOB_STATUS } from "../constants"; | import { JOB_STATUS } from "../constants"; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this: FlowNodeModel, input, processor: Processor) { |   async run(node: FlowNodeModel, input, processor: Processor) { | ||||||
|     const { |     const { | ||||||
|       collection, |       collection, | ||||||
|       multiple, |       multiple, | ||||||
|       params = {} |       params = {} | ||||||
|     } = this.config; |     } = node.config; | ||||||
| 
 | 
 | ||||||
|     const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); |     const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection); | ||||||
|     const options = processor.getParsedValue(params); |     const options = processor.getParsedValue(params); | ||||||
|     const result = await (multiple ? repo.find : repo.findOne).call(repo, { |     const result = await (multiple ? repo.find : repo.findOne).call(repo, { | ||||||
|       ...options, |       ...options, | ||||||
|  | |||||||
| @ -3,14 +3,14 @@ import Processor from "../Processor"; | |||||||
| import { JOB_STATUS } from "../constants"; | import { JOB_STATUS } from "../constants"; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   async run(this: FlowNodeModel, input, processor: Processor) { |   async run(node: FlowNodeModel, input, processor: Processor) { | ||||||
|     const { |     const { | ||||||
|       collection, |       collection, | ||||||
|       multiple = false, |       multiple = false, | ||||||
|       params = {} |       params = {} | ||||||
|     } = this.config; |     } = node.config; | ||||||
| 
 | 
 | ||||||
|     const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); |     const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection); | ||||||
|     const options = processor.getParsedValue(params); |     const options = processor.getParsedValue(params); | ||||||
|     const result = await repo.update({ |     const result = await repo.update({ | ||||||
|       ...options, |       ...options, | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user