@xeplr/workflow-engine 3.1.0 → 3.2.1
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 +17 -2
- package/lib/conditions.js +43 -3
- package/lib/flows.js +38 -1
- package/lib/triggers.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,10 @@ 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.
|
|
108
112
|
- **Letting a function decide**: a transition `{ decide: 'routeByAmount', targets?: [...] }`
|
|
109
113
|
asks a function the app registered with `@xeplr/actions` under `event: 'decide'`
|
|
110
114
|
where to go. It is called with `{ output, values (= output), params, item, trigger,
|
|
@@ -113,7 +117,8 @@ host gives workflow is the boundary.
|
|
|
113
117
|
function, one written for another event, no answer, or an answer outside
|
|
114
118
|
`targets` **fails the run**, naming the function. Functions registered with an
|
|
115
119
|
`event` are screen functions, never steps: the action catalog leaves them out.
|
|
116
|
-
- **Fan-out**: a matching `each` transition
|
|
120
|
+
- **Fan-out**: a matching `each` transition goes through the list its `source`
|
|
121
|
+
names (`output.rows`, `item.lines`, `params.…`, `trigger.…`) and creates one child run per matching
|
|
117
122
|
element (`item`), recorded in `workflow_run_edges`. With `joinStep` the parent
|
|
118
123
|
waits and resumes at the join once all children are terminal (guarded by an
|
|
119
124
|
atomic `waiting → running` patch); without it the parent finishes.
|
|
@@ -162,7 +167,9 @@ The same rules hold for every kind:
|
|
|
162
167
|
- A trigger seen for the first time, or whose settings changed, is given its next due time and does **not** fire, so publishing never starts a run for a time already past.
|
|
163
168
|
- A due trigger is **claimed** by a conditional update of its due time, so two processes running the tick start it once.
|
|
164
169
|
|
|
165
|
-
**
|
|
170
|
+
**What a designer offers.** `triggers.catalog()` lists every kind as `{ kind, label, description, fields }`, where `fields` are the form's boxes (`{ name, label, placeholder?, help?, required?, options? }`). The API serves it at `GET /workflow/triggers`. A designer builds its trigger form from it, so it never offers a kind this engine refuses (one once offered "A file arrives", which no engine had).
|
|
171
|
+
|
|
172
|
+
**A host's own kinds.** `registerTrigger(kind, { label?, description?, fields?, validate, due?, matches? })`:
|
|
166
173
|
- a time kind gives `due(trigger, from)`, its next due `Date`;
|
|
167
174
|
- an event kind gives `matches(trigger, event)`, and the host calls `triggers.emit(kind, event)` when the event happens.
|
|
168
175
|
|
|
@@ -180,6 +187,14 @@ The comparisons are the flows facade's: `=`, `!=`, `>`, `>=`, `<`, `<=`, `contai
|
|
|
180
187
|
|
|
181
188
|
A bad `when` is refused when it is saved, naming the step and the arrow. That is how a screen of your own (and the jobs screen's "After this job") writes branches without knowing the node format.
|
|
182
189
|
|
|
190
|
+
**An arrow the runner could not follow is refused when it is saved** (`checkArrow`, code `TRANSITION_INVALID`, naming the step and arrow):
|
|
191
|
+
- `mode` is `single` (the default) or `each`;
|
|
192
|
+
- an arrow needs a `target` (a step key, `end_success` or `end_failed`), unless it names a `decide` function;
|
|
193
|
+
- an `each` arrow needs `source`, the list it goes through, starting with `output`, `item`, `params` or `trigger`;
|
|
194
|
+
- an arrow either decides or goes through a list, not both, and `targets` is a list of step keys.
|
|
195
|
+
|
|
196
|
+
Why: each of these used to save, and then the live run ended with "No transition matched". A designer once saved every `each` arrow without its `source`, so nothing ever fanned out.
|
|
197
|
+
|
|
183
198
|
## Jobs as steps and as triggers (`lib/jobs.js`)
|
|
184
199
|
|
|
185
200
|
A job meets a workflow in two places, and both are ordinary workflows:
|
package/lib/conditions.js
CHANGED
|
@@ -62,11 +62,51 @@ function whenToCondition(when, where) {
|
|
|
62
62
|
function compileTransitions(transitions, stepName) {
|
|
63
63
|
if (!Array.isArray(transitions)) return transitions;
|
|
64
64
|
return transitions.map(function(t, i) {
|
|
65
|
-
|
|
65
|
+
var where = (stepName ? 'Step "' + stepName + '"' : 'A step') + ', arrow ' + (i + 1);
|
|
66
|
+
checkArrow(t, where);
|
|
67
|
+
if (t.when === undefined) return t;
|
|
66
68
|
var out = Object.assign({}, t);
|
|
67
|
-
out.condition = whenToCondition(t.when,
|
|
69
|
+
out.condition = whenToCondition(t.when, where);
|
|
68
70
|
return out;
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
// What an arrow may be. Each of these used to save and then fail on the live
|
|
75
|
+
// run instead: an "each" arrow without its list matched nothing, and the run
|
|
76
|
+
// ended with "No transition matched" (workflowRunner's routeAfterStep).
|
|
77
|
+
var ARROW_MODES = ['single', 'each'];
|
|
78
|
+
var SOURCE_ROOTS = ['output', 'item', 'params', 'trigger'];
|
|
79
|
+
|
|
80
|
+
function invalidArrow(message) {
|
|
81
|
+
var err = invalid(message);
|
|
82
|
+
err.code = 'TRANSITION_INVALID';
|
|
83
|
+
err.data = { transitions: [{ message: message, keyword: 'transition' }] };
|
|
84
|
+
return err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Refuses an arrow the runner could not follow, naming the step and arrow. */
|
|
88
|
+
function checkArrow(t, where) {
|
|
89
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) throw invalidArrow(where + ': an arrow must be an object such as { "target": "next-step" }.');
|
|
90
|
+
if (t.mode !== undefined && t.mode !== null && ARROW_MODES.indexOf(t.mode) === -1) {
|
|
91
|
+
throw invalidArrow(where + ': "' + t.mode + '" is not a kind of arrow. Kinds: ' + ARROW_MODES.join(', ') + '.');
|
|
92
|
+
}
|
|
93
|
+
if (t.decide !== undefined && t.decide !== null) {
|
|
94
|
+
if (typeof t.decide !== 'string' || !t.decide.trim()) throw invalidArrow(where + ': `decide` names a registered function.');
|
|
95
|
+
if (t.mode === 'each') throw invalidArrow(where + ': an arrow either lets a function decide or goes through a list, not both.');
|
|
96
|
+
if (t.targets !== undefined && t.targets !== null &&
|
|
97
|
+
(!Array.isArray(t.targets) || t.targets.some(function(k) { return typeof k !== 'string' || !k; }))) {
|
|
98
|
+
throw invalidArrow(where + ': `targets` is a list of step keys.');
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (typeof t.target !== 'string' || !t.target.trim()) throw invalidArrow(where + ': say where it goes (`target`): a step key, end_success or end_failed.');
|
|
103
|
+
if (t.mode === 'each') {
|
|
104
|
+
var source = typeof t.source === 'string' ? t.source.trim() : '';
|
|
105
|
+
if (!source) throw invalidArrow(where + ': an arrow for each item needs the list to go through (`source`), e.g. "output.rows".');
|
|
106
|
+
if (!FIELD_PATTERN.test(source) || SOURCE_ROOTS.indexOf(source.split('.')[0]) === -1) {
|
|
107
|
+
throw invalidArrow(where + ': "' + source + '" is not a list it can read. Start it with ' + SOURCE_ROOTS.join(', ') + ', e.g. "output.rows".');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { whenToCondition: whenToCondition, compileTransitions: compileTransitions, checkArrow: checkArrow, ARROW_MODES: ARROW_MODES };
|
package/lib/flows.js
CHANGED
|
@@ -597,7 +597,9 @@ function runView(run, steps, stepRuns) {
|
|
|
597
597
|
// the resolved input is kept (workflowRunner patches `input` as it parks)
|
|
598
598
|
// — the step row holds the tokens, and a browser has nothing to resolve
|
|
599
599
|
// them with.
|
|
600
|
-
given: {}
|
|
600
|
+
given: {},
|
|
601
|
+
// WHAT THE SCREENS BEFORE THIS ONE SAVED, by screen key — see previousOf.
|
|
602
|
+
previous: {}
|
|
601
603
|
};
|
|
602
604
|
if (!waiting) {
|
|
603
605
|
if (run.status === 'failed' && run.error) view.error = run.error;
|
|
@@ -614,9 +616,43 @@ function runView(run, steps, stepRuns) {
|
|
|
614
616
|
if (sr.stepKey !== waiting.stepKey) return;
|
|
615
617
|
Object.assign(view.values, sr.output || {});
|
|
616
618
|
});
|
|
619
|
+
view.previous = previousOf(steps, stepRuns, waiting);
|
|
617
620
|
return view;
|
|
618
621
|
}
|
|
619
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
|
+
|
|
620
656
|
async function stepRunsOf(runId) {
|
|
621
657
|
return db.model('WorkflowStepRun').query().where({ runId: runId }).orderBy('recordCreatedDate', 'asc');
|
|
622
658
|
}
|
|
@@ -937,6 +973,7 @@ module.exports = {
|
|
|
937
973
|
fail: fail,
|
|
938
974
|
END_TARGETS: END_TARGETS,
|
|
939
975
|
conditionToWhen: conditionToWhen,
|
|
976
|
+
previousOf: previousOf,
|
|
940
977
|
toStepRows: toStepRows,
|
|
941
978
|
validateFlow: validateFlow,
|
|
942
979
|
runView: runView,
|
package/lib/triggers.js
CHANGED
|
@@ -54,6 +54,19 @@ function registerTrigger(kind, def) {
|
|
|
54
54
|
|
|
55
55
|
function kinds() { return Object.keys(KINDS); }
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Every kind, as a designer offers it: { kind, label, description, fields }.
|
|
59
|
+
* `fields` is what the form asks for ({ name, label, placeholder?, help?,
|
|
60
|
+
* required?, options? }). A designer builds its trigger form from this rather
|
|
61
|
+
* than from its own list, so it never offers a kind this engine refuses.
|
|
62
|
+
*/
|
|
63
|
+
function catalog() {
|
|
64
|
+
return Object.keys(KINDS).map(function(k) {
|
|
65
|
+
var d = KINDS[k];
|
|
66
|
+
return { kind: k, label: d.label || k, description: d.description || '', fields: d.fields || [] };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
function common(trigger) {
|
|
58
71
|
var out = { kind: trigger.kind };
|
|
59
72
|
if (trigger.enabled === false) out.enabled = false;
|
|
@@ -61,17 +74,26 @@ function common(trigger) {
|
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
registerTrigger('manual', {
|
|
77
|
+
label: 'Someone presses Start',
|
|
64
78
|
description: 'Someone presses Start.',
|
|
79
|
+
fields: [],
|
|
65
80
|
validate: common
|
|
66
81
|
});
|
|
67
82
|
|
|
68
83
|
registerTrigger('api', {
|
|
84
|
+
label: 'Another system calls it',
|
|
69
85
|
description: 'Another system starts it through the API.',
|
|
86
|
+
fields: [],
|
|
70
87
|
validate: common
|
|
71
88
|
});
|
|
72
89
|
|
|
73
90
|
registerTrigger('schedule', {
|
|
91
|
+
label: 'On a schedule',
|
|
74
92
|
description: 'On a schedule — a cron expression, e.g. "0 9 * * 1" for Mondays at 9.',
|
|
93
|
+
fields: [
|
|
94
|
+
{ name: 'cron', label: 'When', placeholder: '0 9 * * 1', help: 'Minute hour day month weekday', required: true },
|
|
95
|
+
{ name: 'timezone', label: 'Time zone', placeholder: 'Asia/Kolkata', help: 'Empty: the server\'s' }
|
|
96
|
+
],
|
|
75
97
|
validate: function(trigger) {
|
|
76
98
|
var cron = String(trigger.cron == null ? '' : trigger.cron).trim();
|
|
77
99
|
if (!cron) throw invalid('A schedule needs `cron`, e.g. "0 9 * * 1" (Mondays at 9:00).');
|
|
@@ -92,7 +114,14 @@ registerTrigger('schedule', {
|
|
|
92
114
|
});
|
|
93
115
|
|
|
94
116
|
registerTrigger('job', {
|
|
117
|
+
label: 'A job finishes',
|
|
95
118
|
description: 'When a job finishes — successfully, with a failure, or either way.',
|
|
119
|
+
fields: [
|
|
120
|
+
{ name: 'jobId', label: 'Job', placeholder: 'the job\'s id', required: true },
|
|
121
|
+
{ name: 'when', label: 'When it', options: [
|
|
122
|
+
{ value: 'success', label: 'succeeds' }, { value: 'failed', label: 'fails' }, { value: 'finished', label: 'finishes either way' }
|
|
123
|
+
] }
|
|
124
|
+
],
|
|
96
125
|
validate: function(trigger) {
|
|
97
126
|
var jobId = String(trigger.jobId == null ? '' : trigger.jobId).trim();
|
|
98
127
|
if (!jobId) throw invalid('A job trigger needs `jobId`: the job whose finish starts this workflow.');
|
|
@@ -286,6 +315,7 @@ async function describe(workflow) {
|
|
|
286
315
|
module.exports = {
|
|
287
316
|
registerTrigger: registerTrigger,
|
|
288
317
|
kinds: kinds,
|
|
318
|
+
catalog: catalog,
|
|
289
319
|
validate: validate,
|
|
290
320
|
isOn: isOn,
|
|
291
321
|
fire: fire,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/workflow-engine",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
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": [
|