@xeplr/workflow-engine 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +182 -2
- package/db/xcfgSetup.js +8 -0
- package/index.js +290 -0
- package/lib/actionCatalog.js +197 -0
- package/lib/actions/jobChainSteps.js +79 -0
- package/lib/actions/jobRun.js +171 -0
- package/lib/actions/screenShow.js +71 -0
- package/lib/auth.js +19 -0
- package/lib/db.js +82 -0
- package/lib/envExposed.js +87 -0
- package/lib/flows.js +916 -0
- package/lib/jobChains.js +426 -0
- package/lib/workflowRunner.js +652 -0
- package/migrations/0003_workflows.sql +35 -0
- package/migrations/0004_workflow_steps.sql +79 -0
- package/migrations/0005_workflow_runs.sql +49 -0
- package/migrations/0006_workflow_step_runs.sql +42 -0
- package/migrations/0007_workflow_resume_keys.sql +42 -0
- package/migrations/0008_workflow_run_edges.sql +43 -0
- package/migrations/0009_workflow_steps_layout.sql +11 -0
- package/migrations/0010_workflow_steps_sample_output.sql +17 -0
- package/migrations/0011_workflow_steps_params.sql +27 -0
- package/migrations/0012_workflows_kind.sql +27 -0
- package/migrations/0013_workflows_key.sql +48 -0
- package/migrations/0014_workflows_trigger.sql +30 -0
- package/migrations-tenants/0001_companies.sql +23 -0
- package/migrations-tenants/0002_workspaces.sql +22 -0
- package/models/Company.js +82 -0
- package/models/Workflow.js +67 -0
- package/models/WorkflowResumeKey.js +32 -0
- package/models/WorkflowRun.js +64 -0
- package/models/WorkflowRunEdge.js +49 -0
- package/models/WorkflowStep.js +66 -0
- package/models/WorkflowStepRun.js +49 -0
- package/models/Workspace.js +75 -0
- package/models/index.js +25 -0
- package/package.json +47 -4
package/lib/jobChains.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
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
|
+
};
|