@xeplr/workflow-engine 3.0.3 → 3.2.0
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.
- package/README.md +12 -0
- package/lib/actionCatalog.js +4 -1
- package/lib/flows.js +78 -2
- package/lib/workflowRunner.js +42 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -105,6 +105,18 @@ host gives workflow is the boundary.
|
|
|
105
105
|
first match wins (`@xeplr/expression-handler`); a blank condition is the
|
|
106
106
|
catch-all; targets are a `stepKey`, `end_success` or `end_failed`. No match
|
|
107
107
|
and no catch-all **fails the run**.
|
|
108
|
+
- **Screens hand over to screens**: a flow's run view carries `previous` — what
|
|
109
|
+
every screen submitted earlier in the run, keyed by screen key, the saved row
|
|
110
|
+
as `values.id` (`lib/flows.js` `previousOf`). Read-only, built from the step
|
|
111
|
+
runs; nothing new is stored, and nothing is checked between screens.
|
|
112
|
+
- **Letting a function decide**: a transition `{ decide: 'routeByAmount', targets?: [...] }`
|
|
113
|
+
asks a function the app registered with `@xeplr/actions` under `event: 'decide'`
|
|
114
|
+
where to go. It is called with `{ output, values (= output), params, item, trigger,
|
|
115
|
+
step, runId, targets }` and returns a step key, `end_success` or `end_failed`;
|
|
116
|
+
`targets`, when listed, are the steps it may choose (none: any step). A missing
|
|
117
|
+
function, one written for another event, no answer, or an answer outside
|
|
118
|
+
`targets` **fails the run**, naming the function. Functions registered with an
|
|
119
|
+
`event` are screen functions, never steps: the action catalog leaves them out.
|
|
108
120
|
- **Fan-out**: a matching `each` transition creates one child run per matching
|
|
109
121
|
element (`item`), recorded in `workflow_run_edges`. With `joinStep` the parent
|
|
110
122
|
waits and resumes at the join once all children are terminal (guarded by an
|
package/lib/actionCatalog.js
CHANGED
|
@@ -173,7 +173,10 @@ function catalog() {
|
|
|
173
173
|
// absent here, so no palette ever draws it. Filtered at the point the list
|
|
174
174
|
// leaves the process rather than by not registering it — the two questions
|
|
175
175
|
// ("can this run" and "should anyone pick it") have different answers.
|
|
176
|
-
|
|
176
|
+
// A SCREEN FUNCTION (registered with an `event`: beforeSave, decide, …) is
|
|
177
|
+
// not a step — a screen's events and a flow's arrows pick it by name. A
|
|
178
|
+
// palette offering it as a step would run it with the wrong context.
|
|
179
|
+
return HIDDEN_NAMES.indexOf(a.name) === -1 && !a.event;
|
|
177
180
|
}).map(function(a) {
|
|
178
181
|
return {
|
|
179
182
|
name: a.name,
|
package/lib/flows.js
CHANGED
|
@@ -72,6 +72,9 @@ var FIELD_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
|
72
72
|
// key. Restated here because they are part of this facade's contract too.
|
|
73
73
|
var END_TARGETS = ['end_success', 'end_failed'];
|
|
74
74
|
|
|
75
|
+
// A registered function's name — the same rule @xeplr/ui-factory's FUNCTION_NAME applies.
|
|
76
|
+
var FUNCTION_NAME = /^[A-Za-z_][\w.-]{0,99}$/;
|
|
77
|
+
|
|
75
78
|
// Which run statuses mean "still going". `queued` is only ever a fanned-out
|
|
76
79
|
// child's starting state, and a screens flow does not fan out — it is in the
|
|
77
80
|
// list because the column has it, not because a flow reaches it.
|
|
@@ -298,6 +301,9 @@ function stepView(step) {
|
|
|
298
301
|
screen: values.screen || null,
|
|
299
302
|
layout: step.layout || null,
|
|
300
303
|
transitions: (step.transitions || []).map(function(t) {
|
|
304
|
+
// A function deciding is said as itself: its name, and the steps it may
|
|
305
|
+
// choose from (none listed: any step of the flow).
|
|
306
|
+
if (t.decide) return { when: conditionToWhen(t.condition), decide: t.decide, targets: t.targets || [] };
|
|
301
307
|
return { when: conditionToWhen(t.condition), target: t.target };
|
|
302
308
|
})
|
|
303
309
|
};
|
|
@@ -408,8 +414,26 @@ function toStepRows(workflowId, steps) {
|
|
|
408
414
|
if (!t || typeof t !== 'object' || Array.isArray(t)) {
|
|
409
415
|
throw fail(400, 'FLOW_INVALID', label + ': must be an object.');
|
|
410
416
|
}
|
|
417
|
+
// LET A FUNCTION DECIDE: `decide` names a registered function (event
|
|
418
|
+
// 'decide') that returns the next step; `targets`, optional, are the
|
|
419
|
+
// steps it may choose from. Its own arrow has no single target.
|
|
420
|
+
if (t.decide !== undefined && t.decide !== null && t.decide !== '') {
|
|
421
|
+
var fn = String(t.decide).trim();
|
|
422
|
+
if (!FUNCTION_NAME.test(fn)) throw fail(400, 'FLOW_INVALID', label + ': `decide` must be the name of a registered function, e.g. "routeByAmount".');
|
|
423
|
+
var targets = t.targets === undefined || t.targets === null ? [] : t.targets;
|
|
424
|
+
if (!Array.isArray(targets) || !targets.every(function(x) { return typeof x === 'string' && x.trim(); })) {
|
|
425
|
+
throw fail(400, 'FLOW_INVALID', label + ': `targets` must be a list of step keys the function may choose from.');
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
condition: whenToCondition(t.when, label),
|
|
429
|
+
mode: 'single',
|
|
430
|
+
decide: fn,
|
|
431
|
+
targets: targets.map(function(x) { return x.trim(); }),
|
|
432
|
+
target: null
|
|
433
|
+
};
|
|
434
|
+
}
|
|
411
435
|
var target = String(t.target == null ? '' : t.target).trim();
|
|
412
|
-
if (!target) throw fail(400, 'FLOW_INVALID', label + ': `target` is required.');
|
|
436
|
+
if (!target) throw fail(400, 'FLOW_INVALID', label + ': `target` is required — or `decide`, to let a function choose.');
|
|
413
437
|
return {
|
|
414
438
|
condition: whenToCondition(t.when, label),
|
|
415
439
|
// Single, always. `each` fans a transition out into one child run per
|
|
@@ -447,6 +471,11 @@ function toStepRows(workflowId, steps) {
|
|
|
447
471
|
|
|
448
472
|
// ── what publishing checks ──────────────────────────────────────────────
|
|
449
473
|
|
|
474
|
+
/** A function in the app's registry (@xeplr/actions), or null. */
|
|
475
|
+
function registered(name) {
|
|
476
|
+
return require('@xeplr/actions').get(name) || null;
|
|
477
|
+
}
|
|
478
|
+
|
|
450
479
|
/**
|
|
451
480
|
* Everything that must be true before a flow can be run, as a list of
|
|
452
481
|
* { stepKey, message } — all of them, not the first one. A designer fixing a
|
|
@@ -493,6 +522,14 @@ function validateFlow(steps) {
|
|
|
493
522
|
if (t.target && END_TARGETS.indexOf(t.target) === -1 && !byKey[t.target]) {
|
|
494
523
|
problem(step.stepKey, 'Transition ' + (ti + 1) + ' goes to "' + t.target + '", which is not a step of this flow.');
|
|
495
524
|
}
|
|
525
|
+
if (t.decide) {
|
|
526
|
+
var def = registered(t.decide);
|
|
527
|
+
if (!def) problem(step.stepKey, 'Transition ' + (ti + 1) + ' lets "' + t.decide + '" decide, but no function of that name is registered.');
|
|
528
|
+
else if (def.event !== 'decide') problem(step.stepKey, 'Transition ' + (ti + 1) + ' lets "' + t.decide + '" decide, but it is written for ' + (def.event ? '"' + def.event + '"' : 'a workflow step') + ', not "decide".');
|
|
529
|
+
(t.targets || []).forEach(function(x) {
|
|
530
|
+
if (END_TARGETS.indexOf(x) === -1 && !byKey[x]) problem(step.stepKey, 'Transition ' + (ti + 1) + ': "' + t.decide + '" may choose "' + x + '", which is not a step of this flow.');
|
|
531
|
+
});
|
|
532
|
+
}
|
|
496
533
|
});
|
|
497
534
|
if (catchAlls > 1) {
|
|
498
535
|
problem(step.stepKey, 'This step has ' + catchAlls + ' catch-alls; only the first could ever match.');
|
|
@@ -511,6 +548,8 @@ function validateFlow(steps) {
|
|
|
511
548
|
reachable[key] = true;
|
|
512
549
|
(byKey[key].transitions || []).forEach(function(t) {
|
|
513
550
|
if (t.target && END_TARGETS.indexOf(t.target) === -1) queue.push(t.target);
|
|
551
|
+
// A function may lead to any step it may choose — all of them, when it lists none.
|
|
552
|
+
if (t.decide) (t.targets && t.targets.length ? t.targets : Object.keys(byKey)).forEach(function(x) { if (END_TARGETS.indexOf(x) === -1) queue.push(x); });
|
|
514
553
|
});
|
|
515
554
|
}
|
|
516
555
|
steps.forEach(function(step) {
|
|
@@ -558,7 +597,9 @@ function runView(run, steps, stepRuns) {
|
|
|
558
597
|
// the resolved input is kept (workflowRunner patches `input` as it parks)
|
|
559
598
|
// — the step row holds the tokens, and a browser has nothing to resolve
|
|
560
599
|
// them with.
|
|
561
|
-
given: {}
|
|
600
|
+
given: {},
|
|
601
|
+
// WHAT THE SCREENS BEFORE THIS ONE SAVED, by screen key — see previousOf.
|
|
602
|
+
previous: {}
|
|
562
603
|
};
|
|
563
604
|
if (!waiting) {
|
|
564
605
|
if (run.status === 'failed' && run.error) view.error = run.error;
|
|
@@ -575,9 +616,43 @@ function runView(run, steps, stepRuns) {
|
|
|
575
616
|
if (sr.stepKey !== waiting.stepKey) return;
|
|
576
617
|
Object.assign(view.values, sr.output || {});
|
|
577
618
|
});
|
|
619
|
+
view.previous = previousOf(steps, stepRuns, waiting);
|
|
578
620
|
return view;
|
|
579
621
|
}
|
|
580
622
|
|
|
623
|
+
/**
|
|
624
|
+
* THE HAND-OVER BETWEEN SCREENS — what every screen submitted earlier in this
|
|
625
|
+
* run, keyed by the SCREEN's key:
|
|
626
|
+
*
|
|
627
|
+
* { department_edit: { values: { id: "6f2a…", name: "Finance", … } } }
|
|
628
|
+
*
|
|
629
|
+
* A field on the next screen fills itself from it with a default of
|
|
630
|
+
* `{previous.department_edit.values.name}` (@xeplr/ui-factory). By screen and
|
|
631
|
+
* not by step, because the binding lives ON the screen, and a screen is the
|
|
632
|
+
* same form in every flow that shows it. `id` is the row that screen saved —
|
|
633
|
+
* part of the values, as it is of the row.
|
|
634
|
+
*
|
|
635
|
+
* Nothing is checked: a screen not reached on this run's branch is simply
|
|
636
|
+
* absent, and a binding to it comes out empty. A screen shown twice (a loop)
|
|
637
|
+
* hands over its latest. The step being shown now is not its own "previous".
|
|
638
|
+
*/
|
|
639
|
+
function previousOf(steps, stepRuns, waiting) {
|
|
640
|
+
var screenOf = {};
|
|
641
|
+
steps.forEach(function(st) {
|
|
642
|
+
if (st.actionName === SCREEN_ACTION && st.values && st.values.screen) screenOf[st.stepKey] = st.values.screen;
|
|
643
|
+
});
|
|
644
|
+
var out = {};
|
|
645
|
+
stepRuns.forEach(function(sr) {
|
|
646
|
+
if (sr.status !== 'success' || (waiting && sr.id === waiting.id)) return;
|
|
647
|
+
var screen = screenOf[sr.stepKey];
|
|
648
|
+
if (!screen) return;
|
|
649
|
+
var values = Object.assign({}, sr.output || {});
|
|
650
|
+
if (values.recordId !== undefined) { values.id = values.recordId; delete values.recordId; }
|
|
651
|
+
out[screen] = { values: values };
|
|
652
|
+
});
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
|
|
581
656
|
async function stepRunsOf(runId) {
|
|
582
657
|
return db.model('WorkflowStepRun').query().where({ runId: runId }).orderBy('recordCreatedDate', 'asc');
|
|
583
658
|
}
|
|
@@ -898,6 +973,7 @@ module.exports = {
|
|
|
898
973
|
fail: fail,
|
|
899
974
|
END_TARGETS: END_TARGETS,
|
|
900
975
|
conditionToWhen: conditionToWhen,
|
|
976
|
+
previousOf: previousOf,
|
|
901
977
|
toStepRows: toStepRows,
|
|
902
978
|
validateFlow: validateFlow,
|
|
903
979
|
runView: runView,
|
package/lib/workflowRunner.js
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
var crypto = require('crypto');
|
|
28
28
|
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
29
29
|
var { runWithMt } = require('@xeplr/db');
|
|
30
|
-
var
|
|
30
|
+
var actions = require('@xeplr/actions');
|
|
31
|
+
var { runAction } = actions;
|
|
31
32
|
var { interpolateAll, getPath, applySchema } = require('@xeplr/schema-handler');
|
|
32
33
|
var xf = require('@xeplr/expression-handler');
|
|
33
34
|
var db = require('./db');
|
|
@@ -455,6 +456,23 @@ async function routeAfterStep(workflow, steps, run, step, output) {
|
|
|
455
456
|
var matches = !t.condition || xf.evaluate(t.condition, row);
|
|
456
457
|
if (!matches) continue;
|
|
457
458
|
|
|
459
|
+
// LET A FUNCTION DECIDE: the arrow names a registered `decide` function,
|
|
460
|
+
// and its answer is the next step. It must be a step of this workflow —
|
|
461
|
+
// one of the arrow's `targets` when it lists them — or an end; anything
|
|
462
|
+
// else fails the run, by name, rather than wandering somewhere nobody drew.
|
|
463
|
+
if (t.decide) {
|
|
464
|
+
var decided;
|
|
465
|
+
try {
|
|
466
|
+
decided = await decideTarget(t, row, step, byKey, run);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
await finishRun(run, 'failed', { message: err.message });
|
|
469
|
+
return { done: true };
|
|
470
|
+
}
|
|
471
|
+
if (decided === 'end_success') { await finishRun(run, 'success', null); return { done: true }; }
|
|
472
|
+
if (decided === 'end_failed') { await finishRun(run, 'failed', { message: 'Ended by "' + t.decide + '" on ' + step.stepKey }); return { done: true }; }
|
|
473
|
+
return { done: false, next: decided };
|
|
474
|
+
}
|
|
475
|
+
|
|
458
476
|
if (t.mode === 'each') {
|
|
459
477
|
var fannedOut = await handleFanOut(workflow, steps, run, step, t, output);
|
|
460
478
|
if (fannedOut) return { done: true };
|
|
@@ -471,6 +489,29 @@ async function routeAfterStep(workflow, steps, run, step, output) {
|
|
|
471
489
|
return { done: true };
|
|
472
490
|
}
|
|
473
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Ask an arrow's `decide` function where to go. It is called with what the
|
|
494
|
+
* step produced (`output` — for a screen, what was filled in — also as
|
|
495
|
+
* `values`), the run's `params`, `item` and `trigger`, the step it is leaving
|
|
496
|
+
* and the steps it may choose from; it returns a step key, 'end_success' or
|
|
497
|
+
* 'end_failed'. Throws, with the function's name, for anything else.
|
|
498
|
+
*/
|
|
499
|
+
async function decideTarget(t, row, step, byKey, run) {
|
|
500
|
+
var def = actions.get(t.decide);
|
|
501
|
+
if (!def) throw new Error('Step ' + step.stepKey + ' asks "' + t.decide + '" where to go next, but no function of that name is registered');
|
|
502
|
+
if (def.event !== 'decide') throw new Error('Step ' + step.stepKey + ' asks "' + t.decide + '" where to go next, but it is written for ' + (def.event ? '"' + def.event + '"' : 'a workflow step'));
|
|
503
|
+
var allowed = Array.isArray(t.targets) && t.targets.length ? t.targets : Object.keys(byKey).concat(['end_success', 'end_failed']);
|
|
504
|
+
var answer = await def.execute({
|
|
505
|
+
output: row.output, values: row.output, params: row.params, item: row.item, trigger: row.trigger,
|
|
506
|
+
step: step.stepKey, runId: run.id, targets: allowed.slice()
|
|
507
|
+
});
|
|
508
|
+
var key = typeof answer === 'string' ? answer.trim() : '';
|
|
509
|
+
if (!key) throw new Error('"' + t.decide + '" did not say where to go next from ' + step.stepKey);
|
|
510
|
+
if (allowed.indexOf(key) === -1) throw new Error('"' + t.decide + '" chose "' + key + '" from ' + step.stepKey + ', which is not one of: ' + allowed.join(', '));
|
|
511
|
+
if (key !== 'end_success' && key !== 'end_failed' && !byKey[key]) throw new Error('"' + t.decide + '" chose "' + key + '", which is not a step of this workflow');
|
|
512
|
+
return key;
|
|
513
|
+
}
|
|
514
|
+
|
|
474
515
|
/**
|
|
475
516
|
* `t.source` is a dotted path to the array to fan over (resolved against
|
|
476
517
|
* { output, item, params } — usually `output.<field>`). Each element that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/workflow-engine",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "The free workflow engine: runs, actions as steps, branching, waits, and triggers — manual, API, a schedule, a job finishing. No HTTP: @xeplr/workflow-api puts the routes on top.",
|
|
6
6
|
"keywords": [
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"url": "https://github.com/Xeplr/xeplr-workflow-engine.git"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@xeplr/actions": "^1.
|
|
34
|
+
"@xeplr/actions": "^1.4.0",
|
|
35
35
|
"@xeplr/auth": "^1.0.0",
|
|
36
36
|
"@xeplr/db": "^1.0.0",
|
|
37
37
|
"@xeplr/expression-handler": "^2.0.0",
|