@nocobase/plugin-workflow-loop 0.17.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ import { Processor, Instruction, JOB_STATUS, FlowNodeModel, JobModel } from '@nocobase/plugin-workflow';
2
+
3
+ function getTargetLength(target) {
4
+ let length = 0;
5
+ if (typeof target === 'number') {
6
+ if (target < 0) {
7
+ throw new Error('Loop target in number type must be greater than 0');
8
+ }
9
+ length = Math.floor(target);
10
+ } else {
11
+ const targets = (Array.isArray(target) ? target : [target]).filter((t) => t != null);
12
+ length = targets.length;
13
+ }
14
+ return length;
15
+ }
16
+
17
+ export default class extends Instruction {
18
+ async run(node: FlowNodeModel, prevJob: JobModel, processor: Processor) {
19
+ const [branch] = processor.getBranches(node);
20
+ const target = processor.getParsedValue(node.config.target, node.id);
21
+ const length = getTargetLength(target);
22
+
23
+ if (!branch || !length) {
24
+ return {
25
+ status: JOB_STATUS.RESOLVED,
26
+ result: 0,
27
+ };
28
+ }
29
+
30
+ const job = await processor.saveJob({
31
+ status: JOB_STATUS.PENDING,
32
+ // save loop index
33
+ result: 0,
34
+ nodeId: node.id,
35
+ upstreamId: prevJob?.id ?? null,
36
+ });
37
+
38
+ // TODO: add loop scope to stack
39
+ // processor.stack.push({
40
+ // label: node.title,
41
+ // value: node.id
42
+ // });
43
+
44
+ await processor.run(branch, job);
45
+
46
+ return null;
47
+ }
48
+
49
+ async resume(node: FlowNodeModel, branchJob, processor: Processor) {
50
+ const job = processor.findBranchParentJob(branchJob, node) as JobModel;
51
+ const loop = processor.nodesMap.get(job.nodeId);
52
+ const [branch] = processor.getBranches(node);
53
+
54
+ const { result, status } = job;
55
+ // if loop has been done (resolved / rejected), do not care newly executed branch jobs.
56
+ if (status !== JOB_STATUS.PENDING) {
57
+ return processor.exit();
58
+ }
59
+
60
+ const nextIndex = result + 1;
61
+
62
+ const target = processor.getParsedValue(loop.config.target, node.id);
63
+ // branchJob.status === JOB_STATUS.RESOLVED means branchJob is done, try next loop or exit as resolved
64
+ if (branchJob.status > JOB_STATUS.PENDING) {
65
+ job.set({ result: nextIndex });
66
+
67
+ const length = getTargetLength(target);
68
+ if (nextIndex < length) {
69
+ await processor.saveJob(job);
70
+ await processor.run(branch, job);
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // branchJob.status < JOB_STATUS.PENDING means branchJob is rejected, any rejection should cause loop rejected
76
+ job.set({
77
+ status: branchJob.status,
78
+ });
79
+
80
+ return job;
81
+ }
82
+
83
+ getScope(node, index, processor) {
84
+ const target = processor.getParsedValue(node.config.target, node.id);
85
+ const targets = (Array.isArray(target) ? target : [target]).filter((t) => t != null);
86
+ const length = getTargetLength(target);
87
+ const item = typeof target === 'number' ? index : targets[index];
88
+
89
+ const result = {
90
+ item,
91
+ index,
92
+ length,
93
+ };
94
+
95
+ return result;
96
+ }
97
+ }
@@ -0,0 +1,14 @@
1
+ import { Plugin } from '@nocobase/server';
2
+ import { default as WorkflowPlugin } from '@nocobase/plugin-workflow';
3
+
4
+ import LoopInstruction from './LoopInstruction';
5
+
6
+ export default class extends Plugin {
7
+ workflow: WorkflowPlugin;
8
+
9
+ async load() {
10
+ const workflowPlugin = this.app.getPlugin('workflow') as WorkflowPlugin;
11
+ this.workflow = workflowPlugin;
12
+ workflowPlugin.instructions.register('loop', new LoopInstruction(workflowPlugin));
13
+ }
14
+ }
@@ -0,0 +1,476 @@
1
+ import Database from '@nocobase/database';
2
+ import { Application } from '@nocobase/server';
3
+ import { BRANCH_INDEX, EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
4
+ import { getApp, sleep } from '@nocobase/plugin-workflow-test';
5
+
6
+ import Plugin from '..';
7
+
8
+ describe('workflow > instructions > loop', () => {
9
+ let app: Application;
10
+ let db: Database;
11
+ let PostRepo;
12
+ let WorkflowModel;
13
+ let workflow;
14
+ let plugin;
15
+
16
+ beforeEach(async () => {
17
+ app = await getApp({
18
+ plugins: [Plugin],
19
+ });
20
+ plugin = app.pm.get('workflow');
21
+
22
+ db = app.db;
23
+ WorkflowModel = db.getCollection('workflows').model;
24
+ PostRepo = db.getCollection('posts').repository;
25
+
26
+ workflow = await WorkflowModel.create({
27
+ enabled: true,
28
+ type: 'collection',
29
+ config: {
30
+ mode: 1,
31
+ collection: 'posts',
32
+ },
33
+ });
34
+ });
35
+
36
+ afterEach(() => app.destroy());
37
+
38
+ describe('branch', () => {
39
+ it('no branch just pass', async () => {
40
+ const n1 = await workflow.createNode({
41
+ type: 'loop',
42
+ });
43
+
44
+ const n2 = await workflow.createNode({
45
+ type: 'echo',
46
+ upstreamId: n1.id,
47
+ });
48
+
49
+ await n1.setDownstream(n2);
50
+
51
+ const post = await PostRepo.create({ values: { title: 't1' } });
52
+
53
+ await sleep(500);
54
+
55
+ const [execution] = await workflow.getExecutions();
56
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
57
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
58
+ expect(jobs.length).toBe(2);
59
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
60
+ expect(jobs[0].result).toBe(0);
61
+ });
62
+
63
+ it('should exit when branch meets error', async () => {
64
+ const n1 = await workflow.createNode({
65
+ type: 'loop',
66
+ config: {
67
+ target: 2,
68
+ },
69
+ });
70
+
71
+ const n2 = await workflow.createNode({
72
+ type: 'error',
73
+ upstreamId: n1.id,
74
+ branchIndex: 0,
75
+ });
76
+
77
+ const n3 = await workflow.createNode({
78
+ type: 'echo',
79
+ upstreamId: n1.id,
80
+ });
81
+
82
+ await n1.setDownstream(n3);
83
+
84
+ const post = await PostRepo.create({ values: { title: 't1' } });
85
+
86
+ await sleep(500);
87
+
88
+ const [execution] = await workflow.getExecutions();
89
+ expect(execution.status).toBe(EXECUTION_STATUS.ERROR);
90
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
91
+ expect(jobs.length).toBe(2);
92
+ expect(jobs[0].status).toBe(JOB_STATUS.ERROR);
93
+ expect(jobs[0].result).toBe(0);
94
+ expect(jobs[1].status).toBe(JOB_STATUS.ERROR);
95
+ });
96
+ });
97
+
98
+ describe('config', () => {
99
+ it('no target just pass', async () => {
100
+ const n1 = await workflow.createNode({
101
+ type: 'loop',
102
+ });
103
+
104
+ const n2 = await workflow.createNode({
105
+ type: 'echo',
106
+ upstreamId: n1.id,
107
+ branchIndex: 0,
108
+ });
109
+
110
+ const n3 = await workflow.createNode({
111
+ type: 'echo',
112
+ upstreamId: n1.id,
113
+ });
114
+
115
+ await n1.setDownstream(n3);
116
+
117
+ const post = await PostRepo.create({ values: { title: 't1' } });
118
+
119
+ await sleep(500);
120
+
121
+ const [execution] = await workflow.getExecutions();
122
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
123
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
124
+ expect(jobs.length).toBe(2);
125
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
126
+ expect(jobs[0].result).toBe(0);
127
+ });
128
+
129
+ it('null target just pass', async () => {
130
+ const n1 = await workflow.createNode({
131
+ type: 'loop',
132
+ config: {
133
+ target: null,
134
+ },
135
+ });
136
+
137
+ const n2 = await workflow.createNode({
138
+ type: 'echo',
139
+ upstreamId: n1.id,
140
+ branchIndex: 0,
141
+ });
142
+
143
+ const n3 = await workflow.createNode({
144
+ type: 'echo',
145
+ upstreamId: n1.id,
146
+ });
147
+
148
+ await n1.setDownstream(n3);
149
+
150
+ const post = await PostRepo.create({ values: { title: 't1' } });
151
+
152
+ await sleep(500);
153
+
154
+ const [execution] = await workflow.getExecutions();
155
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
156
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
157
+ expect(jobs.length).toBe(2);
158
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
159
+ expect(jobs[0].result).toBe(0);
160
+ });
161
+
162
+ it('empty array just pass', async () => {
163
+ const n1 = await workflow.createNode({
164
+ type: 'loop',
165
+ config: {
166
+ target: [],
167
+ },
168
+ });
169
+
170
+ const n2 = await workflow.createNode({
171
+ type: 'echo',
172
+ upstreamId: n1.id,
173
+ branchIndex: 0,
174
+ });
175
+
176
+ const n3 = await workflow.createNode({
177
+ type: 'echo',
178
+ upstreamId: n1.id,
179
+ });
180
+
181
+ await n1.setDownstream(n3);
182
+
183
+ const post = await PostRepo.create({ values: { title: 't1' } });
184
+
185
+ await sleep(500);
186
+
187
+ const [execution] = await workflow.getExecutions();
188
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
189
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
190
+ expect(jobs.length).toBe(2);
191
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
192
+ expect(jobs[0].result).toBe(0);
193
+ });
194
+
195
+ it('target is number, cycle number times', async () => {
196
+ const n1 = await workflow.createNode({
197
+ type: 'loop',
198
+ config: {
199
+ target: 2.5,
200
+ },
201
+ });
202
+
203
+ const n2 = await workflow.createNode({
204
+ type: 'echo',
205
+ upstreamId: n1.id,
206
+ branchIndex: 0,
207
+ });
208
+
209
+ const n3 = await workflow.createNode({
210
+ type: 'echo',
211
+ upstreamId: n1.id,
212
+ });
213
+
214
+ await n1.setDownstream(n3);
215
+
216
+ const post = await PostRepo.create({ values: { title: 't1' } });
217
+
218
+ await sleep(500);
219
+
220
+ const [execution] = await workflow.getExecutions();
221
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
222
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
223
+
224
+ expect(jobs.length).toBe(4);
225
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
226
+ expect(jobs[0].result).toBe(2);
227
+ });
228
+
229
+ it('target is no array, set as an array', async () => {
230
+ const n1 = await workflow.createNode({
231
+ type: 'loop',
232
+ config: {
233
+ target: {},
234
+ },
235
+ });
236
+
237
+ const n2 = await workflow.createNode({
238
+ type: 'echo',
239
+ upstreamId: n1.id,
240
+ branchIndex: 0,
241
+ });
242
+
243
+ const n3 = await workflow.createNode({
244
+ type: 'echo',
245
+ upstreamId: n1.id,
246
+ });
247
+
248
+ await n1.setDownstream(n3);
249
+
250
+ const post = await PostRepo.create({ values: { title: 't1' } });
251
+
252
+ await sleep(500);
253
+
254
+ const [execution] = await workflow.getExecutions();
255
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
256
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
257
+
258
+ expect(jobs.length).toBe(3);
259
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
260
+ expect(jobs[0].result).toBe(1);
261
+ });
262
+
263
+ it('multiple targets', async () => {
264
+ const n1 = await workflow.createNode({
265
+ type: 'loop',
266
+ config: {
267
+ target: [1, 2],
268
+ },
269
+ });
270
+
271
+ const n2 = await workflow.createNode({
272
+ type: 'echo',
273
+ upstreamId: n1.id,
274
+ branchIndex: 0,
275
+ });
276
+
277
+ const n3 = await workflow.createNode({
278
+ type: 'echo',
279
+ upstreamId: n1.id,
280
+ });
281
+
282
+ await n1.setDownstream(n3);
283
+
284
+ const post = await PostRepo.create({ values: { title: 't1' } });
285
+
286
+ await sleep(500);
287
+
288
+ const [execution] = await workflow.getExecutions();
289
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
290
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
291
+
292
+ expect(jobs.length).toBe(4);
293
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
294
+ expect(jobs[0].result).toBe(2);
295
+ expect(jobs.filter((j) => j.nodeId === n2.id).length).toBe(2);
296
+ });
297
+ });
298
+
299
+ describe('scope variable', () => {
300
+ it('item.key', async () => {
301
+ const n1 = await workflow.createNode({
302
+ type: 'loop',
303
+ config: {
304
+ target: '{{$context.data.comments}}',
305
+ },
306
+ });
307
+
308
+ const n2 = await workflow.createNode({
309
+ type: 'calculation',
310
+ config: {
311
+ engine: 'formula.js',
312
+ expression: `{{$scopes.${n1.id}.item.content}}`,
313
+ },
314
+ upstreamId: n1.id,
315
+ branchIndex: 0,
316
+ });
317
+
318
+ const post = await PostRepo.create({
319
+ values: {
320
+ title: 't1',
321
+ comments: [{ content: 'c1' }, { content: 'c2' }],
322
+ },
323
+ });
324
+
325
+ await sleep(500);
326
+
327
+ const [execution] = await workflow.getExecutions();
328
+ expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
329
+ const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
330
+ expect(jobs.length).toBe(3);
331
+ expect(jobs[1].result).toBe('c1');
332
+ expect(jobs[2].result).toBe('c2');
333
+ });
334
+ });
335
+
336
+ describe('mixed', () => {
337
+ it.skip('loop branch contains parallel branches', async () => {
338
+ const n1 = await workflow.createNode({
339
+ type: 'loop',
340
+ config: {
341
+ target: 2,
342
+ },
343
+ });
344
+
345
+ const n2 = await workflow.createNode({
346
+ type: 'parallel',
347
+ branchIndex: 0,
348
+ upstreamId: n1.id,
349
+ config: {
350
+ mode: 'any',
351
+ },
352
+ });
353
+
354
+ const n3 = await workflow.createNode({
355
+ type: 'condition',
356
+ config: {
357
+ rejectOnFalse: true,
358
+ calculation: {
359
+ calculator: '<',
360
+ operands: [`{{$scopes.${n1.id}.item}}`, 1],
361
+ },
362
+ },
363
+ branchIndex: 0,
364
+ upstreamId: n2.id,
365
+ });
366
+ const n4 = await workflow.createNode({
367
+ type: 'echo',
368
+ upstreamId: n3.id,
369
+ });
370
+ await n3.setDownstream(n4);
371
+
372
+ const n5 = await workflow.createNode({
373
+ type: 'condition',
374
+ config: {
375
+ rejectOnFalse: true,
376
+ calculation: {
377
+ calculator: '<',
378
+ operands: [`{{$scopes.${n1.id}.item}}`, 1],
379
+ },
380
+ },
381
+ branchIndex: 1,
382
+ upstreamId: n2.id,
383
+ });
384
+ const n6 = await workflow.createNode({
385
+ type: 'echo',
386
+ upstreamId: n5.id,
387
+ });
388
+ await n5.setDownstream(n6);
389
+
390
+ const post = await PostRepo.create({ values: { title: 't1' } });
391
+
392
+ await sleep(1000);
393
+
394
+ const [e1] = await workflow.getExecutions();
395
+ expect(e1.status).toEqual(EXECUTION_STATUS.FAILED);
396
+ const e1jobs = await e1.getJobs();
397
+ expect(e1jobs.length).toBe(7);
398
+ });
399
+
400
+ it('condition contains loop (target as 0)', async () => {
401
+ const n1 = await workflow.createNode({
402
+ type: 'condition',
403
+ });
404
+
405
+ const n2 = await workflow.createNode({
406
+ type: 'loop',
407
+ branchIndex: BRANCH_INDEX.ON_TRUE,
408
+ upstreamId: n1.id,
409
+ config: {
410
+ target: 0,
411
+ },
412
+ });
413
+
414
+ const n3 = await workflow.createNode({
415
+ type: 'echo',
416
+ branchIndex: 0,
417
+ upstreamId: n2.id,
418
+ });
419
+
420
+ const n4 = await workflow.createNode({
421
+ type: 'echo',
422
+ upstreamId: n1.id,
423
+ });
424
+
425
+ await n1.setDownstream(n4);
426
+
427
+ const post = await PostRepo.create({ values: { title: 't1' } });
428
+
429
+ await sleep(500);
430
+
431
+ const [e1] = await workflow.getExecutions();
432
+ expect(e1.status).toEqual(EXECUTION_STATUS.RESOLVED);
433
+ const jobs = await e1.getJobs({ order: [['id', 'ASC']] });
434
+ expect(jobs.length).toBe(3);
435
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
436
+ });
437
+
438
+ it('condition contains loop (target as 2)', async () => {
439
+ const n1 = await workflow.createNode({
440
+ type: 'condition',
441
+ });
442
+
443
+ const n2 = await workflow.createNode({
444
+ type: 'loop',
445
+ branchIndex: BRANCH_INDEX.ON_TRUE,
446
+ upstreamId: n1.id,
447
+ config: {
448
+ target: 2,
449
+ },
450
+ });
451
+
452
+ const n3 = await workflow.createNode({
453
+ type: 'echo',
454
+ branchIndex: 0,
455
+ upstreamId: n2.id,
456
+ });
457
+
458
+ const n4 = await workflow.createNode({
459
+ type: 'echo',
460
+ upstreamId: n1.id,
461
+ });
462
+
463
+ await n1.setDownstream(n4);
464
+
465
+ const post = await PostRepo.create({ values: { title: 't1' } });
466
+
467
+ await sleep(500);
468
+
469
+ const [e1] = await workflow.getExecutions();
470
+ expect(e1.status).toEqual(EXECUTION_STATUS.RESOLVED);
471
+ const jobs = await e1.getJobs({ order: [['id', 'ASC']] });
472
+ expect(jobs.length).toBe(5);
473
+ expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED);
474
+ });
475
+ });
476
+ });
@@ -0,0 +1 @@
1
+ export { default } from './Plugin';