Merge pull request #171 from nocobase/feat/plugin-workflow
Feature: plugin-workflow MVP
This commit is contained in:
		
						commit
						2924418800
					
				
							
								
								
									
										7
									
								
								packages/plugin-workflow/.npmignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/plugin-workflow/.npmignore
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
node_modules
 | 
			
		||||
*.log
 | 
			
		||||
docs
 | 
			
		||||
__tests__
 | 
			
		||||
tsconfig.json
 | 
			
		||||
src
 | 
			
		||||
.fatherrc.ts
 | 
			
		||||
							
								
								
									
										12
									
								
								packages/plugin-workflow/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								packages/plugin-workflow/package.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@nocobase/plugin-workflow",
 | 
			
		||||
  "version": "0.6.0-alpha.0",
 | 
			
		||||
  "main": "lib/index.js",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
  },
 | 
			
		||||
  "gitHead": "f0b335ac30f29f25c95d7d137655fa64d8d67f1e"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								packages/plugin-workflow/src/__tests__/collections/posts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/plugin-workflow/src/__tests__/collections/posts.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'posts',
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'title',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'boolean',
 | 
			
		||||
      name: 'published',
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
@ -0,0 +1,15 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'targets',
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'col1',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'col2',
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
							
								
								
									
										512
									
								
								packages/plugin-workflow/src/__tests__/execution.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										512
									
								
								packages/plugin-workflow/src/__tests__/execution.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,512 @@
 | 
			
		||||
import { Application } from '@nocobase/server';
 | 
			
		||||
import Database from '@nocobase/database';
 | 
			
		||||
import { getApp } from '.';
 | 
			
		||||
import { BRANCH_INDEX, EXECUTION_STATUS, JOB_STATUS } from '../constants';
 | 
			
		||||
 | 
			
		||||
jest.setTimeout(300000);
 | 
			
		||||
 | 
			
		||||
describe('execution', () => {
 | 
			
		||||
  let app: Application;
 | 
			
		||||
  let db: Database;
 | 
			
		||||
  let PostModel;
 | 
			
		||||
  let WorkflowModel;
 | 
			
		||||
  let workflow;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    app = await getApp();
 | 
			
		||||
 | 
			
		||||
    db = app.db;
 | 
			
		||||
    WorkflowModel = db.getCollection('workflows').model;
 | 
			
		||||
    PostModel = db.getCollection('posts').model;
 | 
			
		||||
 | 
			
		||||
    workflow = await WorkflowModel.create({
 | 
			
		||||
      title: 'test workflow',
 | 
			
		||||
      enabled: true,
 | 
			
		||||
      type: 'model',
 | 
			
		||||
      config: {
 | 
			
		||||
        mode: 1,
 | 
			
		||||
        collection: 'posts'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(() => db.close());
 | 
			
		||||
 | 
			
		||||
  describe('base', () => {
 | 
			
		||||
    it('empty workflow without any nodes', async () => {
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.context.data.title).toEqual(post.title);
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('execute resolved workflow', async () => {
 | 
			
		||||
      await workflow.createNode({
 | 
			
		||||
        title: 'echo',
 | 
			
		||||
        type: 'echo'
 | 
			
		||||
      });
 | 
			
		||||
  
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
  
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
 | 
			
		||||
      expect(execution.start()).rejects.toThrow();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('workflow with single simple node', async () => {
 | 
			
		||||
      await workflow.createNode({
 | 
			
		||||
        title: 'echo',
 | 
			
		||||
        type: 'echo'
 | 
			
		||||
      });
 | 
			
		||||
  
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
  
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.context.data.title).toEqual(post.title);
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
  
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(1);
 | 
			
		||||
      const { status, result } = jobs[0].get();
 | 
			
		||||
      expect(status).toEqual(JOB_STATUS.RESOLVED);
 | 
			
		||||
      expect(result).toMatchObject({ data: JSON.parse(JSON.stringify(post.toJSON())) });
 | 
			
		||||
    });
 | 
			
		||||
  
 | 
			
		||||
    it('workflow with multiple simple nodes', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'echo 1',
 | 
			
		||||
        type: 'echo'
 | 
			
		||||
      });
 | 
			
		||||
  
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'echo 2',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n2);
 | 
			
		||||
  
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
  
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.context.data.title).toEqual(post.title);
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
  
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(2);
 | 
			
		||||
      const { status, result } = jobs[1].get();
 | 
			
		||||
      expect(status).toEqual(JOB_STATUS.RESOLVED);
 | 
			
		||||
      expect(result).toMatchObject({ data: JSON.parse(JSON.stringify(post.toJSON())) });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('workflow with error node', async () => {
 | 
			
		||||
      await workflow.createNode({
 | 
			
		||||
        title: 'error',
 | 
			
		||||
        type: 'error'
 | 
			
		||||
      });
 | 
			
		||||
  
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(1);
 | 
			
		||||
      const { status, result } = jobs[0].get();
 | 
			
		||||
      expect(status).toEqual(JOB_STATUS.REJECTED);
 | 
			
		||||
      expect(result).toBe('Error: definite error');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('manual nodes', () => {
 | 
			
		||||
    it('manual node should suspend execution, and could be manually resume', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'prompt',
 | 
			
		||||
        type: 'prompt',
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n2);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
      const [pending] = await execution.getJobs();
 | 
			
		||||
      expect(pending.status).toEqual(JOB_STATUS.PENDING);
 | 
			
		||||
      expect(pending.result).toEqual(null);
 | 
			
		||||
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(2);
 | 
			
		||||
      expect(jobs[0].status).toEqual(JOB_STATUS.RESOLVED);
 | 
			
		||||
      expect(jobs[0].result).toEqual(123);
 | 
			
		||||
      expect(jobs[1].status).toEqual(JOB_STATUS.RESOLVED);
 | 
			
		||||
      expect(jobs[1].result).toEqual(123);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('manual node should suspend execution, resuming with error should end execution', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'prompt error',
 | 
			
		||||
        type: 'prompt->error',
 | 
			
		||||
      });
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
      await n1.setDownstream(n2);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
      const [pending] = await execution.getJobs();
 | 
			
		||||
      expect(pending.status).toEqual(JOB_STATUS.PENDING);
 | 
			
		||||
      expect(pending.result).toEqual(null);
 | 
			
		||||
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(1);
 | 
			
		||||
      expect(jobs[0].status).toEqual(JOB_STATUS.REJECTED);
 | 
			
		||||
      expect(jobs[0].result).toEqual('Error: input failed');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('branch: condition', () => {
 | 
			
		||||
    it('condition node link to different downstreams', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition',
 | 
			
		||||
        // no config means always true
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'true to echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await workflow.createNode({
 | 
			
		||||
        title: 'false to echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_FALSE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(2);
 | 
			
		||||
      expect(jobs[0].nodeId).toEqual(n1.id);
 | 
			
		||||
      expect(jobs[1].nodeId).toEqual(n2.id);
 | 
			
		||||
      expect(jobs[1].result).toEqual(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('suspend downstream in condition branch, then go on', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition',
 | 
			
		||||
        // no config means always true
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'manual',
 | 
			
		||||
        type: 'prompt',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'echo input value',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n3);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
 | 
			
		||||
      const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(3);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('resume error downstream in condition branch, should reject', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition',
 | 
			
		||||
        // no config means always true
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'manual',
 | 
			
		||||
        type: 'prompt->error',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'echo input value',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n3);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
 | 
			
		||||
      const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(2);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('branch: parallel node', () => {
 | 
			
		||||
    it('link to single branch', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'parallel',
 | 
			
		||||
        type: 'parallel'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'echo1',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 0
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'echo2',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n3);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(3);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('link to multipe branches', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'parallel',
 | 
			
		||||
        type: 'parallel'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'echo1',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 0
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'echo2',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 1
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n4 = await workflow.createNode({
 | 
			
		||||
        title: 'echo on end',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n4);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(4);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('downstream has manual node', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'parallel',
 | 
			
		||||
        type: 'parallel'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'prompt',
 | 
			
		||||
        type: 'prompt',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 0
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 1
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n4 = await workflow.createNode({
 | 
			
		||||
        title: 'echo on end',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n4);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
 | 
			
		||||
      const [pending] = await execution.getJobs({ nodeId: n2.id });
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(4);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('branch: mixed', () => {
 | 
			
		||||
    it('condition branches contains parallel', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'parallel',
 | 
			
		||||
        type: 'parallel',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'prompt',
 | 
			
		||||
        type: 'prompt',
 | 
			
		||||
        upstreamId: n2.id,
 | 
			
		||||
        branchIndex: 0
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n4 = await workflow.createNode({
 | 
			
		||||
        title: 'parallel echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n2.id,
 | 
			
		||||
        branchIndex: 1
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n5 = await workflow.createNode({
 | 
			
		||||
        title: 'last echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n5);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
 | 
			
		||||
      const pendingJobs = await execution.getJobs();
 | 
			
		||||
      expect(pendingJobs.length).toBe(4);
 | 
			
		||||
 | 
			
		||||
      const pending = pendingJobs.find(item => item.nodeId === n3.id );
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(5);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('parallel branches contains condition', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'parallel',
 | 
			
		||||
        type: 'parallel'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'prompt',
 | 
			
		||||
        type: 'prompt',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 0
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition',
 | 
			
		||||
        upstreamId: n1.id,
 | 
			
		||||
        branchIndex: 1
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n4 = await workflow.createNode({
 | 
			
		||||
        title: 'condition echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n3.id,
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n5 = await workflow.createNode({
 | 
			
		||||
        title: 'last echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await n1.setDownstream(n5);
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
 | 
			
		||||
 | 
			
		||||
      const pendingJobs = await execution.getJobs();
 | 
			
		||||
      expect(pendingJobs.length).toBe(4);
 | 
			
		||||
 | 
			
		||||
      const pending = pendingJobs.find(item => item.nodeId === n2.id );
 | 
			
		||||
      pending.set('result', 123);
 | 
			
		||||
      await execution.resume(pending);
 | 
			
		||||
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(jobs.length).toEqual(5);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										61
									
								
								packages/plugin-workflow/src/__tests__/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								packages/plugin-workflow/src/__tests__/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,61 @@
 | 
			
		||||
import path from 'path';
 | 
			
		||||
import { MockServer, mockServer } from '@nocobase/test';
 | 
			
		||||
 | 
			
		||||
import plugin from '../server';
 | 
			
		||||
import { registerInstruction } from '../instructions';
 | 
			
		||||
import { JOB_STATUS } from '../constants';
 | 
			
		||||
 | 
			
		||||
export function sleep(ms: number) {
 | 
			
		||||
  return new Promise(resolve => {
 | 
			
		||||
    setTimeout(resolve, ms);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getApp(options = {}): Promise<MockServer> {
 | 
			
		||||
  const app = mockServer(options);
 | 
			
		||||
 | 
			
		||||
  app.plugin(plugin);
 | 
			
		||||
 | 
			
		||||
  // for test only
 | 
			
		||||
  registerInstruction('echo', {
 | 
			
		||||
    run(this, { result }, execution) {
 | 
			
		||||
      return {
 | 
			
		||||
        status: JOB_STATUS.RESOLVED,
 | 
			
		||||
        result
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  registerInstruction('error', {
 | 
			
		||||
    run(this, input, execution) {
 | 
			
		||||
      throw new Error('definite error');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  registerInstruction('prompt->error', {
 | 
			
		||||
    run(this, input, execution) {
 | 
			
		||||
      return {
 | 
			
		||||
        status: JOB_STATUS.PENDING
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    resume(this, input, execution) {
 | 
			
		||||
      throw new Error('input failed');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  await app.load();
 | 
			
		||||
  
 | 
			
		||||
  await app.db.import({
 | 
			
		||||
    directory: path.resolve(__dirname, './collections')
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await app.db.sync();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
  // TODO: need a better life cycle event than manually trigger
 | 
			
		||||
  await app.emitAsync('beforeStart');
 | 
			
		||||
  
 | 
			
		||||
  return app;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,119 @@
 | 
			
		||||
import { Application } from '@nocobase/server';
 | 
			
		||||
import Database from '@nocobase/database';
 | 
			
		||||
import { getApp } from '..';
 | 
			
		||||
import { EXECUTION_STATUS, BRANCH_INDEX } from '../../constants';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
describe('workflow > instructions > condition', () => {
 | 
			
		||||
  let app: Application;
 | 
			
		||||
  let db: Database;
 | 
			
		||||
  let PostModel;
 | 
			
		||||
  let WorkflowModel;
 | 
			
		||||
  let workflow;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    app = await getApp();
 | 
			
		||||
 | 
			
		||||
    db = app.db;
 | 
			
		||||
    WorkflowModel = db.getCollection('workflows').model;
 | 
			
		||||
    PostModel = db.getCollection('posts').model;
 | 
			
		||||
 | 
			
		||||
    workflow = await WorkflowModel.create({
 | 
			
		||||
      title: 'test workflow',
 | 
			
		||||
      enabled: true,
 | 
			
		||||
      type: 'model',
 | 
			
		||||
      config: {
 | 
			
		||||
        mode: 1,
 | 
			
		||||
        collection: 'posts'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(() => db.close());
 | 
			
		||||
 | 
			
		||||
  describe('config.rejectOnFalse', () => {
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('single calculation', () => {
 | 
			
		||||
    it('calculation to true downstream', async () => {
 | 
			
		||||
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition',
 | 
			
		||||
        config: {
 | 
			
		||||
          // (1 === 1): true
 | 
			
		||||
          calculation: {
 | 
			
		||||
            calculator: 'equal',
 | 
			
		||||
            operands: [{ value: 1 }, { value: 1 }]
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n2 = await workflow.createNode({
 | 
			
		||||
        title: 'true to echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const n3 = await workflow.createNode({
 | 
			
		||||
        title: 'false to echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_FALSE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(2);
 | 
			
		||||
      expect(jobs[1].result).toEqual(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('calculation to false downstream', async () => {
 | 
			
		||||
      const n1 = await workflow.createNode({
 | 
			
		||||
        title: 'condition',
 | 
			
		||||
        type: 'condition',
 | 
			
		||||
        config: {
 | 
			
		||||
          // (0 === 1): false
 | 
			
		||||
          calculation: {
 | 
			
		||||
            calculator: 'equal',
 | 
			
		||||
            operands: [{ value: 0 }, { value: 1 }]
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await workflow.createNode({
 | 
			
		||||
        title: 'true to echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_TRUE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await workflow.createNode({
 | 
			
		||||
        title: 'false to echo',
 | 
			
		||||
        type: 'echo',
 | 
			
		||||
        branchIndex: BRANCH_INDEX.ON_FALSE,
 | 
			
		||||
        upstreamId: n1.id
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const post = await PostModel.create({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [execution] = await workflow.getExecutions();
 | 
			
		||||
      expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
 | 
			
		||||
      const jobs = await execution.getJobs();
 | 
			
		||||
      expect(jobs.length).toEqual(2);
 | 
			
		||||
      expect(jobs[1].result).toEqual(false);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('group calculation', () => {
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										33
									
								
								packages/plugin-workflow/src/collections/executions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/plugin-workflow/src/collections/executions.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'executions',
 | 
			
		||||
  model: 'ExecutionModel',
 | 
			
		||||
  title: '执行流程',
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
      name: 'workflow',
 | 
			
		||||
      title: '所属工作流'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'hasMany',
 | 
			
		||||
      name: 'jobs',
 | 
			
		||||
      title: '流程记录'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'json',
 | 
			
		||||
      type: 'jsonb',
 | 
			
		||||
      name: 'context',
 | 
			
		||||
      title: '上下文数据'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'select',
 | 
			
		||||
      type: 'integer',
 | 
			
		||||
      name: 'status',
 | 
			
		||||
      title: '状态'
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
							
								
								
									
										78
									
								
								packages/plugin-workflow/src/collections/flow_nodes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								packages/plugin-workflow/src/collections/flow_nodes.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,78 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'flow_nodes',
 | 
			
		||||
  // model: 'FlowNodeModel',
 | 
			
		||||
  title: 'Workflow Nodes',
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'string',
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'title',
 | 
			
		||||
      title: '名称',
 | 
			
		||||
      component: {
 | 
			
		||||
        showInTable: true,
 | 
			
		||||
        showInDetail: true,
 | 
			
		||||
        showInForm: true,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    // which workflow belongs to
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      name: 'workflow',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      name: 'upstream',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
      target: 'flow_nodes'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      name: 'branches',
 | 
			
		||||
      type: 'hasMany',
 | 
			
		||||
      target: 'flow_nodes',
 | 
			
		||||
      sourceKey: 'id',
 | 
			
		||||
      foreignKey: 'upstream_id',
 | 
			
		||||
    },
 | 
			
		||||
    // only works when upstream node is branching type, like condition and parallel.
 | 
			
		||||
    // put here because the design of flow-links model is not really necessary for now.
 | 
			
		||||
    // or it should be put into flow-links model.
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'select',
 | 
			
		||||
      name: 'branchIndex',
 | 
			
		||||
      type: 'integer',
 | 
			
		||||
      title: 'branch index'
 | 
			
		||||
    },
 | 
			
		||||
    // for reasons:
 | 
			
		||||
    // 1. redirect type node to solve cycle flow.
 | 
			
		||||
    // 2. recognize as true next node after branches.
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      name: 'downstream',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
      target: 'flow_nodes'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'select',
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'type',
 | 
			
		||||
      title: '类型',
 | 
			
		||||
      // TODO: data for test only now
 | 
			
		||||
      dataSource: [
 | 
			
		||||
        { label: '数据处理', value: 'data' },
 | 
			
		||||
        { label: '数据查询', value: 'query' },
 | 
			
		||||
        { label: '等待人工输入', value: 'prompt' },
 | 
			
		||||
        { label: '条件判断', value: 'condition' },
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'json',
 | 
			
		||||
      type: 'jsonb',
 | 
			
		||||
      name: 'config',
 | 
			
		||||
      title: '配置',
 | 
			
		||||
      defaultValue: {}
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
							
								
								
									
										48
									
								
								packages/plugin-workflow/src/collections/jobs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								packages/plugin-workflow/src/collections/jobs.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,48 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'jobs',
 | 
			
		||||
  title: '流程记录',
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
      name: 'execution',
 | 
			
		||||
      title: '所属流程'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
      name: 'node',
 | 
			
		||||
      target: 'flow_nodes',
 | 
			
		||||
      title: '所属节点'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'belongsTo',
 | 
			
		||||
      name: 'upstream',
 | 
			
		||||
      target: 'jobs',
 | 
			
		||||
      title: '上游记录'
 | 
			
		||||
    },
 | 
			
		||||
    // pending / resolved / rejected
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'status',
 | 
			
		||||
      type: 'integer',
 | 
			
		||||
      name: 'status',
 | 
			
		||||
      title: '处理状态'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'json',
 | 
			
		||||
      type: 'jsonb',
 | 
			
		||||
      name: 'result',
 | 
			
		||||
      title: '处理结果'
 | 
			
		||||
    },
 | 
			
		||||
    // TODO: possibly need node snapshot in case if node has been changed
 | 
			
		||||
    // {
 | 
			
		||||
    //   interface: 'json',
 | 
			
		||||
    //   type: 'jsonb',
 | 
			
		||||
    //   name: 'nodeSnapshot',
 | 
			
		||||
    //   title: 'node snapshot'
 | 
			
		||||
    // }
 | 
			
		||||
  ]
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
							
								
								
									
										56
									
								
								packages/plugin-workflow/src/collections/workflows.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								packages/plugin-workflow/src/collections/workflows.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'workflows',
 | 
			
		||||
  model: 'WorkflowModel',
 | 
			
		||||
  title: '自动化',
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'string',
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'title',
 | 
			
		||||
      title: '自动化名称',
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'boolean',
 | 
			
		||||
      type: 'boolean',
 | 
			
		||||
      name: 'enabled',
 | 
			
		||||
      title: '启用'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'textarea',
 | 
			
		||||
      type: 'text',
 | 
			
		||||
      name: 'description',
 | 
			
		||||
      title: '描述'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'select',
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      title: '触发方式',
 | 
			
		||||
      name: 'type',
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'json',
 | 
			
		||||
      type: 'jsonb',
 | 
			
		||||
      title: '触发配置',
 | 
			
		||||
      name: 'config',
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'hasMany',
 | 
			
		||||
      name: 'nodes',
 | 
			
		||||
      target: 'flow_nodes',
 | 
			
		||||
      title: '流程节点'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      interface: 'linkTo',
 | 
			
		||||
      type: 'hasMany',
 | 
			
		||||
      name: 'executions',
 | 
			
		||||
      target: 'executions',
 | 
			
		||||
      title: '触发执行'
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
							
								
								
									
										19
									
								
								packages/plugin-workflow/src/constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/plugin-workflow/src/constants.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
export const EXECUTION_STATUS = {
 | 
			
		||||
  STARTED: 0,
 | 
			
		||||
  RESOLVED: 1,
 | 
			
		||||
  REJECTED: -1,
 | 
			
		||||
  CANCELLED: -2
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const JOB_STATUS = {
 | 
			
		||||
  PENDING: 0,
 | 
			
		||||
  RESOLVED: 1,
 | 
			
		||||
  REJECTED: -1,
 | 
			
		||||
  CANCELLED: -2
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BRANCH_INDEX = {
 | 
			
		||||
  DEFAULT: null,
 | 
			
		||||
  ON_TRUE: 1,
 | 
			
		||||
  ON_FALSE: 0
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										110
									
								
								packages/plugin-workflow/src/instructions/condition.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								packages/plugin-workflow/src/instructions/condition.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,110 @@
 | 
			
		||||
// config: {
 | 
			
		||||
//   not: false,
 | 
			
		||||
//   group: {
 | 
			
		||||
//     type: 'and',
 | 
			
		||||
//     calculations: [
 | 
			
		||||
//       {
 | 
			
		||||
//         calculator: 'time.equal',
 | 
			
		||||
//         operands: [{ type: 'context', options: { path: 'time' } }, { type: 'fn', options: { name: 'newDate', args: [] } }]
 | 
			
		||||
//       },
 | 
			
		||||
//       {
 | 
			
		||||
//         calculator: 'value.equal',
 | 
			
		||||
//         operands: [{ type: 'job.result', options: { id: 213, path: '' } }, { type: 'constant', value: { a: 1 } }]
 | 
			
		||||
//       }
 | 
			
		||||
//     ]
 | 
			
		||||
//   }
 | 
			
		||||
// }
 | 
			
		||||
 | 
			
		||||
import Sequelize = require('sequelize');
 | 
			
		||||
import { getValue, Operand } from "../utils/getter";
 | 
			
		||||
import { getCalculator } from "../utils/calculators";
 | 
			
		||||
import { JOB_STATUS } from "../constants";
 | 
			
		||||
 | 
			
		||||
type BaseCalculation = {
 | 
			
		||||
  not?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type SingleCalculation = BaseCalculation & {
 | 
			
		||||
  calculation: string;
 | 
			
		||||
  operands?: Operand[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type GroupCalculationOptions = {
 | 
			
		||||
  type: 'and' | 'or';
 | 
			
		||||
  calculations: Calculation[]
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type GroupCalculation = BaseCalculation & {
 | 
			
		||||
  group: GroupCalculationOptions
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// TODO(type)
 | 
			
		||||
type Calculation = SingleCalculation | GroupCalculation;
 | 
			
		||||
 | 
			
		||||
function calculate(config, input, execution) {
 | 
			
		||||
  if (!config) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { not, group } = config;
 | 
			
		||||
  let result;
 | 
			
		||||
  if (group) {
 | 
			
		||||
    const method = group.type === 'and' ? 'every' : 'some';
 | 
			
		||||
    result = group.calculations[method](calculation => calculate(calculation, input, execution));
 | 
			
		||||
  } else {
 | 
			
		||||
    const args = config.operands.map(operand => getValue(operand, input, execution));
 | 
			
		||||
    const fn = getCalculator(config.calculator);
 | 
			
		||||
    if (!fn) {
 | 
			
		||||
      throw new Error(`no calculator function registered for "${config.calculator}"`);
 | 
			
		||||
    }
 | 
			
		||||
    result = fn(...args);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return not ? !result : result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  async run(this, prevJob, execution) {
 | 
			
		||||
    // TODO(optimize): loading of jobs could be reduced and turned into incrementally in execution
 | 
			
		||||
    // const jobs = await execution.getJobs();
 | 
			
		||||
    const { calculation } = this.config || {};
 | 
			
		||||
    const result = calculate(calculation, prevJob, execution);
 | 
			
		||||
 | 
			
		||||
    if (!result && this.config.rejectOnFalse) {
 | 
			
		||||
      return {
 | 
			
		||||
        status: JOB_STATUS.REJECTED,
 | 
			
		||||
        result
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const job = {
 | 
			
		||||
      status: JOB_STATUS.RESOLVED,
 | 
			
		||||
      result,
 | 
			
		||||
      // TODO(optimize): try unify the building of job
 | 
			
		||||
      nodeId: this.id,
 | 
			
		||||
      upstreamId: prevJob instanceof Sequelize.Model ? prevJob.get('id') : null
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const branchNode = execution.nodes
 | 
			
		||||
      .find(item => item.upstream === this && Boolean(item.branchIndex) === result);
 | 
			
		||||
 | 
			
		||||
    if (!branchNode) {
 | 
			
		||||
      return job;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const savedJob = await execution.saveJob(job);
 | 
			
		||||
 | 
			
		||||
    return execution.exec(branchNode, savedJob);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async resume(this, branchJob, execution) {
 | 
			
		||||
    if (branchJob.status === JOB_STATUS.RESOLVED) {
 | 
			
		||||
      // return to continue this.downstream
 | 
			
		||||
      return branchJob;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // pass control to upper scope by ending current scope
 | 
			
		||||
    return execution.end(this, branchJob);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								packages/plugin-workflow/src/instructions/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/plugin-workflow/src/instructions/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
			
		||||
import ExecutionModel from "../models/Execution";
 | 
			
		||||
import FlowNodeModel from "../models/FlowNode";
 | 
			
		||||
 | 
			
		||||
import prompt from './prompt';
 | 
			
		||||
import condition from './condition';
 | 
			
		||||
import parallel from './parallel';
 | 
			
		||||
 | 
			
		||||
export interface Job {
 | 
			
		||||
  status: number;
 | 
			
		||||
  result: unknown;
 | 
			
		||||
  [key: string]: unknown;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type InstructionResult = Job | Promise<Job>;
 | 
			
		||||
 | 
			
		||||
// what should a instruction do?
 | 
			
		||||
// - base on input and context, do any calculations or system call (io), and produce a result or pending.
 | 
			
		||||
export interface Instruction {
 | 
			
		||||
  run(
 | 
			
		||||
    this: FlowNodeModel,
 | 
			
		||||
    // what should input to be?
 | 
			
		||||
    // - just use previously output result for convenience?
 | 
			
		||||
    input: any,
 | 
			
		||||
    // what should context to be?
 | 
			
		||||
    // - could be the workflow execution object (containing context data)
 | 
			
		||||
    execution: ExecutionModel
 | 
			
		||||
  ): InstructionResult;
 | 
			
		||||
  // for start node in main flow (or branch) to resume when manual sub branch triggered
 | 
			
		||||
  resume?(): InstructionResult
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const registery = new Map<string, Instruction>();
 | 
			
		||||
 | 
			
		||||
export function getInstruction(key: string): Instruction {
 | 
			
		||||
  return registery.get(key);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function registerInstruction(key: string, instruction: any) {
 | 
			
		||||
  registery.set(key, instruction);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
registerInstruction('prompt', prompt);
 | 
			
		||||
registerInstruction('condition', condition);
 | 
			
		||||
registerInstruction('parallel', parallel);
 | 
			
		||||
							
								
								
									
										92
									
								
								packages/plugin-workflow/src/instructions/parallel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/plugin-workflow/src/instructions/parallel.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
			
		||||
import { JOB_STATUS } from "../constants";
 | 
			
		||||
import ExecutionModel from "../models/Execution";
 | 
			
		||||
import FlowNodeModel from "../models/FlowNode";
 | 
			
		||||
import JobModel from "../models/Job";
 | 
			
		||||
 | 
			
		||||
export const PARALLEL_MODE = {
 | 
			
		||||
  ALL: 'all',
 | 
			
		||||
  ANY: 'any',
 | 
			
		||||
  RACE: 'race'
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const StatusGetters = {
 | 
			
		||||
  [PARALLEL_MODE.ALL](result) {
 | 
			
		||||
    if (result.some(j => j && j.status === JOB_STATUS.REJECTED)) {
 | 
			
		||||
      return JOB_STATUS.REJECTED;
 | 
			
		||||
    }
 | 
			
		||||
    if (result.every(j => j && j.status === JOB_STATUS.RESOLVED)) {
 | 
			
		||||
      return JOB_STATUS.RESOLVED;
 | 
			
		||||
    }
 | 
			
		||||
    return JOB_STATUS.PENDING;
 | 
			
		||||
  },
 | 
			
		||||
  [PARALLEL_MODE.ANY](result) {
 | 
			
		||||
    return result.some(j => j && j.status === JOB_STATUS.RESOLVED)
 | 
			
		||||
      ? JOB_STATUS.RESOLVED
 | 
			
		||||
      : (
 | 
			
		||||
        result.some(j => j && j.status === JOB_STATUS.PENDING)
 | 
			
		||||
          ? JOB_STATUS.PENDING
 | 
			
		||||
          : JOB_STATUS.REJECTED
 | 
			
		||||
      )
 | 
			
		||||
  },
 | 
			
		||||
  [PARALLEL_MODE.RACE](result) {
 | 
			
		||||
    return result.some(j => j && j.status === JOB_STATUS.RESOLVED)
 | 
			
		||||
      ? JOB_STATUS.RESOLVED
 | 
			
		||||
      : (
 | 
			
		||||
        result.some(j => j && j.status === JOB_STATUS.REJECTED)
 | 
			
		||||
          ? JOB_STATUS.REJECTED
 | 
			
		||||
          : JOB_STATUS.PENDING
 | 
			
		||||
      )
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  async run(this: FlowNodeModel, prevJob: JobModel, execution: ExecutionModel) {
 | 
			
		||||
    const branches = execution.nodes
 | 
			
		||||
      .filter(item => item.upstream === this && item.branchIndex !== null)
 | 
			
		||||
      .sort((a, b) => a.branchIndex - b.branchIndex);
 | 
			
		||||
 | 
			
		||||
    const job = await execution.saveJob({
 | 
			
		||||
      status: JOB_STATUS.PENDING,
 | 
			
		||||
      result: Array(branches.length).fill(null),
 | 
			
		||||
      nodeId: this.id,
 | 
			
		||||
      upstreamId: prevJob?.id ?? null
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // NOTE:
 | 
			
		||||
    // use `reduce` but not `Promise.all` here to avoid racing manupulating db.
 | 
			
		||||
    // for users, this is almost equivalent to `Promise.all`,
 | 
			
		||||
    // because of the delay is not significant sensible.
 | 
			
		||||
    // another better aspect of this is, it could handle sequenced branches in future.
 | 
			
		||||
    await branches.reduce((promise: Promise<any>, branch) => promise.then(() => execution.exec(branch, job)), Promise.resolve());
 | 
			
		||||
 | 
			
		||||
    return execution.end(this, job);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async resume(this, branchJob, execution: ExecutionModel) {
 | 
			
		||||
    const job = execution.findBranchParentJob(branchJob, this);
 | 
			
		||||
 | 
			
		||||
    const { result, status } = job;
 | 
			
		||||
    // if parallel has been done (resolved / rejected), do not care newly executed branch jobs.
 | 
			
		||||
    if (status !== JOB_STATUS.PENDING) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // find the index of the node which start the branch
 | 
			
		||||
    const jobNode = execution.nodesMap.get(branchJob.nodeId);
 | 
			
		||||
    const { branchIndex } = execution.findBranchStartNode(jobNode);
 | 
			
		||||
    const { mode = PARALLEL_MODE.ALL } = this.config || {};
 | 
			
		||||
 | 
			
		||||
    const newResult = [...result.slice(0, branchIndex), branchJob.get(), ...result.slice(branchIndex + 1)];
 | 
			
		||||
    job.set({
 | 
			
		||||
      result: newResult,
 | 
			
		||||
      status: StatusGetters[mode](newResult)
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (job.status === JOB_STATUS.PENDING) {
 | 
			
		||||
      await job.save({ transaction: execution.transaction });
 | 
			
		||||
      return execution.end(this, job);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return job;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								packages/plugin-workflow/src/instructions/prompt.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/plugin-workflow/src/instructions/prompt.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
import { JOB_STATUS } from "../constants";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  run(this, input, execution) {
 | 
			
		||||
    return {
 | 
			
		||||
      status: JOB_STATUS.PENDING
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  resume(this, job, execution) {
 | 
			
		||||
    job.set('status', JOB_STATUS.RESOLVED);
 | 
			
		||||
    return job;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										254
									
								
								packages/plugin-workflow/src/models/Execution.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								packages/plugin-workflow/src/models/Execution.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,254 @@
 | 
			
		||||
import {
 | 
			
		||||
  Model,
 | 
			
		||||
  BelongsToGetAssociationMixin,
 | 
			
		||||
  Optional,
 | 
			
		||||
  HasManyGetAssociationsMixin,
 | 
			
		||||
  Transaction
 | 
			
		||||
} from 'sequelize';
 | 
			
		||||
 | 
			
		||||
import Database from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
import { EXECUTION_STATUS, JOB_STATUS } from '../constants';
 | 
			
		||||
import { getInstruction } from '../instructions';
 | 
			
		||||
import WorkflowModel from './Workflow';
 | 
			
		||||
import FlowNodeModel from './FlowNode';
 | 
			
		||||
import JobModel from './Job';
 | 
			
		||||
 | 
			
		||||
interface ExecutionAttributes {
 | 
			
		||||
  id: number;
 | 
			
		||||
  title: string;
 | 
			
		||||
  context: any;
 | 
			
		||||
  status: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ExecutionCreationAttributes extends Optional<ExecutionAttributes, 'id'> {}
 | 
			
		||||
 | 
			
		||||
export interface ExecutionOptions {
 | 
			
		||||
  transaction?: Transaction;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class ExecutionModel
 | 
			
		||||
  extends Model<ExecutionAttributes, ExecutionCreationAttributes>
 | 
			
		||||
  implements ExecutionAttributes {
 | 
			
		||||
 | 
			
		||||
  declare static readonly database: Database;
 | 
			
		||||
 | 
			
		||||
  declare id: number;
 | 
			
		||||
  declare title: string;
 | 
			
		||||
  declare context: any;
 | 
			
		||||
  declare status: number;
 | 
			
		||||
 | 
			
		||||
  declare createdAt: Date;
 | 
			
		||||
  declare updatedAt: Date;
 | 
			
		||||
 | 
			
		||||
  declare workflow?: WorkflowModel;
 | 
			
		||||
  declare getWorkflow: BelongsToGetAssociationMixin<WorkflowModel>;
 | 
			
		||||
 | 
			
		||||
  declare jobs?: JobModel[];
 | 
			
		||||
  declare getJobs: HasManyGetAssociationsMixin<JobModel>;
 | 
			
		||||
 | 
			
		||||
  options: ExecutionOptions;
 | 
			
		||||
  transaction: Transaction;
 | 
			
		||||
 | 
			
		||||
  nodes: Array<FlowNodeModel> = [];
 | 
			
		||||
  nodesMap = new Map<number, FlowNodeModel>();
 | 
			
		||||
  jobsMap = new Map<number, JobModel>();
 | 
			
		||||
 | 
			
		||||
  static StatusMap = {
 | 
			
		||||
    [JOB_STATUS.PENDING]: EXECUTION_STATUS.STARTED,
 | 
			
		||||
    [JOB_STATUS.RESOLVED]: EXECUTION_STATUS.RESOLVED,
 | 
			
		||||
    [JOB_STATUS.REJECTED]: EXECUTION_STATUS.REJECTED,
 | 
			
		||||
    [JOB_STATUS.CANCELLED]: EXECUTION_STATUS.CANCELLED,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 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: Array<JobModel>) {
 | 
			
		||||
    jobs.forEach(job => {
 | 
			
		||||
      this.jobsMap.set(job.id, job);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async prepare(options) {
 | 
			
		||||
    if (this.status !== EXECUTION_STATUS.STARTED) {
 | 
			
		||||
      throw new Error(`execution was ended with status ${this.status}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.options = options || {};
 | 
			
		||||
    const { transaction = await (<typeof ExecutionModel>this.constructor).database.sequelize.transaction() } = this.options;
 | 
			
		||||
    this.transaction = transaction;
 | 
			
		||||
 | 
			
		||||
    if (!this.workflow) {
 | 
			
		||||
      this.workflow = await this.getWorkflow({ transaction });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nodes = await this.workflow.getNodes({ transaction });
 | 
			
		||||
 | 
			
		||||
    this.makeNodes(nodes);
 | 
			
		||||
 | 
			
		||||
    const jobs = await this.getJobs({ transaction });
 | 
			
		||||
 | 
			
		||||
    this.makeJobs(jobs);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async start(options: ExecutionOptions) {
 | 
			
		||||
    await this.prepare(options);
 | 
			
		||||
    if (this.nodes.length) {
 | 
			
		||||
      const head = this.nodes.find(item => !item.upstream);
 | 
			
		||||
      await this.exec(head, { result: this.context });
 | 
			
		||||
    } else {
 | 
			
		||||
      await this.exit(null);
 | 
			
		||||
    }
 | 
			
		||||
    await this.commit();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async resume(job: JobModel, options: ExecutionOptions) {
 | 
			
		||||
    await this.prepare(options);
 | 
			
		||||
    const node = this.nodesMap.get(job.nodeId);
 | 
			
		||||
    await this.recall(node, job);
 | 
			
		||||
    await this.commit();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async commit() {
 | 
			
		||||
    if (!this.options || !this.options.transaction) {
 | 
			
		||||
      await this.transaction.commit();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async run(instruction, node: FlowNodeModel, prevJob) {
 | 
			
		||||
    let job;
 | 
			
		||||
    try {
 | 
			
		||||
      // call instruction to get result and status
 | 
			
		||||
      job = await instruction.call(node, prevJob, this);
 | 
			
		||||
      if (!job) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      // for uncaught error, set to rejected
 | 
			
		||||
      job = {
 | 
			
		||||
        result: err instanceof Error ? err.toString() : err,
 | 
			
		||||
        status: JOB_STATUS.REJECTED
 | 
			
		||||
      };
 | 
			
		||||
      // if previous job is from resuming
 | 
			
		||||
      if (prevJob && prevJob.nodeId === node.id) {
 | 
			
		||||
        prevJob.set(job);
 | 
			
		||||
        job = prevJob;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let savedJob: JobModel;
 | 
			
		||||
    // TODO(optimize): many checking of resuming or new could be improved
 | 
			
		||||
    // could be implemented separately in exec() / resume()
 | 
			
		||||
    if (job instanceof Model) {
 | 
			
		||||
      savedJob = await job.save({ transaction: this.transaction }) as JobModel;
 | 
			
		||||
    } else {
 | 
			
		||||
      const upstreamId = prevJob instanceof Model ? prevJob.get('id') : null;
 | 
			
		||||
      savedJob = await this.saveJob({
 | 
			
		||||
        nodeId: node.id,
 | 
			
		||||
        upstreamId,
 | 
			
		||||
        ...job
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (savedJob.get('status') === JOB_STATUS.RESOLVED && node.downstream) {
 | 
			
		||||
      // run next node
 | 
			
		||||
      return this.exec(node.downstream, savedJob);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // all nodes in scope have been executed
 | 
			
		||||
    return this.end(node, savedJob);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async exec(node, input?) {
 | 
			
		||||
    const { run } = getInstruction(node.type);
 | 
			
		||||
 | 
			
		||||
    return this.run(run, node, input);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // parent node should take over the control
 | 
			
		||||
  end(node, job) {
 | 
			
		||||
    const parentNode = this.findBranchParentNode(node);
 | 
			
		||||
    // no parent, means on main flow
 | 
			
		||||
    if (parentNode) {
 | 
			
		||||
      return this.recall(parentNode, job);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // really done for all nodes
 | 
			
		||||
    // * should mark execution as done with last job status
 | 
			
		||||
    return this.exit(job);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async recall(node, job) {
 | 
			
		||||
    const { resume } = getInstruction(node.type);
 | 
			
		||||
    if (!resume) {
 | 
			
		||||
      return Promise.reject(new Error('`resume` should be implemented because the node made branch'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.run(resume, node, job);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async exit(job: JobModel | null) {
 | 
			
		||||
    const status = job ? ExecutionModel.StatusMap[job.status] : EXECUTION_STATUS.RESOLVED;
 | 
			
		||||
    await this.update({ status }, { transaction: this.transaction });
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // TODO(optimize)
 | 
			
		||||
  async saveJob(payload) {
 | 
			
		||||
    const { database } = <typeof WorkflowModel>this.constructor;
 | 
			
		||||
    const { model } = database.getCollection('jobs');
 | 
			
		||||
    const [result] = await model.upsert({
 | 
			
		||||
      ...payload,
 | 
			
		||||
      executionId: this.id
 | 
			
		||||
    }, { transaction: this.transaction }) as [JobModel, boolean | null];
 | 
			
		||||
    this.jobsMap.set(result.id, result);
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // find the first node in current branch
 | 
			
		||||
  findBranchStartNode(node: FlowNodeModel): FlowNodeModel | null {
 | 
			
		||||
    for (let n = node; n; n = n.upstream) {
 | 
			
		||||
      if (n.branchIndex !== null) {
 | 
			
		||||
        return n;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // find the node start current branch
 | 
			
		||||
  findBranchParentNode(node: FlowNodeModel): FlowNodeModel | null {
 | 
			
		||||
    for (let n = node; n; n = n.upstream) {
 | 
			
		||||
      if (n.branchIndex !== null) {
 | 
			
		||||
        return n.upstream;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  findBranchParentJob(job: JobModel, node: FlowNodeModel): JobModel | null {
 | 
			
		||||
    for (let j = job; j; j = this.jobsMap.get(j.upstreamId)) {
 | 
			
		||||
      if (j.nodeId === node.id) {
 | 
			
		||||
        return j;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								packages/plugin-workflow/src/models/FlowNode.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/plugin-workflow/src/models/FlowNode.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
import { Model, BelongsToGetAssociationMixin } from 'sequelize';
 | 
			
		||||
import WorkflowModel from './Workflow';
 | 
			
		||||
 | 
			
		||||
export default class FlowNodeModel extends Model {
 | 
			
		||||
  declare id: number;
 | 
			
		||||
  declare title: string;
 | 
			
		||||
  declare branchIndex: null | number;
 | 
			
		||||
  declare type: string;
 | 
			
		||||
  declare config: any;
 | 
			
		||||
 | 
			
		||||
  declare createdAt: Date;
 | 
			
		||||
  declare updatedAt: Date;
 | 
			
		||||
 | 
			
		||||
  declare upstream: FlowNodeModel;
 | 
			
		||||
  declare downstream: FlowNodeModel;
 | 
			
		||||
 | 
			
		||||
  declare workflow?: WorkflowModel;
 | 
			
		||||
  declare getWorkflow: BelongsToGetAssociationMixin<WorkflowModel>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								packages/plugin-workflow/src/models/Job.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/plugin-workflow/src/models/Job.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
import { Model, BelongsToGetAssociationMixin } from 'sequelize';
 | 
			
		||||
import FlowNodeModel from './FlowNode';
 | 
			
		||||
 | 
			
		||||
export default class JobModel extends Model {
 | 
			
		||||
  declare id: number;
 | 
			
		||||
  declare status: number;
 | 
			
		||||
  declare result: any;
 | 
			
		||||
 | 
			
		||||
  declare createdAt: Date;
 | 
			
		||||
  declare updatedAt: Date;
 | 
			
		||||
 | 
			
		||||
  declare upstreamId: number;
 | 
			
		||||
  declare upstream: JobModel;
 | 
			
		||||
 | 
			
		||||
  declare nodeId: number;
 | 
			
		||||
  declare node?: FlowNodeModel;
 | 
			
		||||
  declare getNode: BelongsToGetAssociationMixin<FlowNodeModel>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								packages/plugin-workflow/src/models/Workflow.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/plugin-workflow/src/models/Workflow.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
			
		||||
import { Model, HasManyGetAssociationsMixin, HasManyCreateAssociationMixin } from 'sequelize';
 | 
			
		||||
 | 
			
		||||
import Database from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
import { get as getTrigger } from '../triggers';
 | 
			
		||||
import { EXECUTION_STATUS } from '../constants';
 | 
			
		||||
import ExecutionModel from './Execution';
 | 
			
		||||
import FlowNodeModel from './FlowNode';
 | 
			
		||||
 | 
			
		||||
export default class WorkflowModel extends Model {
 | 
			
		||||
  declare static database: Database;
 | 
			
		||||
 | 
			
		||||
  declare id: number;
 | 
			
		||||
  declare title: string;
 | 
			
		||||
  declare enabled: boolean;
 | 
			
		||||
  declare description?: string;
 | 
			
		||||
  declare type: string;
 | 
			
		||||
  declare config: any;
 | 
			
		||||
 | 
			
		||||
  declare createdAt: Date;
 | 
			
		||||
  declare updatedAt: Date;
 | 
			
		||||
 | 
			
		||||
  declare nodes: FlowNodeModel[];
 | 
			
		||||
  declare getNodes: HasManyGetAssociationsMixin<FlowNodeModel>;
 | 
			
		||||
  declare createNode: HasManyCreateAssociationMixin<FlowNodeModel>;
 | 
			
		||||
 | 
			
		||||
  declare executions: ExecutionModel[];
 | 
			
		||||
  declare getExecutions: HasManyGetAssociationsMixin<ExecutionModel>;
 | 
			
		||||
  declare createExecution: HasManyCreateAssociationMixin<ExecutionModel>;
 | 
			
		||||
 | 
			
		||||
  static async mount() {
 | 
			
		||||
    const collection = this.database.getCollection('workflows');
 | 
			
		||||
    const workflows = await collection.repository.find({
 | 
			
		||||
      filter: { enabled: true }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    workflows.forEach((workflow: WorkflowModel) => {
 | 
			
		||||
      workflow.toggle();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.addHook('afterCreate', (model: WorkflowModel) => model.toggle());
 | 
			
		||||
    this.addHook('afterUpdate', (model: WorkflowModel) => model.toggle());
 | 
			
		||||
    this.addHook('afterDestroy', (model: WorkflowModel) => model.toggle(false));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getHookId() {
 | 
			
		||||
    return `workflow-${this.get('id')}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async toggle(enable?: boolean) {
 | 
			
		||||
    const type = this.get('type');
 | 
			
		||||
    const { on, off } = getTrigger(type);
 | 
			
		||||
    if (typeof enable !== 'undefined' ? enable : this.get('enabled')) {
 | 
			
		||||
      on.call(this, this.start.bind(this));
 | 
			
		||||
    } else {
 | 
			
		||||
      off.call(this);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async start(context: Object, options) {
 | 
			
		||||
    // `null` means not to trigger
 | 
			
		||||
    if (context === null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const execution = await this.createExecution({
 | 
			
		||||
      context,
 | 
			
		||||
      status: EXECUTION_STATUS.STARTED
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    execution.workflow = this;
 | 
			
		||||
 | 
			
		||||
    await execution.start(options);
 | 
			
		||||
    return execution;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										59
									
								
								packages/plugin-workflow/src/server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								packages/plugin-workflow/src/server.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,59 @@
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
import WorkflowModel from './models/Workflow';
 | 
			
		||||
import ExecutionModel from './models/Execution';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'workflow',
 | 
			
		||||
  async load(options = {}) {
 | 
			
		||||
    const { db } = this.app;
 | 
			
		||||
 | 
			
		||||
    db.registerModels({
 | 
			
		||||
      WorkflowModel,
 | 
			
		||||
      ExecutionModel,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await db.import({
 | 
			
		||||
      directory: path.resolve(__dirname, 'collections'),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // [Life Cycle]:
 | 
			
		||||
    //   * load all workflows in db
 | 
			
		||||
    //   * add all hooks for enabled workflows
 | 
			
		||||
    //   * add hooks for create/update[enabled]/delete workflow to add/remove specific hooks
 | 
			
		||||
    this.app.on('beforeStart', async () => {
 | 
			
		||||
      const { model } = db.getCollection('workflows');
 | 
			
		||||
      await model.mount();
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // [Life Cycle]: initialize all necessary seed data
 | 
			
		||||
    this.app.on('db.init', async () => {
 | 
			
		||||
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // const [Automation, AutomationJob] = database.getModels(['automations', 'automations_jobs']);
 | 
			
		||||
 | 
			
		||||
    // Automation.addHook('afterCreate', async (model: AutomationModel) => {
 | 
			
		||||
    //   model.get('enabled') && await model.loadJobs();
 | 
			
		||||
    // });
 | 
			
		||||
 | 
			
		||||
    // Automation.addHook('afterUpdate', async (model: AutomationModel) => {
 | 
			
		||||
    //   if (!model.changed('enabled' as any)) {
 | 
			
		||||
    //     return;
 | 
			
		||||
    //   }
 | 
			
		||||
    //   model.get('enabled') ? await model.loadJobs() : await model.cancelJobs();
 | 
			
		||||
    // });
 | 
			
		||||
 | 
			
		||||
    // Automation.addHook('beforeDestroy', async (model: AutomationModel) => {
 | 
			
		||||
    //   await model.cancelJobs();
 | 
			
		||||
    // });
 | 
			
		||||
 | 
			
		||||
    // AutomationJob.addHook('afterCreate', async (model: AutomationJobModel) => {
 | 
			
		||||
    //   await model.bootstrap();
 | 
			
		||||
    // });
 | 
			
		||||
 | 
			
		||||
    // AutomationJob.addHook('beforeDestroy', async (model: AutomationJobModel) => {
 | 
			
		||||
    //   await model.cancel();
 | 
			
		||||
    // });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								packages/plugin-workflow/src/triggers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/plugin-workflow/src/triggers/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
import WorkflowModel from '../models/Workflow';
 | 
			
		||||
import modelTrigger from './model';
 | 
			
		||||
 | 
			
		||||
export interface Trigger {
 | 
			
		||||
  name: string;
 | 
			
		||||
  on(this: WorkflowModel, callback: Function): void;
 | 
			
		||||
  off(this: WorkflowModel): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const triggers = new Map<string, Trigger>();
 | 
			
		||||
 | 
			
		||||
export function register(type: string, trigger: Trigger): void {
 | 
			
		||||
  triggers.set(type, trigger);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function get(type: string): Trigger | undefined {
 | 
			
		||||
  return triggers.get(type);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
register(modelTrigger.name, modelTrigger);
 | 
			
		||||
							
								
								
									
										44
									
								
								packages/plugin-workflow/src/triggers/model.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/plugin-workflow/src/triggers/model.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
			
		||||
import WorkflowModel from "../models/Workflow";
 | 
			
		||||
 | 
			
		||||
export interface ModelChangeTriggerConfig {
 | 
			
		||||
  collection: string;
 | 
			
		||||
  // TODO: ICondition
 | 
			
		||||
  filter: any;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MODE_BITMAP = {
 | 
			
		||||
  CREATE: 1,
 | 
			
		||||
  UPDATE: 2,
 | 
			
		||||
  DESTROY: 4
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const MODE_BITMAP_EVENTS = new Map();
 | 
			
		||||
MODE_BITMAP_EVENTS.set(MODE_BITMAP.CREATE, 'afterCreate');
 | 
			
		||||
MODE_BITMAP_EVENTS.set(MODE_BITMAP.UPDATE, 'afterUpdate');
 | 
			
		||||
MODE_BITMAP_EVENTS.set(MODE_BITMAP.DESTROY, 'afterDestroy');
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'model',
 | 
			
		||||
  on(this: WorkflowModel, callback: Function) {
 | 
			
		||||
    const { database } = <typeof WorkflowModel>this.constructor;
 | 
			
		||||
    const { collection, mode } = this.config;
 | 
			
		||||
    const { model } = database.getCollection(collection);
 | 
			
		||||
    const handler = (data: any, options) => callback({ data }, options);
 | 
			
		||||
    // TODO: duplication when mode change should be considered
 | 
			
		||||
    for (let [key, event] of MODE_BITMAP_EVENTS.entries()) {
 | 
			
		||||
      if (mode & key) {
 | 
			
		||||
        model.addHook(event, this.getHookId(), handler);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  off(this: WorkflowModel) {
 | 
			
		||||
    const { database } = <typeof WorkflowModel>this.constructor;
 | 
			
		||||
    const { collection, mode } = this.config;
 | 
			
		||||
    const { model } = database.getCollection(collection);
 | 
			
		||||
    for (let [key, event] of MODE_BITMAP_EVENTS.entries()) {
 | 
			
		||||
      if (mode & key) {
 | 
			
		||||
        model.removeHook(event, this.getHookId());
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								packages/plugin-workflow/src/utils/calculators.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/plugin-workflow/src/utils/calculators.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
			
		||||
type Calculator = (...args: any[]) => any;
 | 
			
		||||
 | 
			
		||||
const calculators = new Map<string, Calculator>();
 | 
			
		||||
 | 
			
		||||
export function getCalculator(type: string): Calculator {
 | 
			
		||||
  return calculators.get(type);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function registerCalculator(type: string, fn: Calculator) {
 | 
			
		||||
  calculators.set(type, fn);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function registerCalculators(calculators) {
 | 
			
		||||
  Object.keys(calculators).forEach(key => {
 | 
			
		||||
    registerCalculator(key, calculators[key]);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function equal(a, b) {
 | 
			
		||||
  return a === b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function gt(a, b) {
 | 
			
		||||
  return a > b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function gte(a, b) {
 | 
			
		||||
  return a >= b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function lt(a, b) {
 | 
			
		||||
  return a < b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function lte(a, b) {
 | 
			
		||||
  return a <= b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TODO: add more common calculators
 | 
			
		||||
 | 
			
		||||
registerCalculators({
 | 
			
		||||
  equal,
 | 
			
		||||
  gt,
 | 
			
		||||
  gte,
 | 
			
		||||
  lt,
 | 
			
		||||
  lte,
 | 
			
		||||
  '===': equal,
 | 
			
		||||
  '>': gt,
 | 
			
		||||
  '>=': gte,
 | 
			
		||||
  '<': lt,
 | 
			
		||||
  '<=': lte
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										55
									
								
								packages/plugin-workflow/src/utils/getter.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								packages/plugin-workflow/src/utils/getter.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
			
		||||
import { get } from 'lodash';
 | 
			
		||||
 | 
			
		||||
import ExecutionModel from '../models/Execution';
 | 
			
		||||
 | 
			
		||||
export type OperandType = 'context' | 'input' | 'job';
 | 
			
		||||
 | 
			
		||||
export type ObjectGetterOptions = {
 | 
			
		||||
  path?: string
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type JobGetterOptions = ObjectGetterOptions & {
 | 
			
		||||
  id: number
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ConstantOperand = {
 | 
			
		||||
  type?: 'constant';
 | 
			
		||||
  value: any
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ContextOperand = {
 | 
			
		||||
  type: 'context';
 | 
			
		||||
  options: ObjectGetterOptions;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type InputOperand = {
 | 
			
		||||
  type: 'input';
 | 
			
		||||
  options: ObjectGetterOptions;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type JobOperand = {
 | 
			
		||||
  type: 'job';
 | 
			
		||||
  options: JobGetterOptions;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Operand = ContextOperand | InputOperand | JobOperand | ConstantOperand;
 | 
			
		||||
 | 
			
		||||
// TODO: other instructions may also use this method, could be moved to utils.
 | 
			
		||||
export function getValue(operand: Operand, input: any, execution: ExecutionModel) {
 | 
			
		||||
  switch (operand.type) {
 | 
			
		||||
    // from execution context
 | 
			
		||||
    case 'context':
 | 
			
		||||
      return get(execution, operand.options.path);
 | 
			
		||||
    // from input from last job or manual
 | 
			
		||||
    case 'input':
 | 
			
		||||
      return get(input, operand.options.path);
 | 
			
		||||
    // from job in execution
 | 
			
		||||
    case 'job':
 | 
			
		||||
      // assume jobs have been fetched from execution before
 | 
			
		||||
      const job = execution.jobsMap.get(operand.options.id);
 | 
			
		||||
      return get(job, operand.options.path);
 | 
			
		||||
    // constant
 | 
			
		||||
    default:
 | 
			
		||||
      return operand.value;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user