@xeplr/workflow-engine 1.0.1 → 3.0.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.
@@ -1,79 +0,0 @@
1
- // THE TWO STEPS OF A JOB CHAIN — see lib/jobChains.js.
2
- //
3
- // Registered but HIDDEN from GET /actions, like screen-show: only the facade
4
- // that generates a chain's workflow knows how to build these steps, and one
5
- // dropped onto the canvas by hand would have no rules and no finished job to
6
- // read.
7
-
8
- var db = require('../db');
9
- var jobChains = require('../jobChains');
10
-
11
- var NOT_CONNECTED =
12
- 'This step starts a job, but this install has not connected jobs to workflow. ' +
13
- 'The host must pass startJob to startEngine()/registerWorkflow() — with @xeplr/jobs ' +
14
- 'in the same process that is (jobId, body) => jobs.trigger(jobId, body).';
15
-
16
- // `decide` does nothing. Its transitions are the job's rules, and the engine
17
- // evaluates them against the run's params once it returns.
18
- var decide = {
19
- name: jobChains.DECIDE_ACTION,
20
- description: 'Choose which job runs next from how the last one ended. Its transitions are the rules.',
21
- requires: [],
22
- inputSchema: [{ name: 'rules', type: 'array', order: 1, description: 'The rules as written on the job.' }],
23
- outputSchema: null,
24
- execute: async function() { return {}; }
25
- };
26
-
27
- // `run_n` starts one job and parks until it reports back. The rule's inputs and
28
- // carry are resolved HERE against the run's own params (typed — see
29
- // resolveTyped), not by the engine's text interpolation, so a window or a
30
- // number passes as itself.
31
- var next = {
32
- name: jobChains.NEXT_ACTION,
33
- description: 'Start the next job of a chain with the period and values carried so far, and wait for it.',
34
- requires: [],
35
- inputSchema: [
36
- { name: 'jobId', type: 'string', required: true, order: 1 },
37
- { name: 'inputs', type: 'object', order: 2 },
38
- { name: 'carry', type: 'object', order: 3 },
39
- { name: 'callbackUrl', type: 'string', order: 4 }
40
- ],
41
- outputSchema: null,
42
-
43
- execute: async function(ctx) {
44
- var input = ctx.input || {};
45
- var startJob = jobChains.jobStarter();
46
- if (!startJob) throw new Error(NOT_CONNECTED);
47
-
48
- // The STEP's values as saved, not the interpolated input: `inputs` and
49
- // `carry` hold `{output.x}` bindings that only resolveTyped keeps typed.
50
- var step = await db.model('WorkflowStep').query().findById(ctx.system.stepId);
51
- var run = await db.model('WorkflowRun').query().findById(ctx.system.runId);
52
- var call = jobChains.nextJobCall(run && run.params, {
53
- jobId: input.jobId,
54
- inputs: step && step.values ? step.values.inputs : input.inputs,
55
- carry: step && step.values ? step.values.carry : input.carry
56
- });
57
-
58
- // The resume key travels in the chain, so a host can resume this step
59
- // in-process when the job ends (jobChains.jobFinished); the callback URL
60
- // is the same key over HTTP, for a jobs service in another process. Null
61
- // when WORKFLOW_PUBLIC_URL is unset, which is fine in-process.
62
- call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
63
- var started = await startJob(input.jobId, {
64
- inputs: call.inputs,
65
- chain: call.chain,
66
- callbackUrl: input.callbackUrl || null
67
- });
68
-
69
- return {
70
- jobId: input.jobId,
71
- occurrenceId: (started && started.occurrenceId) || null,
72
- status: 'running',
73
- window: call.inputs.window || null,
74
- carry: call.chain.carry
75
- };
76
- }
77
- };
78
-
79
- module.exports = { decide: decide, next: next };
package/lib/jobChains.js DELETED
@@ -1,426 +0,0 @@
1
- // JOB CHAINS — "after this job, run that one", kept as an ordinary workflow.
2
- //
3
- // A job can say what should happen when it finishes: a list of rules, each a
4
- // condition on how it ended and the job to run next. The rules are written on
5
- // the job's own screen, but they are NOT kept by the jobs package and nothing
6
- // in jobs starts another job. Every hand-off between jobs, screens and anything
7
- // else goes through this engine — one rule, one place.
8
- //
9
- // So a job's rules become one hidden workflow (kind 'job-chain', key
10
- // 'job-chain-<jobId>'), generated here the way lib/flows.js generates a
11
- // screens flow:
12
- //
13
- // decide does nothing; its transitions are the rules, first match wins
14
- // run_1 … n one per rule: start that job and wait for it to finish
15
- //
16
- // and it runs every time the job finishes. The host tells this module when
17
- // that happens (jobFinished — @xeplr/jobs' onFinish hook), and how to start a
18
- // job (setJobStarter — @xeplr/jobs' trigger()). This module never imports
19
- // jobs; the host is the only thing that knows both are present.
20
- //
21
- // ── what travels ────────────────────────────────────────────────────────
22
- //
23
- // A run of the chain starts with params:
24
- //
25
- // status 'success' | 'failed' — did the finished job's work happen
26
- // error its error, when it failed
27
- // output the finished job's own output (what its action returned)
28
- // window the period it ran for: the range it was given, or — when it
29
- // was given none — the range its action reports it covered
30
- // given / used those two ranges separately, handed to the next job as
31
- // chain.previous (its parameters can bind either)
32
- // carry values carried along the chain, from every job before it
33
- // jobId the job that finished
34
- //
35
- // and every rule's `when` reads those (`output.rowsMoved`, `carry.batch`). The
36
- // next job is started with the SAME window unless the rule sets one, so the
37
- // period flows from the first job of a chain to the last. A rule's `inputs`
38
- // fill inputs the next job accepts (its acceptsInputs); its `carry` adds
39
- // values for later jobs. Both may bind `{output.x}`, `{carry.x}`, `{window.x}`.
40
- var { generateId } = require('@xeplr/utils/lib/helpers');
41
- var { interpolate, getPath } = require('@xeplr/schema-handler');
42
- var db = require('./db');
43
- var flows = require('./flows');
44
-
45
- var CHAIN_KIND = 'job-chain';
46
- var DECIDE_ACTION = 'job-chain-decide';
47
- var NEXT_ACTION = 'job-chain-next';
48
-
49
- // The roots a rule may read — the params a chain run starts with.
50
- var ROOTS = ['status', 'error', 'output', 'carry', 'window', 'jobId'];
51
- var FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_-]+)*$/;
52
- var JOB_ID_PATTERN = /^[A-Za-z0-9_-]{1,25}$/;
53
-
54
- // A chain longer than this is a loop nobody drew on purpose.
55
- var MAX_DEPTH = 25;
56
-
57
- var EDIT_MESSAGE = 'This workflow is made from a job\'s "After this job" rules — change them on the job.';
58
-
59
- var fail = flows.fail;
60
-
61
- function keyFor(jobId) { return 'job-chain-' + jobId; }
62
-
63
- // ── how a job is started ────────────────────────────────────────────────
64
- //
65
- // Supplied by the host: async (jobId, { inputs, chain, callbackUrl }) →
66
- // { occurrenceId }. With @xeplr/jobs in the same process that is
67
- // `jobs.trigger`. Unset, a chain step fails and says what is missing.
68
- var _startJob = null;
69
-
70
- function setJobStarter(fn) {
71
- _startJob = typeof fn === 'function' ? fn : null;
72
- }
73
-
74
- function jobStarter() { return _startJob; }
75
-
76
- // ── a rule's `when` → the engine's condition node ───────────────────────
77
-
78
- function fieldPath(field, where) {
79
- var f = String(field == null ? '' : field).trim();
80
- if (!f) throw fail(400, 'CHAIN_INVALID', where + ': `field` is required — e.g. "status" or "output.rowsMoved".');
81
- if (!FIELD_PATTERN.test(f)) throw fail(400, 'CHAIN_INVALID', where + ': "' + f + '" is not a usable field.');
82
- var root = f.split('.')[0];
83
- if (ROOTS.indexOf(root) === -1) {
84
- throw fail(400, 'CHAIN_INVALID', where + ': "' + f + '" must start with one of ' + ROOTS.join(', ') + '.');
85
- }
86
- return 'params.' + f;
87
- }
88
-
89
- /**
90
- * `{ field, op, value }`, `{ all: [...] }` or `{ any: [...] }` → the node the
91
- * engine evaluates. null/undefined → the catch-all ("otherwise").
92
- */
93
- function whenToCondition(when, where) {
94
- if (when === null || when === undefined) return null;
95
- if (typeof when !== 'object' || Array.isArray(when)) {
96
- throw fail(400, 'CHAIN_INVALID', where + ': the condition must be an object such as ' +
97
- '{ "field": "status", "op": "=", "value": "success" }, or empty for "otherwise".');
98
- }
99
- if (when.all !== undefined || when.any !== undefined) {
100
- var groupKey = when.all !== undefined ? 'all' : 'any';
101
- var list = when[groupKey];
102
- if (!Array.isArray(list) || !list.length) {
103
- throw fail(400, 'CHAIN_INVALID', where + ': `' + groupKey + '` needs a non-empty list of conditions.');
104
- }
105
- var group = {};
106
- group[groupKey] = list.map(function(c, i) {
107
- var node = whenToCondition(c, where + ', ' + groupKey + ' ' + (i + 1));
108
- if (!node) throw fail(400, 'CHAIN_INVALID', where + ': an empty condition inside `' + groupKey + '`.');
109
- return node;
110
- });
111
- return group;
112
- }
113
- return flows.comparisonNode(when, where, fieldPath(when.field, where), 'CHAIN_INVALID');
114
- }
115
-
116
- // ── values a rule passes on ─────────────────────────────────────────────
117
-
118
- /**
119
- * Resolve `{path}` bindings against a chain run's params, KEEPING TYPES: a
120
- * string that is exactly one binding takes the bound value as it is — an
121
- * object stays an object, a number a number — so `"window": "{window}"` or
122
- * `"batch": "{carry.batch}"` passes the real thing. Anything else is text,
123
- * resolved by @xeplr/schema-handler's interpolate like every other binding.
124
- */
125
- function resolveTyped(value, context) {
126
- if (value == null) return value;
127
- if (typeof value === 'string') {
128
- var whole = /^\{([^{}]+)\}$/.exec(value.trim());
129
- if (whole) {
130
- var v = getPath(context, whole[1].trim());
131
- return v === undefined ? null : v;
132
- }
133
- return interpolate(value, context);
134
- }
135
- if (Array.isArray(value)) return value.map(function(v) { return resolveTyped(v, context); });
136
- if (typeof value === 'object') {
137
- var out = {};
138
- Object.keys(value).forEach(function(k) { out[k] = resolveTyped(value[k], context); });
139
- return out;
140
- }
141
- return value;
142
- }
143
-
144
- function plainObject(value, where, name) {
145
- if (value === undefined || value === null) return {};
146
- if (typeof value !== 'object' || Array.isArray(value)) {
147
- throw fail(400, 'CHAIN_INVALID', where + ': `' + name + '` must be an object of name → value.');
148
- }
149
- return value;
150
- }
151
-
152
- // ── the rules ⇄ the workflow's steps ────────────────────────────────────
153
-
154
- /**
155
- * Validate a job's rules and return them normalised. Every problem is a 400
156
- * naming the rule; nothing is written until all of them pass.
157
- */
158
- function normaliseRules(jobId, rules) {
159
- if (!Array.isArray(rules)) throw fail(400, 'CHAIN_INVALID', '`rules` must be a list.');
160
- var catchAllAt = -1;
161
- return rules.map(function(rule, i) {
162
- var where = 'Rule ' + (i + 1);
163
- if (!rule || typeof rule !== 'object' || Array.isArray(rule)) throw fail(400, 'CHAIN_INVALID', where + ': must be an object.');
164
- var run = String(rule.run == null ? '' : rule.run).trim();
165
- if (!run) throw fail(400, 'CHAIN_INVALID', where + ': choose the job to run.');
166
- if (!JOB_ID_PATTERN.test(run)) throw fail(400, 'CHAIN_INVALID', where + ': "' + run + '" is not a job id.');
167
- if (run === jobId) throw fail(400, 'CHAIN_INVALID', where + ': a job cannot run itself when it finishes — that would never stop.');
168
- if (catchAllAt > -1) {
169
- throw fail(400, 'CHAIN_INVALID', where + ': rule ' + (catchAllAt + 1) + ' has no condition, so it always matches and nothing after it can run.');
170
- }
171
- var when = rule.when === undefined ? null : rule.when;
172
- var condition = whenToCondition(when, where);
173
- if (!condition) catchAllAt = i;
174
- return {
175
- when: when,
176
- condition: condition,
177
- run: run,
178
- inputs: plainObject(rule.inputs, where, 'inputs'),
179
- carry: plainObject(rule.carry, where, 'carry')
180
- };
181
- });
182
- }
183
-
184
- function stepRows(workflowId, rules) {
185
- var transitions = rules.map(function(r, i) {
186
- return { condition: r.condition, mode: 'single', target: 'run_' + (i + 1) };
187
- });
188
- // No rule matched: the chain simply ends. A job's rules are "if this, run
189
- // that", and nothing matching is an ordinary outcome, not a failure.
190
- if (!rules.length || rules[rules.length - 1].condition) {
191
- transitions.push({ condition: null, mode: 'single', target: 'end_success' });
192
- }
193
-
194
- var rows = [{
195
- id: generateId(), workflowId: workflowId, stepKey: 'decide', name: 'Decide what runs next',
196
- actionName: DECIDE_ACTION, kind: 'auto', onError: 'stop',
197
- // THE RULES AS WRITTEN live on the step they generated, so GET answers
198
- // with exactly what was saved — conditions included — rather than
199
- // reconstructing them from engine nodes.
200
- values: { rules: rules.map(function(r) { return { when: r.when, run: r.run, inputs: r.inputs, carry: r.carry }; }) },
201
- transitions: transitions,
202
- position: 0
203
- }];
204
-
205
- rules.forEach(function(r, i) {
206
- rows.push({
207
- id: generateId(), workflowId: workflowId, stepKey: 'run_' + (i + 1), name: 'Run job ' + r.run,
208
- actionName: NEXT_ACTION,
209
- // A wait step: it starts the job and parks until the job reports back,
210
- // so the run says whether the next job actually worked.
211
- kind: 'wait', onError: 'stop',
212
- values: {
213
- jobId: r.run, inputs: r.inputs, carry: r.carry,
214
- callbackUrl: '{resumeUrl}'
215
- },
216
- transitions: [{ condition: null, mode: 'single', target: 'end_success' }],
217
- position: i + 1
218
- });
219
- });
220
- return rows;
221
- }
222
-
223
- async function loadChain(jobId) {
224
- return db.model('Workflow').query().where({ key: keyFor(jobId), kind: CHAIN_KIND }).first();
225
- }
226
-
227
- function chainView(jobId, workflow, steps) {
228
- var decide = (steps || []).find(function(s) { return s.stepKey === 'decide'; });
229
- return {
230
- jobId: jobId,
231
- workflowId: workflow ? workflow.id : null,
232
- rules: decide && decide.values && Array.isArray(decide.values.rules) ? decide.values.rules : []
233
- };
234
- }
235
-
236
- /** GET — a job's rules. A job without any answers an empty list, not a 404. */
237
- async function getChain(jobId) {
238
- var workflow = await loadChain(jobId);
239
- if (!workflow || workflow.status !== 'published') return chainView(jobId, null, []);
240
- var steps = await db.model('WorkflowStep').query().where({ workflowId: workflow.id }).orderBy('position');
241
- return chainView(jobId, workflow, steps);
242
- }
243
-
244
- /**
245
- * PUT — replace a job's rules. An empty list removes them: the workflow is
246
- * kept (its runs are history) but set back to draft, so it no longer runs.
247
- *
248
- * @param {string} jobId
249
- * @param {{ rules: object[], jobName?: string }} body
250
- */
251
- async function putChain(jobId, body) {
252
- body = body || {};
253
- if (!JOB_ID_PATTERN.test(String(jobId || ''))) throw fail(400, 'CHAIN_INVALID', 'Not a job id: "' + jobId + '".');
254
- var rules = normaliseRules(jobId, body.rules === undefined ? [] : body.rules);
255
- var name = 'After ' + (String(body.jobName || '').trim() || 'job ' + jobId).slice(0, 240);
256
-
257
- var workflow = await loadChain(jobId);
258
- if (!workflow) {
259
- if (!rules.length) return chainView(jobId, null, []);
260
- workflow = await db.model('Workflow').query().insert({
261
- id: generateId(), name: name, kind: CHAIN_KIND, key: keyFor(jobId), status: 'published',
262
- description: 'Made from the "After this job" rules of job ' + jobId + '.'
263
- });
264
- } else {
265
- await db.model('Workflow').query().findById(workflow.id)
266
- .patch({ name: name, status: rules.length ? 'published' : 'draft' });
267
- }
268
-
269
- // HARD delete, as putFlow does: (workflowId, stepKey) is unique regardless
270
- // of isActive, and every save regenerates the same keys.
271
- await db.model('WorkflowStep').query().delete().where({ workflowId: workflow.id });
272
- if (!rules.length) return chainView(jobId, null, []);
273
-
274
- var rows = stepRows(workflow.id, rules);
275
- for (var i = 0; i < rows.length; i++) await db.model('WorkflowStep').query().insert(rows[i]);
276
- return chainView(jobId, workflow, rows);
277
- }
278
-
279
- // ── a job has finished ──────────────────────────────────────────────────
280
-
281
- function pause(ms) {
282
- return new Promise(function(resolve) { var t = setTimeout(resolve, ms); if (t.unref) t.unref(); });
283
- }
284
-
285
- /**
286
- * Resume the chain step that started this job.
287
- *
288
- * A job can finish before the step that started it has parked — the start is
289
- * fire-and-forget, and a quick job ends in milliseconds — and the engine
290
- * answers that with RESUME_KEY_NOT_READY rather than routing a step that is
291
- * still running. So it is tried again for a few seconds, as @xeplr/jobs'
292
- * HTTP callback retries. A key already used (that callback got there first)
293
- * is a settled answer.
294
- */
295
- async function resumeStep(runner, key, output, opts) {
296
- for (var attempt = 0; attempt < 20; attempt++) {
297
- try {
298
- await runner.resumeByKey(key, output, opts);
299
- return true;
300
- } catch (err) {
301
- if (err.code === 'RESUME_KEY_INVALID') return false;
302
- if (err.code !== 'RESUME_KEY_NOT_READY') throw err;
303
- await pause(250);
304
- }
305
- }
306
- throw new Error('The chain step waiting on this job never parked, so it could not be told the job finished.');
307
- }
308
-
309
- /**
310
- * Called by the host for EVERY job run that ends (@xeplr/jobs' onFinish), in
311
- * that job's tenant context, with the jobs envelope { status, error, output }.
312
- *
313
- * Two things, independently:
314
- *
315
- * 1. If this run was started by a chain step, resume that step, so the
316
- * chain run that started it records how it went.
317
- * 2. If the finished job has rules, start its chain.
318
- *
319
- * Never throws for "nothing to do"; a chain that cannot start is logged by
320
- * the caller's hook guard and recorded as a failed run where possible.
321
- */
322
- async function jobFinished(report) {
323
- report = report || {};
324
- var out = report.output || {};
325
- var chain = out.chain || {};
326
- var result = { resumed: false, runId: null };
327
- var runner = require('./workflowRunner');
328
-
329
- if (chain.resumeKey) {
330
- result.resumed = await resumeStep(runner, chain.resumeKey, out,
331
- report.status === 'success' ? {} : { status: 'failed', error: report.error });
332
- }
333
-
334
- var jobId = out.jobId;
335
- if (!jobId) return result;
336
- var workflow = await loadChain(jobId);
337
- if (!workflow || workflow.status !== 'published') return result;
338
-
339
- var path = Array.isArray(chain.path) ? chain.path.slice() : [];
340
- path.push(jobId);
341
-
342
- var run = await runner.startRun(workflow.id, {
343
- jobId: jobId,
344
- status: report.status === 'success' ? 'success' : 'failed',
345
- error: report.error || null,
346
- output: out.output || null,
347
- // THE PERIOD THAT FLOWS ON: the range the job was given when it was given
348
- // one; otherwise the range its action reports it actually covered (a
349
- // movement that started from the copy's own highest value is given none,
350
- // and reports where it read from). See README "Job chains".
351
- window: out.window || (out.output && out.output.window) || null,
352
- // BOTH of the finished job's ranges, as they are, for the next job's
353
- // parameters to pick from (@xeplr/jobs' run contract, previous.*): what
354
- // it was given, and what its action reports it actually covered.
355
- given: out.window || null,
356
- used: (out.output && out.output.window) || null,
357
- carry: chain.carry && typeof chain.carry === 'object' ? chain.carry : {},
358
- path: path
359
- }, { trigger: 'job' });
360
- result.runId = run && run.id;
361
- return result;
362
- }
363
-
364
- /**
365
- * What a chain step hands the job it starts: the inputs (window first, then
366
- * the rule's own), the carry, and the path so far. Pure — exported to test.
367
- */
368
- function nextJobCall(params, values) {
369
- params = params || {};
370
- values = values || {};
371
- var path = Array.isArray(params.path) ? params.path : [];
372
- if (path.indexOf(values.jobId) > -1) {
373
- throw new Error('Job ' + values.jobId + ' has already run in this chain (' + path.concat(values.jobId).join(' → ') +
374
- '), so running it again would loop. Change the "After this job" rules so the chain ends.');
375
- }
376
- if (path.length >= MAX_DEPTH) {
377
- throw new Error('This chain has run ' + path.length + ' jobs in a row, which is the most allowed. Check the "After this job" rules for a loop.');
378
- }
379
-
380
- var inputs = resolveTyped(values.inputs || {}, params);
381
- // THE PERIOD FLOWS ON unless the rule says otherwise.
382
- if (inputs.window === undefined && params.window) inputs = Object.assign({ window: params.window }, inputs);
383
-
384
- var carry = Object.assign({}, params.carry || {}, resolveTyped(values.carry || {}, params));
385
- return {
386
- inputs: inputs,
387
- chain: { carry: carry, path: path, previous: { given: params.given || null, used: params.used || null } }
388
- };
389
- }
390
-
391
- /**
392
- * True when a POST /workflows/save touches a chain's workflow — refused, for
393
- * the reason flows.touchesScreens gives: these steps are generated, and a hand
394
- * edit would vanish at the next save of the job's rules.
395
- */
396
- async function touchesChains(body) {
397
- var entries = Array.isArray(body) ? body : (body ? [body] : []);
398
- if (entries.some(function(e) { return e && e.kind === CHAIN_KIND; })) return true;
399
- var ids = entries.map(function(e) { return e && e.id; }).filter(Boolean);
400
- if (!ids.length) return false;
401
- var rows = await db.model('Workflow').query().whereIn('id', ids);
402
- return rows.some(function(r) { return r.kind === CHAIN_KIND; });
403
- }
404
-
405
- module.exports = {
406
- CHAIN_KIND: CHAIN_KIND,
407
- DECIDE_ACTION: DECIDE_ACTION,
408
- NEXT_ACTION: NEXT_ACTION,
409
- EDIT_MESSAGE: EDIT_MESSAGE,
410
- ROOTS: ROOTS,
411
- keyFor: keyFor,
412
-
413
- setJobStarter: setJobStarter,
414
- jobStarter: jobStarter,
415
-
416
- // Pure, exported to test.
417
- whenToCondition: whenToCondition,
418
- normaliseRules: normaliseRules,
419
- resolveTyped: resolveTyped,
420
- nextJobCall: nextJobCall,
421
-
422
- getChain: getChain,
423
- putChain: putChain,
424
- jobFinished: jobFinished,
425
- touchesChains: touchesChains
426
- };