@xeplr/workflow-engine 2.0.0 → 3.0.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 +103 -31
- package/index.js +23 -5
- package/lib/actionCatalog.js +4 -6
- package/lib/actions/decide.js +15 -0
- package/lib/actions/jobRun.js +25 -7
- package/lib/conditions.js +72 -0
- package/lib/flows.js +1 -1
- package/lib/jobs.js +144 -0
- package/lib/triggers.js +299 -0
- package/lib/workflowRunner.js +53 -7
- package/migrations/0015_workflow_triggers.sql +85 -0
- package/models/Workflow.js +11 -4
- package/models/WorkflowRun.js +2 -0
- package/models/WorkflowStep.js +14 -0
- package/models/WorkflowTriggerState.js +35 -0
- package/models/index.js +3 -1
- package/package.json +4 -1
- package/lib/actions/jobChainSteps.js +0 -79
- package/lib/jobChains.js +0 -426
package/lib/triggers.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// TRIGGERS — WHAT STARTS A WORKFLOW, FOR EVERY KIND OF START.
|
|
2
|
+
//
|
|
3
|
+
// A workflow's `trigger` (0014) says what is supposed to start it. This module
|
|
4
|
+
// is what acts on it: one registry of kinds, the same way actions are one
|
|
5
|
+
// registry of steps. A kind is a name and up to three functions:
|
|
6
|
+
//
|
|
7
|
+
// validate(trigger) → the trigger normalised, or throws naming the problem
|
|
8
|
+
// due(trigger, from) → for a kind that fires on time: the next moment it is
|
|
9
|
+
// due after `from` (Date), or null
|
|
10
|
+
// matches(trigger, event) → for a kind that fires on an event: whether this
|
|
11
|
+
// event is the one it waits for
|
|
12
|
+
//
|
|
13
|
+
// Built in: manual and api (nothing to arm — someone or something calls the
|
|
14
|
+
// run), schedule (a cron expression), and job (a job finished). Folder and
|
|
15
|
+
// mailbox watchers are later kinds of the same registry; a host can register
|
|
16
|
+
// its own with registerTrigger().
|
|
17
|
+
//
|
|
18
|
+
// THE SAME RULES FOR EVERY KIND:
|
|
19
|
+
// * only a PUBLISHED workflow fires; a draft never does
|
|
20
|
+
// * `enabled: false` switches a trigger off without losing it
|
|
21
|
+
// * a run is started with what fired it as `triggerData`, which steps read
|
|
22
|
+
// as {trigger.*} ({trigger.status}, {trigger.window.from}, {trigger.dueAt})
|
|
23
|
+
// * every fire and every skip is recorded on workflow_trigger_states, so
|
|
24
|
+
// "when did it last run, when is it next due, why did it not start" is a
|
|
25
|
+
// row, not a log search
|
|
26
|
+
var { runWithMt } = require('@xeplr/db');
|
|
27
|
+
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
28
|
+
var parser = require('cron-parser');
|
|
29
|
+
var db = require('./db');
|
|
30
|
+
|
|
31
|
+
var KINDS = {};
|
|
32
|
+
var JOB_ID_PATTERN = /^[A-Za-z0-9_-]{1,25}$/;
|
|
33
|
+
var JOB_WHEN = ['success', 'failed', 'finished'];
|
|
34
|
+
|
|
35
|
+
// A model validation error by NAME (as Objection's is), so the generic save
|
|
36
|
+
// route answers it as one — 422 with the reason under `fields.trigger` —
|
|
37
|
+
// rather than as a server error. status/code are for callers of the engine.
|
|
38
|
+
function invalid(message) {
|
|
39
|
+
var err = new Error(message);
|
|
40
|
+
err.name = 'ValidationError';
|
|
41
|
+
err.type = 'ModelValidation';
|
|
42
|
+
err.data = { trigger: [{ message: message, keyword: 'trigger' }] };
|
|
43
|
+
err.statusCode = 400;
|
|
44
|
+
err.status = 400;
|
|
45
|
+
err.code = 'TRIGGER_INVALID';
|
|
46
|
+
return err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Add (or replace) a kind of trigger. */
|
|
50
|
+
function registerTrigger(kind, def) {
|
|
51
|
+
if (!kind || typeof kind !== 'string') throw new Error('registerTrigger: a kind name is required');
|
|
52
|
+
KINDS[kind] = Object.assign({ kind: kind }, def || {});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function kinds() { return Object.keys(KINDS); }
|
|
56
|
+
|
|
57
|
+
function common(trigger) {
|
|
58
|
+
var out = { kind: trigger.kind };
|
|
59
|
+
if (trigger.enabled === false) out.enabled = false;
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
registerTrigger('manual', {
|
|
64
|
+
description: 'Someone presses Start.',
|
|
65
|
+
validate: common
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
registerTrigger('api', {
|
|
69
|
+
description: 'Another system starts it through the API.',
|
|
70
|
+
validate: common
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
registerTrigger('schedule', {
|
|
74
|
+
description: 'On a schedule — a cron expression, e.g. "0 9 * * 1" for Mondays at 9.',
|
|
75
|
+
validate: function(trigger) {
|
|
76
|
+
var cron = String(trigger.cron == null ? '' : trigger.cron).trim();
|
|
77
|
+
if (!cron) throw invalid('A schedule needs `cron`, e.g. "0 9 * * 1" (Mondays at 9:00).');
|
|
78
|
+
var opts = trigger.timezone ? { tz: String(trigger.timezone) } : undefined;
|
|
79
|
+
try { parser.parseExpression(cron, opts); } catch (e) {
|
|
80
|
+
throw invalid('"' + cron + '" is not a cron expression this can read: ' + e.message);
|
|
81
|
+
}
|
|
82
|
+
var out = common(trigger);
|
|
83
|
+
out.cron = cron;
|
|
84
|
+
if (trigger.timezone) out.timezone = String(trigger.timezone);
|
|
85
|
+
return out;
|
|
86
|
+
},
|
|
87
|
+
due: function(trigger, from) {
|
|
88
|
+
var opts = { currentDate: from };
|
|
89
|
+
if (trigger.timezone) opts.tz = trigger.timezone;
|
|
90
|
+
return parser.parseExpression(trigger.cron, opts).next().toDate();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
registerTrigger('job', {
|
|
95
|
+
description: 'When a job finishes — successfully, with a failure, or either way.',
|
|
96
|
+
validate: function(trigger) {
|
|
97
|
+
var jobId = String(trigger.jobId == null ? '' : trigger.jobId).trim();
|
|
98
|
+
if (!jobId) throw invalid('A job trigger needs `jobId`: the job whose finish starts this workflow.');
|
|
99
|
+
if (!JOB_ID_PATTERN.test(jobId)) throw invalid('"' + jobId + '" is not a job id.');
|
|
100
|
+
var when = trigger.when == null ? 'success' : String(trigger.when);
|
|
101
|
+
if (JOB_WHEN.indexOf(when) === -1) {
|
|
102
|
+
throw invalid('`when` must be one of ' + JOB_WHEN.join(', ') + ' — got "' + when + '".');
|
|
103
|
+
}
|
|
104
|
+
var out = common(trigger);
|
|
105
|
+
out.jobId = jobId;
|
|
106
|
+
out.when = when;
|
|
107
|
+
return out;
|
|
108
|
+
},
|
|
109
|
+
matches: function(trigger, event) {
|
|
110
|
+
if (!event || event.jobId !== trigger.jobId) return false;
|
|
111
|
+
if (trigger.when === 'finished') return true;
|
|
112
|
+
return event.status === trigger.when;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A workflow's trigger, checked and normalised. null/undefined stays null
|
|
118
|
+
* ("started however it is called"). Throws a 400 naming the problem.
|
|
119
|
+
*/
|
|
120
|
+
function validate(trigger) {
|
|
121
|
+
if (trigger === null || trigger === undefined) return null;
|
|
122
|
+
if (typeof trigger !== 'object' || Array.isArray(trigger)) {
|
|
123
|
+
throw invalid('`trigger` must be an object such as { "kind": "schedule", "cron": "0 9 * * 1" }.');
|
|
124
|
+
}
|
|
125
|
+
var def = KINDS[trigger.kind];
|
|
126
|
+
if (!def) throw invalid('"' + trigger.kind + '" is not a kind of trigger. Kinds: ' + kinds().join(', ') + '.');
|
|
127
|
+
return def.validate ? def.validate(trigger) : common(trigger);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isOn(workflow) {
|
|
131
|
+
return Boolean(workflow && workflow.status === 'published' && workflow.trigger &&
|
|
132
|
+
workflow.trigger.enabled !== false && KINDS[workflow.trigger.kind]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mtOf(row) {
|
|
136
|
+
return { mtId1: row.mtId1 || null, mtId2: row.mtId2 || null, mtId3: row.mtId3 || null, mtId4: row.mtId4 || null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── the record of what each trigger did ─────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async function stateOf(workflowId) {
|
|
142
|
+
return db.model('WorkflowTriggerState').query().where({ workflowId: workflowId }).first();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function writeState(workflow, patch) {
|
|
146
|
+
var State = db.model('WorkflowTriggerState');
|
|
147
|
+
var row = await stateOf(workflow.id);
|
|
148
|
+
var values = Object.assign({ kind: workflow.trigger ? workflow.trigger.kind : null }, patch);
|
|
149
|
+
if (row) return State.query().patchAndFetchById(row.id, values);
|
|
150
|
+
return State.query().insert(Object.assign({ id: generateId(), workflowId: workflow.id }, values));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start a run because a trigger fired, inside the workflow's own tenant
|
|
155
|
+
* context. A run that cannot start (a required parameter nobody supplies, no
|
|
156
|
+
* steps) is recorded as a skip with the reason, never thrown at the caller —
|
|
157
|
+
* a finished job must not fail because a workflow after it is broken.
|
|
158
|
+
*/
|
|
159
|
+
async function fire(workflow, triggerData) {
|
|
160
|
+
var runner = require('./workflowRunner');
|
|
161
|
+
return runWithMt(mtOf(workflow), async function() {
|
|
162
|
+
try {
|
|
163
|
+
var run = await runner.startRun(workflow.id, {}, { trigger: workflow.trigger.kind, triggerData: triggerData });
|
|
164
|
+
await writeState(workflow, { lastFiredAt: new Date().toISOString(), lastRunId: run && run.id });
|
|
165
|
+
return { fired: true, runId: run && run.id };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await writeState(workflow, { lastSkippedAt: new Date().toISOString(), lastSkipReason: err.message });
|
|
168
|
+
return { fired: false, reason: err.message };
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── event kinds: a job finished ─────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
// A chain of jobs longer than this is a loop nobody drew on purpose.
|
|
176
|
+
var MAX_DEPTH = 25;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* An event happened (today: a job finished). Every published, enabled
|
|
180
|
+
* workflow whose trigger is of `kind` and matches it starts, in the caller's
|
|
181
|
+
* tenant context — the event's own. Returns what each one did.
|
|
182
|
+
*/
|
|
183
|
+
async function emit(kind, event) {
|
|
184
|
+
var def = KINDS[kind];
|
|
185
|
+
if (!def || !def.matches) return [];
|
|
186
|
+
var rows = await db.model('Workflow').query()
|
|
187
|
+
.where({ status: 'published' })
|
|
188
|
+
.whereRaw("\"trigger\"->>'kind' = ?", [kind]);
|
|
189
|
+
var out = [];
|
|
190
|
+
for (var i = 0; i < rows.length; i++) {
|
|
191
|
+
var w = rows[i];
|
|
192
|
+
if (!isOn(w) || !def.matches(w.trigger, event)) continue;
|
|
193
|
+
var r = await fire(w, Object.assign({ kind: kind }, event));
|
|
194
|
+
out.push(Object.assign({ workflowId: w.id }, r));
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── time kinds: the schedule tick ───────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One pass over every timed trigger, across every tenant (a tick is a timer:
|
|
203
|
+
* there is no request and so no tenant — rows are picked unscoped and each
|
|
204
|
+
* run is started in its own workflow's context, as @xeplr/jobs' scheduler
|
|
205
|
+
* does).
|
|
206
|
+
*
|
|
207
|
+
* A trigger seen for the first time, or whose settings changed, is given its
|
|
208
|
+
* next due time and does not fire — so publishing a workflow never starts a
|
|
209
|
+
* run for a time that has already passed. A due one is CLAIMED by moving its
|
|
210
|
+
* due time forward with a conditional update; only the process whose update
|
|
211
|
+
* landed fires it, so two processes running the tick start it once.
|
|
212
|
+
*
|
|
213
|
+
* `now` is for tests; production passes nothing.
|
|
214
|
+
*/
|
|
215
|
+
async function tick(now) {
|
|
216
|
+
now = now || new Date();
|
|
217
|
+
var timed = kinds().filter(function(k) { return KINDS[k].due; });
|
|
218
|
+
if (!timed.length) return [];
|
|
219
|
+
var Workflow = db.model('Workflow');
|
|
220
|
+
var State = db.model('WorkflowTriggerState');
|
|
221
|
+
var rows = await Workflow.unscopedQuery()
|
|
222
|
+
.where({ status: 'published', isActive: true })
|
|
223
|
+
.whereRaw("\"trigger\"->>'kind' in (" + timed.map(function() { return '?'; }).join(',') + ')', timed);
|
|
224
|
+
var fired = [];
|
|
225
|
+
for (var i = 0; i < rows.length; i++) {
|
|
226
|
+
var w = rows[i];
|
|
227
|
+
if (!isOn(w)) continue;
|
|
228
|
+
var def = KINDS[w.trigger.kind];
|
|
229
|
+
var settings = JSON.stringify(w.trigger);
|
|
230
|
+
await runWithMt(mtOf(w), async function() {
|
|
231
|
+
var state = await stateOf(w.id);
|
|
232
|
+
if (!state || !state.nextDueAt || state.settings !== settings) {
|
|
233
|
+
await writeState(w, { settings: settings, nextDueAt: def.due(w.trigger, now).toISOString() });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
var dueAt = new Date(state.nextDueAt);
|
|
237
|
+
if (dueAt > now) return;
|
|
238
|
+
var next = def.due(w.trigger, now).toISOString();
|
|
239
|
+
var claimed = await State.query()
|
|
240
|
+
.where({ id: state.id, nextDueAt: state.nextDueAt })
|
|
241
|
+
.patch({ nextDueAt: next });
|
|
242
|
+
if (!claimed) return;
|
|
243
|
+
var r = await fire(w, { kind: w.trigger.kind, dueAt: dueAt.toISOString(), firedAt: now.toISOString() });
|
|
244
|
+
fired.push(Object.assign({ workflowId: w.id }, r));
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return fired;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
var _timer = null;
|
|
251
|
+
|
|
252
|
+
/** Run the tick every `intervalMs` (default 30 s). The timer does not keep the process alive. */
|
|
253
|
+
function startTicking(intervalMs) {
|
|
254
|
+
stopTicking();
|
|
255
|
+
var every = intervalMs || 30000;
|
|
256
|
+
var running = false;
|
|
257
|
+
_timer = setInterval(function() {
|
|
258
|
+
if (running) return;
|
|
259
|
+
running = true;
|
|
260
|
+
tick().catch(function(err) {
|
|
261
|
+
console.error('[workflow] trigger tick failed — no scheduled workflow is starting: ' + err.message);
|
|
262
|
+
}).finally(function() { running = false; });
|
|
263
|
+
}, every);
|
|
264
|
+
if (_timer.unref) _timer.unref();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stopTicking() {
|
|
268
|
+
if (_timer) clearInterval(_timer);
|
|
269
|
+
_timer = null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** What a workflow's trigger is and what it last did — for a screen. */
|
|
273
|
+
async function describe(workflow) {
|
|
274
|
+
var state = workflow ? await stateOf(workflow.id) : null;
|
|
275
|
+
return {
|
|
276
|
+
trigger: (workflow && workflow.trigger) || null,
|
|
277
|
+
on: isOn(workflow),
|
|
278
|
+
lastFiredAt: (state && state.lastFiredAt) || null,
|
|
279
|
+
lastRunId: (state && state.lastRunId) || null,
|
|
280
|
+
lastSkippedAt: (state && state.lastSkippedAt) || null,
|
|
281
|
+
lastSkipReason: (state && state.lastSkipReason) || null,
|
|
282
|
+
nextDueAt: (state && isOn(workflow) && state.nextDueAt) || null
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
registerTrigger: registerTrigger,
|
|
288
|
+
kinds: kinds,
|
|
289
|
+
validate: validate,
|
|
290
|
+
isOn: isOn,
|
|
291
|
+
fire: fire,
|
|
292
|
+
emit: emit,
|
|
293
|
+
tick: tick,
|
|
294
|
+
startTicking: startTicking,
|
|
295
|
+
stopTicking: stopTicking,
|
|
296
|
+
describe: describe,
|
|
297
|
+
MAX_DEPTH: MAX_DEPTH,
|
|
298
|
+
JOB_WHEN: JOB_WHEN
|
|
299
|
+
};
|
package/lib/workflowRunner.js
CHANGED
|
@@ -112,6 +112,36 @@ async function finishRun(run, status, error) {
|
|
|
112
112
|
* the two can differ), params/item are the run's own, resumeKey is injected
|
|
113
113
|
* separately, only for the specific step that needs it.
|
|
114
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* Resolve `{path}` bindings KEEPING TYPES: a string that is exactly one
|
|
117
|
+
* binding takes the bound value as it is (an object stays an object, a number
|
|
118
|
+
* a number); anything else is text, as interpolate makes it.
|
|
119
|
+
*/
|
|
120
|
+
function resolveTyped(value, context) {
|
|
121
|
+
if (value == null) return value;
|
|
122
|
+
if (typeof value === 'string') {
|
|
123
|
+
var whole = /^\{([^{}]+)\}$/.exec(value.trim());
|
|
124
|
+
if (whole) {
|
|
125
|
+
var v = getPath(context, whole[1].trim());
|
|
126
|
+
return v === undefined ? null : v;
|
|
127
|
+
}
|
|
128
|
+
return interpolateAll(value, context);
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(value)) return value.map(function(v) { return resolveTyped(v, context); });
|
|
131
|
+
if (typeof value === 'object') {
|
|
132
|
+
var out = {};
|
|
133
|
+
Object.keys(value).forEach(function(k) { out[k] = resolveTyped(value[k], context); });
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// What started a run, as {trigger.*} reads it — in bindings AND in the
|
|
140
|
+
// conditions on arrows. A run started by hand has just its kind.
|
|
141
|
+
function triggerOf(run) {
|
|
142
|
+
return run.triggerData || { kind: run.trigger || 'manual' };
|
|
143
|
+
}
|
|
144
|
+
|
|
115
145
|
async function buildContext(run) {
|
|
116
146
|
var stepRuns = await db.model('WorkflowStepRun').query()
|
|
117
147
|
.where({ runId: run.id })
|
|
@@ -124,6 +154,10 @@ async function buildContext(run) {
|
|
|
124
154
|
|
|
125
155
|
return {
|
|
126
156
|
params: run.params || {},
|
|
157
|
+
// WHAT STARTED THIS RUN (lib/triggers.js): a finished job's status,
|
|
158
|
+
// window and output, a schedule's due time. {trigger.*} in a binding or a
|
|
159
|
+
// condition. A run started by hand has just its kind.
|
|
160
|
+
trigger: triggerOf(run),
|
|
127
161
|
item: run.item || null,
|
|
128
162
|
steps: byKey,
|
|
129
163
|
previous_step: previous ? { output: previous.output || {} } : {},
|
|
@@ -260,6 +294,8 @@ async function startRun(workflowId, params, opts) {
|
|
|
260
294
|
// with, so a result can be explained") describe a run that never happened.
|
|
261
295
|
params: resolved,
|
|
262
296
|
trigger: opts.trigger || 'manual',
|
|
297
|
+
// What fired it, in full — read by steps as {trigger.*}.
|
|
298
|
+
triggerData: opts.triggerData || null,
|
|
263
299
|
// WHO STARTED IT. Every caller already passes opts.user (the router hands
|
|
264
300
|
// it req.user), and without this the column stayed null on every run —
|
|
265
301
|
// which made "the runs I have not finished" an unanswerable question, and
|
|
@@ -344,8 +380,17 @@ async function executeFrom(workflow, steps, run, startStepKey) {
|
|
|
344
380
|
input: resolvedValues,
|
|
345
381
|
// resumeKey: a wait step's own key, for an action that hands it to
|
|
346
382
|
// something in this same process rather than over a URL (a job
|
|
347
|
-
// started in-process reports back through
|
|
348
|
-
|
|
383
|
+
// started in-process reports back through jobs.jobFinished).
|
|
384
|
+
// trigger: what started the run, for an action that carries it on
|
|
385
|
+
// (job-run passes a job's period to the next job).
|
|
386
|
+
// resolveTyped: a binding that is the WHOLE value keeps its type
|
|
387
|
+
// ("{trigger.output.totalRows}" stays 7, "{trigger.window}" an object),
|
|
388
|
+
// for an action that passes values on rather than printing them
|
|
389
|
+
// (job-run's inputs and carry). Every other action takes `input`, as
|
|
390
|
+
// resolved above, unchanged.
|
|
391
|
+
system: { runId: run.id, stepId: step.id, stepKey: step.stepKey, resumeKey: resumeKey,
|
|
392
|
+
trigger: triggerOf(run), rawValues: step.values || {},
|
|
393
|
+
resolveTyped: function(value) { return resolveTyped(value, context); } }
|
|
349
394
|
});
|
|
350
395
|
|
|
351
396
|
if (result.status !== 'success') {
|
|
@@ -403,7 +448,7 @@ async function routeAfterStep(workflow, steps, run, step, output) {
|
|
|
403
448
|
return { done: false, next: next };
|
|
404
449
|
}
|
|
405
450
|
|
|
406
|
-
var row = { output: output, item: run.item, params: run.params };
|
|
451
|
+
var row = { output: output, item: run.item, params: run.params, trigger: triggerOf(run) };
|
|
407
452
|
|
|
408
453
|
for (var i = 0; i < step.transitions.length; i++) {
|
|
409
454
|
var t = step.transitions[i];
|
|
@@ -437,12 +482,12 @@ async function routeAfterStep(workflow, steps, run, step, output) {
|
|
|
437
482
|
* `step.joinStep`.
|
|
438
483
|
*/
|
|
439
484
|
async function handleFanOut(workflow, steps, run, step, t, output) {
|
|
440
|
-
var array = t.source ? getPath({ output: output, item: run.item, params: run.params }, t.source) : null;
|
|
485
|
+
var array = t.source ? getPath({ output: output, item: run.item, params: run.params, trigger: triggerOf(run) }, t.source) : null;
|
|
441
486
|
if (!Array.isArray(array)) return false;
|
|
442
487
|
|
|
443
488
|
var matches = [];
|
|
444
489
|
array.forEach(function(el, idx) {
|
|
445
|
-
var elRow = { output: output, item: el, params: run.params };
|
|
490
|
+
var elRow = { output: output, item: el, params: run.params, trigger: triggerOf(run) };
|
|
446
491
|
if (!t.condition || xf.evaluate(t.condition, elRow)) matches.push({ el: el, idx: idx });
|
|
447
492
|
});
|
|
448
493
|
if (!matches.length) return false;
|
|
@@ -452,7 +497,7 @@ async function handleFanOut(workflow, steps, run, step, t, output) {
|
|
|
452
497
|
var m = matches[i];
|
|
453
498
|
var itemKey = m.el && typeof m.el === 'object' ? (m.el.id || m.el.messageId || m.el.key) : null;
|
|
454
499
|
var child = await db.model('WorkflowRun').query().insert(Object.assign({
|
|
455
|
-
id: uid(), workflowId: workflow.id, status: 'queued', params: run.params,
|
|
500
|
+
id: uid(), workflowId: workflow.id, status: 'queued', params: run.params, triggerData: run.triggerData || null,
|
|
456
501
|
item: m.el, trigger: 'fanout', startedAt: new Date().toISOString()
|
|
457
502
|
}, mtOf(run)));
|
|
458
503
|
await db.model('WorkflowRunEdge').query().insert({
|
|
@@ -649,4 +694,5 @@ async function resumeByKey(key, output, opts) {
|
|
|
649
694
|
// resolveParams and collectParams are exported for their own sake: they are
|
|
650
695
|
// the pieces of the engine that are pure — rows in, a schema or a resolved
|
|
651
696
|
// set out, or a throw — and testing them needs no database.
|
|
652
|
-
module.exports = {
|
|
697
|
+
module.exports = {
|
|
698
|
+
resolveTyped: resolveTyped, startRun: startRun, resumeByKey: resumeByKey, resolveParams: resolveParams, collectParams: collectParams };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
-- 0015_workflow_triggers.sql
|
|
2
|
+
--
|
|
3
|
+
-- TRIGGERS START RUNS — and a job finishing is one of them.
|
|
4
|
+
--
|
|
5
|
+
-- 0014 recorded what is supposed to start a workflow; nothing acted on it.
|
|
6
|
+
-- lib/triggers.js now does, for every kind through one registry: a schedule,
|
|
7
|
+
-- a job finishing, and later a folder or a mailbox. Three things here.
|
|
8
|
+
--
|
|
9
|
+
-- 1. WHAT FIRED A RUN, IN FULL. workflow_runs.trigger is the kind ('job',
|
|
10
|
+
-- 'schedule'); triggerData is what came with it — a finished job's status,
|
|
11
|
+
-- period and output, a schedule's due time — read by steps as {trigger.*}.
|
|
12
|
+
ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "triggerData" jsonb;
|
|
13
|
+
|
|
14
|
+
-- 2. WHAT EACH TRIGGER LAST DID: when it fired and which run that was, when
|
|
15
|
+
-- it is next due, and when and why it did not start a run (a required
|
|
16
|
+
-- parameter nobody supplies, a workflow with no steps). One row per
|
|
17
|
+
-- workflow, so "why did this not run last night" is a SELECT.
|
|
18
|
+
--
|
|
19
|
+
-- Its times are timestamptz: a due time is an instant, and a plain
|
|
20
|
+
-- timestamp read back in another server timezone would fire hours off.
|
|
21
|
+
--
|
|
22
|
+
-- `settings` is the trigger as it was when nextDueAt was computed; a
|
|
23
|
+
-- changed schedule is noticed by comparing it, and given a fresh due time
|
|
24
|
+
-- rather than firing for one computed from the old expression.
|
|
25
|
+
CREATE TABLE IF NOT EXISTS "workflow_trigger_states" (
|
|
26
|
+
"id" varchar(25) PRIMARY KEY,
|
|
27
|
+
"workflowId" varchar(25) NOT NULL,
|
|
28
|
+
"kind" varchar(32),
|
|
29
|
+
"settings" text,
|
|
30
|
+
"nextDueAt" timestamptz,
|
|
31
|
+
"lastFiredAt" timestamptz,
|
|
32
|
+
"lastRunId" varchar(25),
|
|
33
|
+
"lastSkippedAt" timestamptz,
|
|
34
|
+
"lastSkipReason" text,
|
|
35
|
+
"isActive" boolean DEFAULT true,
|
|
36
|
+
"mtId1" varchar(25),
|
|
37
|
+
"mtId2" varchar(25),
|
|
38
|
+
"mtId3" varchar(25),
|
|
39
|
+
"mtId4" varchar(25),
|
|
40
|
+
"recordCreatedDate" timestamp,
|
|
41
|
+
"recordModifiedDate" timestamp,
|
|
42
|
+
"recordCreatedBy" varchar(25),
|
|
43
|
+
"recordModifiedBy" varchar(25)
|
|
44
|
+
);
|
|
45
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "workflow_trigger_states_workflow_unique" ON "workflow_trigger_states" ("workflowId");
|
|
46
|
+
|
|
47
|
+
-- 3. JOB CHAINS BECOME ORDINARY WORKFLOWS.
|
|
48
|
+
--
|
|
49
|
+
-- A job's "After this job" rules used to be a hidden, locked workflow of
|
|
50
|
+
-- kind 'job-chain' with two private actions and their own routes. It is now
|
|
51
|
+
-- what it always was underneath: a workflow, triggered by the job finishing.
|
|
52
|
+
--
|
|
53
|
+
-- kind 'job-chain' → 'workflow', with trigger
|
|
54
|
+
-- { kind: 'job', jobId, when: 'finished' }
|
|
55
|
+
-- (the decide step still routes on status)
|
|
56
|
+
-- key 'job-chain-<jobId>' → none; the trigger says which job
|
|
57
|
+
-- 'job-chain-decide' step → 'decide' (a plain branching step)
|
|
58
|
+
-- 'job-chain-next' steps → 'job-run', which carries the period on
|
|
59
|
+
-- conditions on params.x → trigger.x (status, output, window, carry…)
|
|
60
|
+
-- bindings {output.x} etc. → {trigger.output.x}
|
|
61
|
+
--
|
|
62
|
+
-- A rule input of exactly "{window}" is dropped: job-run passes the finished
|
|
63
|
+
-- job's period on by itself, as an object, which a binding would turn into
|
|
64
|
+
-- text.
|
|
65
|
+
UPDATE "workflow_steps" s
|
|
66
|
+
SET "actionName" = 'decide',
|
|
67
|
+
"values" = '{}'::jsonb,
|
|
68
|
+
"transitions" = replace(s."transitions"::text, '"params.', '"trigger.')::jsonb
|
|
69
|
+
FROM "workflows" w
|
|
70
|
+
WHERE w."id" = s."workflowId" AND w."kind" = 'job-chain' AND s."actionName" = 'job-chain-decide';
|
|
71
|
+
|
|
72
|
+
UPDATE "workflow_steps" s
|
|
73
|
+
SET "actionName" = 'job-run',
|
|
74
|
+
"values" = regexp_replace(
|
|
75
|
+
(CASE WHEN s."values"->'inputs'->>'window' = '{window}'
|
|
76
|
+
THEN s."values" #- '{inputs,window}' ELSE s."values" END)::text,
|
|
77
|
+
'\{\s*(status|error|output|carry|window|jobId|given|used)\M', '{trigger.\1', 'g')::jsonb
|
|
78
|
+
FROM "workflows" w
|
|
79
|
+
WHERE w."id" = s."workflowId" AND w."kind" = 'job-chain' AND s."actionName" = 'job-chain-next';
|
|
80
|
+
|
|
81
|
+
UPDATE "workflows"
|
|
82
|
+
SET "trigger" = jsonb_build_object('kind', 'job', 'jobId', substring("key" from 11), 'when', 'finished'),
|
|
83
|
+
"kind" = 'workflow',
|
|
84
|
+
"key" = NULL
|
|
85
|
+
WHERE "kind" = 'job-chain' AND "key" LIKE 'job-chain-%';
|
package/models/Workflow.js
CHANGED
|
@@ -27,9 +27,10 @@ class Workflow extends BaseModel {
|
|
|
27
27
|
description: { type: ['string', 'null'], maxLength: 1000 },
|
|
28
28
|
// [{ name, type, required, default, description, order }]
|
|
29
29
|
params: { type: ['array', 'null'] },
|
|
30
|
-
// WHAT STARTS A RUN
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// WHAT STARTS A RUN — { kind, … }, one shape per kind (migrations/0014),
|
|
31
|
+
// acted on by lib/triggers.js: manual, api, schedule, job, and any kind
|
|
32
|
+
// a host registers. Checked on every write (below), so a trigger that
|
|
33
|
+
// could never fire is refused when it is saved, not discovered later.
|
|
33
34
|
trigger: { type: ['object', 'null'] },
|
|
34
35
|
status: { type: 'string', maxLength: 20 },
|
|
35
36
|
// WHAT THE BUILDER OFFERS, not what the engine does — 'workflow' (the
|
|
@@ -37,7 +38,7 @@ class Workflow extends BaseModel {
|
|
|
37
38
|
// designed in another app entirely through the /flows facade and
|
|
38
39
|
// read-only in this product's own builder). workflowRunner never reads
|
|
39
40
|
// it. See migrations/0012 and 0013.
|
|
40
|
-
kind: { type: 'string', enum: ['workflow', 'jobs', 'screens'
|
|
41
|
+
kind: { type: 'string', enum: ['workflow', 'jobs', 'screens'] },
|
|
41
42
|
isActive: { type: 'boolean' },
|
|
42
43
|
recordCreatedDate: { type: ['string', 'null'] },
|
|
43
44
|
recordModifiedDate: { type: ['string', 'null'] },
|
|
@@ -50,6 +51,12 @@ class Workflow extends BaseModel {
|
|
|
50
51
|
$beforeInsert() {
|
|
51
52
|
super.$beforeInsert();
|
|
52
53
|
if (!this.status) this.status = 'draft';
|
|
54
|
+
if (this.trigger !== undefined) this.trigger = require('../lib/triggers').validate(this.trigger);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
$beforeUpdate(opt, ctx) {
|
|
58
|
+
super.$beforeUpdate(opt, ctx);
|
|
59
|
+
if (this.trigger !== undefined) this.trigger = require('../lib/triggers').validate(this.trigger);
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
static get relationMappings() {
|
package/models/WorkflowRun.js
CHANGED
|
@@ -24,6 +24,8 @@ class WorkflowRun extends BaseModel {
|
|
|
24
24
|
// element this occurrence exists to process. See WorkflowRunEdge.
|
|
25
25
|
item: { type: ['object', 'null'] },
|
|
26
26
|
trigger: { type: ['string', 'null'], maxLength: 64 },
|
|
27
|
+
// What fired the run, in full (lib/triggers.js) — steps read it as {trigger.*}.
|
|
28
|
+
triggerData: { type: ['object', 'null'] },
|
|
27
29
|
startedAt: { type: ['string', 'null'] },
|
|
28
30
|
finishedAt: { type: ['string', 'null'] },
|
|
29
31
|
durationMs: { type: ['integer', 'null'] },
|
package/models/WorkflowStep.js
CHANGED
|
@@ -49,6 +49,20 @@ class WorkflowStep extends BaseModel {
|
|
|
49
49
|
super.$beforeInsert();
|
|
50
50
|
if (!this.kind) this.kind = 'auto';
|
|
51
51
|
if (!this.onError) this.onError = 'stop';
|
|
52
|
+
this.compileWhen();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$beforeUpdate(opt, ctx) {
|
|
56
|
+
super.$beforeUpdate(opt, ctx);
|
|
57
|
+
this.compileWhen();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// An arrow written as `when` ({ field, op, value }) gets its engine
|
|
61
|
+
// `condition` here, on every write — lib/conditions.js.
|
|
62
|
+
compileWhen() {
|
|
63
|
+
if (this.transitions !== undefined) {
|
|
64
|
+
this.transitions = require('../lib/conditions').compileTransitions(this.transitions, this.name || this.stepKey);
|
|
65
|
+
}
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
static get relationMappings() {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
var { BaseModel } = require('@xeplr/db');
|
|
2
|
+
|
|
3
|
+
// What a workflow's trigger last did — see 0015_workflow_triggers.sql and
|
|
4
|
+
// lib/triggers.js. One row per workflow; written by the engine, never by a
|
|
5
|
+
// document save.
|
|
6
|
+
|
|
7
|
+
class WorkflowTriggerState extends BaseModel {
|
|
8
|
+
static get tableName() { return 'workflow_trigger_states'; }
|
|
9
|
+
static get idColumn() { return 'id'; }
|
|
10
|
+
|
|
11
|
+
static get jsonSchema() {
|
|
12
|
+
return {
|
|
13
|
+
type: 'object',
|
|
14
|
+
required: ['id', 'workflowId'],
|
|
15
|
+
properties: {
|
|
16
|
+
id: { type: 'string', maxLength: 25 },
|
|
17
|
+
workflowId: { type: 'string', maxLength: 25 },
|
|
18
|
+
kind: { type: ['string', 'null'], maxLength: 32 },
|
|
19
|
+
settings: { type: ['string', 'null'] },
|
|
20
|
+
nextDueAt: { type: ['string', 'null'] },
|
|
21
|
+
lastFiredAt: { type: ['string', 'null'] },
|
|
22
|
+
lastRunId: { type: ['string', 'null'], maxLength: 25 },
|
|
23
|
+
lastSkippedAt: { type: ['string', 'null'] },
|
|
24
|
+
lastSkipReason: { type: ['string', 'null'] },
|
|
25
|
+
isActive: { type: 'boolean' },
|
|
26
|
+
recordCreatedDate: { type: ['string', 'null'] },
|
|
27
|
+
recordModifiedDate: { type: ['string', 'null'] },
|
|
28
|
+
recordCreatedBy: { type: ['string', 'null'], maxLength: 25 },
|
|
29
|
+
recordModifiedBy: { type: ['string', 'null'], maxLength: 25 }
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = WorkflowTriggerState;
|
package/models/index.js
CHANGED
|
@@ -12,6 +12,7 @@ var WorkflowRun = require('./WorkflowRun');
|
|
|
12
12
|
var WorkflowStepRun = require('./WorkflowStepRun');
|
|
13
13
|
var WorkflowResumeKey = require('./WorkflowResumeKey');
|
|
14
14
|
var WorkflowRunEdge = require('./WorkflowRunEdge');
|
|
15
|
+
var WorkflowTriggerState = require('./WorkflowTriggerState');
|
|
15
16
|
|
|
16
17
|
module.exports = {
|
|
17
18
|
Company: Company,
|
|
@@ -21,5 +22,6 @@ module.exports = {
|
|
|
21
22
|
WorkflowRun: WorkflowRun,
|
|
22
23
|
WorkflowStepRun: WorkflowStepRun,
|
|
23
24
|
WorkflowResumeKey: WorkflowResumeKey,
|
|
24
|
-
WorkflowRunEdge: WorkflowRunEdge
|
|
25
|
+
WorkflowRunEdge: WorkflowRunEdge,
|
|
26
|
+
WorkflowTriggerState: WorkflowTriggerState
|
|
25
27
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/workflow-engine",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "The workflow engine — workflow's own database and tables, the action catalog, starting and resuming runs, and the flows facade. No HTTP: @xeplr/workflow-api puts the routes on top.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -45,5 +45,8 @@
|
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@xeplr/jobs": "^2.3.0",
|
|
47
47
|
"knex": "^3.3.0"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"cron-parser": "^4.9.0"
|
|
48
51
|
}
|
|
49
52
|
}
|