@xeplr/workflow 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 +352 -0
- package/bin/www +7 -0
- package/db/xcfgSetup.js +8 -0
- package/env.required.js +41 -0
- package/index.js +313 -0
- package/lib/actionCatalog.js +193 -0
- package/lib/actions/jobRun.js +149 -0
- package/lib/actions/screenShow.js +55 -0
- package/lib/db.js +82 -0
- package/lib/envExposed.js +87 -0
- package/lib/flows.js +832 -0
- package/lib/flowsRouter.js +113 -0
- package/lib/router.js +260 -0
- package/lib/workflowRunner.js +649 -0
- package/migrations/0001_companies.sql +23 -0
- package/migrations/0002_workspaces.sql +22 -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-auth/0001_workflow_access.sql +129 -0
- package/migrations-auth/0003_nav_menus.sql +62 -0
- package/migrations-auth/0004_flows_access.sql +76 -0
- package/models/Company.js +82 -0
- package/models/Workflow.js +63 -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/orchestration/standalone.js +123 -0
- package/package.json +69 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// The engine. Two public entry points:
|
|
2
|
+
//
|
|
3
|
+
// startRun(workflowId, params, opts) — begin a new occurrence
|
|
4
|
+
// resumeByKey(key, output) — the ENTIRE consumer-side contract
|
|
5
|
+
// for a 'wait' step. The caller
|
|
6
|
+
// never needs to know which run or
|
|
7
|
+
// step a key belongs to — that
|
|
8
|
+
// lookup lives in
|
|
9
|
+
// workflow_resume_keys alone.
|
|
10
|
+
//
|
|
11
|
+
// Everything else here is internal plumbing: resolving a step's `values`
|
|
12
|
+
// against the run's context, calling the action through @xeplr/actions'
|
|
13
|
+
// runAction (which validates input against the action's own inputSchema —
|
|
14
|
+
// this module never duplicates that), routing via transitions
|
|
15
|
+
// (@xeplr/expression-handler evaluates the structured condition), and
|
|
16
|
+
// fanning an 'each' transition out into child runs (see
|
|
17
|
+
// workflow_run_edges.sql) that this same engine drives independently.
|
|
18
|
+
//
|
|
19
|
+
// TENANT CONTEXT: every DB touch after the very first lookup runs inside
|
|
20
|
+
// runWithMt(mtOf(row), ...) using the RUN's own stored mtIds — never the
|
|
21
|
+
// caller's ambient context. A run can resume long after the request that
|
|
22
|
+
// started it has ended (a resume click days later, a fanned-out child
|
|
23
|
+
// finishing on its own), so there may be no ambient context at all — see
|
|
24
|
+
// @xeplr/jobs' lib/execute.js, which this follows exactly for the same
|
|
25
|
+
// reason.
|
|
26
|
+
|
|
27
|
+
var crypto = require('crypto');
|
|
28
|
+
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
29
|
+
var { runWithMt } = require('@xeplr/db');
|
|
30
|
+
var { runAction } = require('@xeplr/actions');
|
|
31
|
+
var { interpolateAll, getPath, applySchema } = require('@xeplr/schema-handler');
|
|
32
|
+
var xf = require('@xeplr/expression-handler');
|
|
33
|
+
var db = require('./db');
|
|
34
|
+
var { exposedEnv } = require('./envExposed');
|
|
35
|
+
|
|
36
|
+
// EVERY MODEL COMES THROUGH db.model(), never from models/index.js directly.
|
|
37
|
+
//
|
|
38
|
+
// This module is required before connectDb has run, so a class captured here
|
|
39
|
+
// at load time would carry the process's GLOBAL Objection binding — which,
|
|
40
|
+
// embedded in a host app, is the HOST's database. Every query would then look
|
|
41
|
+
// for workflow's tables somewhere they have never existed.
|
|
42
|
+
//
|
|
43
|
+
// Written out at each call site rather than hidden behind locals, so it is
|
|
44
|
+
// visible that these are bound lookups and not module-level classes. Same
|
|
45
|
+
// shape as @xeplr/auth's `auth.model('User').query()`. Objection caches per
|
|
46
|
+
// (Model, knex) pair, so the lookup is a map hit rather than a new class.
|
|
47
|
+
|
|
48
|
+
function uid() { return generateId(); }
|
|
49
|
+
|
|
50
|
+
function mintResumeKey() {
|
|
51
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where a resume key can be reached from OUTSIDE this process.
|
|
56
|
+
*
|
|
57
|
+
* Read at the moment a wait step runs rather than captured at load, so the
|
|
58
|
+
* same rule holds as everywhere else in this package: nothing about the
|
|
59
|
+
* environment is frozen at require time. Null when unset — the refusal
|
|
60
|
+
* belongs to whichever action actually needed it, which can say what it was
|
|
61
|
+
* trying to do.
|
|
62
|
+
*
|
|
63
|
+
* The path is fixed because the route is: see router.js's
|
|
64
|
+
* POST /public/resume/:key. WORKFLOW_PUBLIC_URL is the base a caller can
|
|
65
|
+
* reach this service on, mount path included when a host mounted it under
|
|
66
|
+
* one — e.g. https://bi.example.com/workflow
|
|
67
|
+
*/
|
|
68
|
+
function publicResumeUrl(key) {
|
|
69
|
+
var base = process.env.WORKFLOW_PUBLIC_URL;
|
|
70
|
+
if (!base) return null;
|
|
71
|
+
return base.replace(/\/+$/, '') + '/public/resume/' + key;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every table here uses all four slots regardless of how many levels this
|
|
75
|
+
// app registered — an unregistered level is simply never read. See
|
|
76
|
+
// @xeplr/jobs' lib/execute.js mtOf() for the same reasoning.
|
|
77
|
+
function mtOf(row) {
|
|
78
|
+
var ctx = {};
|
|
79
|
+
for (var n = 1; n <= 4; n++) {
|
|
80
|
+
var key = 'mtId' + n;
|
|
81
|
+
if (row && row[key]) ctx[key] = row[key];
|
|
82
|
+
}
|
|
83
|
+
return ctx;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stepMap(steps) {
|
|
87
|
+
var byKey = {};
|
|
88
|
+
steps.forEach(function(s) { byKey[s.stepKey] = s; });
|
|
89
|
+
return byKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nextByPosition(steps, step) {
|
|
93
|
+
var idx = steps.findIndex(function(s) { return s.stepKey === step.stepKey; });
|
|
94
|
+
return (idx > -1 && idx < steps.length - 1) ? steps[idx + 1].stepKey : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function finishRun(run, status, error) {
|
|
98
|
+
var startedAt = run.startedAt ? new Date(run.startedAt).getTime() : Date.now();
|
|
99
|
+
var finishedAt = Date.now();
|
|
100
|
+
await db.model('WorkflowRun').query().findById(run.id).patch({
|
|
101
|
+
status: status,
|
|
102
|
+
error: error || null,
|
|
103
|
+
finishedAt: new Date(finishedAt).toISOString(),
|
|
104
|
+
durationMs: finishedAt - startedAt
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The run's context for resolving a step's `values` and evaluating its
|
|
110
|
+
* transitions: previous_step / steps.<key> come from what has actually run
|
|
111
|
+
* IN THIS RUN so far (not from array position — a jump via transitions means
|
|
112
|
+
* the two can differ), params/item are the run's own, resumeKey is injected
|
|
113
|
+
* separately, only for the specific step that needs it.
|
|
114
|
+
*/
|
|
115
|
+
async function buildContext(run) {
|
|
116
|
+
var stepRuns = await db.model('WorkflowStepRun').query()
|
|
117
|
+
.where({ runId: run.id })
|
|
118
|
+
.whereIn('status', ['success', 'waiting'])
|
|
119
|
+
.orderBy('recordCreatedDate', 'asc');
|
|
120
|
+
|
|
121
|
+
var byKey = {};
|
|
122
|
+
stepRuns.forEach(function(sr) { byKey[sr.stepKey] = { output: sr.output || {} }; });
|
|
123
|
+
var previous = stepRuns.length ? stepRuns[stepRuns.length - 1] : null;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
params: run.params || {},
|
|
127
|
+
item: run.item || null,
|
|
128
|
+
steps: byKey,
|
|
129
|
+
previous_step: previous ? { output: previous.output || {} } : {},
|
|
130
|
+
// Only what WORKFLOW_ENV_EXPOSED names, never process.env itself — a
|
|
131
|
+
// resolved input is echoed to the builder and persisted on the step run,
|
|
132
|
+
// so anything reachable here is readable by anyone who can author a step.
|
|
133
|
+
// See lib/envExposed.js.
|
|
134
|
+
env: exposedEnv(),
|
|
135
|
+
resumeKey: null,
|
|
136
|
+
// The full URL that key resolves to. Set beside resumeKey on a wait step,
|
|
137
|
+
// for the same reason and at the same moment — see executeFrom.
|
|
138
|
+
resumeUrl: null
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The schema a RUN is validated against: the union of what every step declares
|
|
144
|
+
* it needs from the caller.
|
|
145
|
+
*
|
|
146
|
+
* Declared per STEP because the step is where you find out you need it — you
|
|
147
|
+
* are binding `{params.customerEmail}` into an email-send's `to`, and that is
|
|
148
|
+
* the moment to say the workflow requires it. Keeping a separate workflow-
|
|
149
|
+
* level list means adding the requirement and adding the binding are two
|
|
150
|
+
* actions in two places, and the day somebody does only the first the run
|
|
151
|
+
* demands a parameter nothing reads.
|
|
152
|
+
*
|
|
153
|
+
* UNION RULES, for a name declared by more than one step:
|
|
154
|
+
*
|
|
155
|
+
* required ANY step requiring it makes it required. A caller cannot supply
|
|
156
|
+
* it to one step and not another — there is one value, asked for
|
|
157
|
+
* once, before any of them run.
|
|
158
|
+
* otherwise the FIRST declaration by step position wins (type, default,
|
|
159
|
+
* description). Two steps disagreeing about the type of one name
|
|
160
|
+
* is an authoring mistake, and picking the earlier one at least
|
|
161
|
+
* makes it deterministic rather than dependent on row order.
|
|
162
|
+
*
|
|
163
|
+
* `workflows.params` is folded in FIRST when present, so workflows that
|
|
164
|
+
* declared at the workflow level before steps could carry it keep working —
|
|
165
|
+
* and keep priority, since they are the older statement of intent.
|
|
166
|
+
*/
|
|
167
|
+
function collectParams(workflow, steps) {
|
|
168
|
+
var byName = {};
|
|
169
|
+
var order = [];
|
|
170
|
+
|
|
171
|
+
function fold(declared) {
|
|
172
|
+
(Array.isArray(declared) ? declared : []).forEach(function(f) {
|
|
173
|
+
if (!f || !f.name) return;
|
|
174
|
+
if (!byName[f.name]) {
|
|
175
|
+
byName[f.name] = Object.assign({}, f);
|
|
176
|
+
order.push(f.name);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Already seen: the earlier declaration stands, except that requiring
|
|
180
|
+
// it anywhere requires it everywhere.
|
|
181
|
+
if (f.required) byName[f.name].required = true;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fold(workflow.params);
|
|
186
|
+
(steps || []).forEach(function(step) { fold(step.params); });
|
|
187
|
+
|
|
188
|
+
return order.map(function(name, i) {
|
|
189
|
+
return Object.assign({}, byName[name], { order: i + 1 });
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate a caller's params against what the workflow's steps declare, and
|
|
195
|
+
* return the set the run should actually use (declared defaults filled in).
|
|
196
|
+
*
|
|
197
|
+
* A workflow whose steps declare NOTHING takes whatever it is given,
|
|
198
|
+
* unchanged. That is not a loophole — it is the state every workflow built
|
|
199
|
+
* before this existed is in, and silently rejecting their params would break
|
|
200
|
+
* them all. Declaring is how a workflow opts INTO being strict.
|
|
201
|
+
*
|
|
202
|
+
* Undeclared keys are dropped by applySchema, which is the same rule a step's
|
|
203
|
+
* input follows. A caller passing `custmerId` gets told `customerId` is
|
|
204
|
+
* missing rather than having the typo travel to the first step that reads it.
|
|
205
|
+
*/
|
|
206
|
+
function resolveParams(workflow, steps, params) {
|
|
207
|
+
var declared = collectParams(workflow, steps);
|
|
208
|
+
if (!declared.length) return params || {};
|
|
209
|
+
try {
|
|
210
|
+
return applySchema(declared, params || {}, 'params');
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// Re-thrown with the workflow named. The caller asked to run "Onboard
|
|
213
|
+
// customer", not to satisfy a schema, and the message is what a person
|
|
214
|
+
// reads in a snackbar. `details` is carried through untouched so the run
|
|
215
|
+
// dialog can mark the individual fields.
|
|
216
|
+
var wrapped = new Error('Cannot run "' + workflow.name + '": ' + err.message);
|
|
217
|
+
wrapped.details = err.details || [];
|
|
218
|
+
wrapped.code = 'PARAMS_INVALID';
|
|
219
|
+
throw wrapped;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Begin a new occurrence. Runs inside whatever tenant context is already
|
|
225
|
+
* ambient (an authenticated HTTP request already has it, via mtMiddleware) —
|
|
226
|
+
* BaseModel's $beforeInsert fills mtId1-4 on the new run from that context.
|
|
227
|
+
*/
|
|
228
|
+
async function startRun(workflowId, params, opts) {
|
|
229
|
+
opts = opts || {};
|
|
230
|
+
var workflow = await db.model('Workflow').query().findById(workflowId);
|
|
231
|
+
if (!workflow) throw new Error('Workflow not found: ' + workflowId);
|
|
232
|
+
var steps = await db.model('WorkflowStep').query().where({ workflowId: workflowId }).orderBy('position');
|
|
233
|
+
if (!steps.length) throw new Error('Workflow "' + workflow.name + '" has no steps');
|
|
234
|
+
|
|
235
|
+
// A WORKFLOW DECLARES ITS INPUTS THE WAY AN ACTION DOES, and refuses to
|
|
236
|
+
// start without them. `workflows.params` is deliberately the same field
|
|
237
|
+
// shape as an action's inputSchema (see 0003_workflows.sql), so this is the
|
|
238
|
+
// same applySchema that runAction validates a step's input with — one
|
|
239
|
+
// definition of what "required" means, for both levels of the product.
|
|
240
|
+
//
|
|
241
|
+
// BEFORE the run row is inserted. A refused start must leave nothing
|
|
242
|
+
// behind: an occurrence recorded for a call that never ran a step is a row
|
|
243
|
+
// somebody has to explain, and it would show up in the run list as a
|
|
244
|
+
// failure of the workflow rather than of the caller.
|
|
245
|
+
//
|
|
246
|
+
// Missing `to` on a step is caught by the action, at the moment that step
|
|
247
|
+
// runs — which can be the fourth step, twenty minutes in, after three
|
|
248
|
+
// others have already written to a database. Missing `customerId` on the
|
|
249
|
+
// WORKFLOW is caught here, before anything happens at all. That is the
|
|
250
|
+
// whole reason this belongs at the top rather than in the step.
|
|
251
|
+
var resolved = resolveParams(workflow, steps, params);
|
|
252
|
+
|
|
253
|
+
var run = await db.model('WorkflowRun').query().insert({
|
|
254
|
+
id: uid(),
|
|
255
|
+
workflowId: workflowId,
|
|
256
|
+
status: 'running',
|
|
257
|
+
// The RESOLVED set, not what the caller typed: applySchema fills declared
|
|
258
|
+
// defaults, and those are what the run actually used. Storing the raw
|
|
259
|
+
// input would make workflow_runs.params ("the values the run was started
|
|
260
|
+
// with, so a result can be explained") describe a run that never happened.
|
|
261
|
+
params: resolved,
|
|
262
|
+
trigger: opts.trigger || 'manual',
|
|
263
|
+
// WHO STARTED IT. Every caller already passes opts.user (the router hands
|
|
264
|
+
// it req.user), and without this the column stayed null on every run —
|
|
265
|
+
// which made "the runs I have not finished" an unanswerable question, and
|
|
266
|
+
// that is the one question somebody halfway through a flow of screens
|
|
267
|
+
// actually has. A fanned-out child is inserted elsewhere and inherits
|
|
268
|
+
// nothing here on purpose: nobody started it.
|
|
269
|
+
recordCreatedBy: (opts.user && opts.user.id) || null,
|
|
270
|
+
startedAt: new Date().toISOString()
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await executeFrom(workflow, steps, run, steps[0].stepKey);
|
|
274
|
+
return db.model('WorkflowRun').query().findById(run.id);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Drive a run forward from `startStepKey` until it either pauses on a wait
|
|
279
|
+
* step, reaches an end (success/failed, by falling off the end of the list
|
|
280
|
+
* or by an explicit transition), or fans out (see handleFanOut — that also
|
|
281
|
+
* returns control here, since the parent's own linear progress stops at the
|
|
282
|
+
* fan-out point).
|
|
283
|
+
*/
|
|
284
|
+
async function executeFrom(workflow, steps, run, startStepKey) {
|
|
285
|
+
return runWithMt(mtOf(run), async function() {
|
|
286
|
+
var byKey = stepMap(steps);
|
|
287
|
+
var cursor = startStepKey;
|
|
288
|
+
|
|
289
|
+
while (cursor) {
|
|
290
|
+
var step = byKey[cursor];
|
|
291
|
+
if (!step) { await finishRun(run, 'failed', { message: 'Unknown step: ' + cursor }); return; }
|
|
292
|
+
|
|
293
|
+
var context = await buildContext(run);
|
|
294
|
+
var resolvedValues = interpolateAll(step.values || {}, context);
|
|
295
|
+
|
|
296
|
+
var stepRun = await db.model('WorkflowStepRun').query().insert({
|
|
297
|
+
id: uid(), runId: run.id, stepId: step.id, stepKey: step.stepKey,
|
|
298
|
+
actionName: step.actionName, status: 'running', input: resolvedValues, position: step.position
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// A wait step's resume key has to exist BEFORE the action runs, since
|
|
302
|
+
// the whole point is handing it to the action as input (a confirm
|
|
303
|
+
// link, an approval URL) — so it's minted, folded into the context,
|
|
304
|
+
// and the values are re-resolved with it available as {resumeKey}.
|
|
305
|
+
//
|
|
306
|
+
// THE ROW IS WRITTEN BEFORE THE ACTION TOO, not after it returns. The
|
|
307
|
+
// action is what puts the key in front of somebody — the moment
|
|
308
|
+
// email-send hands off, a click can arrive, and it routinely does:
|
|
309
|
+
// mail scanners pre-fetch links within seconds of delivery. Inserting
|
|
310
|
+
// afterwards left a window where the key was live in an inbox and
|
|
311
|
+
// absent from the table, and resumeByKey answers that with "already
|
|
312
|
+
// used or not valid" — which is the opposite of true and sends anyone
|
|
313
|
+
// debugging it looking for a double-click that never happened.
|
|
314
|
+
var resumeKey = null;
|
|
315
|
+
var resumeKeyRowId = null;
|
|
316
|
+
if (step.kind === 'wait') {
|
|
317
|
+
resumeKey = mintResumeKey();
|
|
318
|
+
context.resumeKey = resumeKey;
|
|
319
|
+
// {resumeUrl} — THE WHOLE ADDRESS, not just the key.
|
|
320
|
+
//
|
|
321
|
+
// A step that hands its key to something OUTSIDE this process (a job,
|
|
322
|
+
// an API, anything that will call back later) needs a URL, and the
|
|
323
|
+
// author must not be the one assembling it: hardcoding this service's
|
|
324
|
+
// own hostname into a step's values means every environment gets a
|
|
325
|
+
// step that works in exactly one of them, and the failure is a
|
|
326
|
+
// callback that quietly never arrives.
|
|
327
|
+
//
|
|
328
|
+
// Null when WORKFLOW_PUBLIC_URL is unset, and deliberately NOT
|
|
329
|
+
// defaulted to localhost — a wrong-but-present address produces a
|
|
330
|
+
// callback that goes nowhere and a run that waits forever, which is
|
|
331
|
+
// far worse than an action refusing at the point of use and naming
|
|
332
|
+
// the variable. Same rule the workspace keeps for database names.
|
|
333
|
+
context.resumeUrl = publicResumeUrl(resumeKey);
|
|
334
|
+
resolvedValues = interpolateAll(step.values || {}, context);
|
|
335
|
+
await db.model('WorkflowStepRun').query().findById(stepRun.id).patch({ input: resolvedValues });
|
|
336
|
+
resumeKeyRowId = uid();
|
|
337
|
+
await db.model('WorkflowResumeKey').query().insert({
|
|
338
|
+
id: resumeKeyRowId, runId: run.id, stepId: step.id, key: resumeKey
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
var result = await runAction({
|
|
343
|
+
name: step.actionName,
|
|
344
|
+
input: resolvedValues,
|
|
345
|
+
system: { runId: run.id, stepId: step.id, stepKey: step.stepKey }
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (result.status !== 'success') {
|
|
349
|
+
await db.model('WorkflowStepRun').query().findById(stepRun.id).patch({
|
|
350
|
+
status: 'failed', error: result.error, durationMs: result.durationMs
|
|
351
|
+
});
|
|
352
|
+
// The action never delivered the key, so burn it. Consumed rather
|
|
353
|
+
// than deleted: the row is the record that this step DID reach the
|
|
354
|
+
// point of minting one, which is worth keeping when working out why
|
|
355
|
+
// a run stalled. Either way it can no longer resolve a call.
|
|
356
|
+
if (resumeKeyRowId) {
|
|
357
|
+
await db.model('WorkflowResumeKey').query().findById(resumeKeyRowId)
|
|
358
|
+
.patch({ consumedDate: new Date().toISOString() });
|
|
359
|
+
}
|
|
360
|
+
if (step.onError === 'continue') { cursor = nextByPosition(steps, step); continue; }
|
|
361
|
+
await finishRun(run, 'failed', result.error);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await db.model('WorkflowStepRun').query().findById(stepRun.id).patch({
|
|
366
|
+
status: step.kind === 'wait' ? 'waiting' : 'success',
|
|
367
|
+
output: result.output, durationMs: result.durationMs
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (step.kind === 'wait') {
|
|
371
|
+
// The key row already exists (written before the action ran, above).
|
|
372
|
+
await db.model('WorkflowRun').query().findById(run.id).patch({ status: 'waiting' });
|
|
373
|
+
return; // paused — resumeByKey continues from exactly here
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
var routed = await routeAfterStep(workflow, steps, run, step, result.output);
|
|
377
|
+
if (routed.done) return;
|
|
378
|
+
cursor = routed.next;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await finishRun(run, 'success', null);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Decide what runs next after a step's OWN successful result. No
|
|
387
|
+
* transitions declared → the old default, advance by position. Transitions
|
|
388
|
+
* declared → first matching row wins; an 'each' row that matches at least
|
|
389
|
+
* one element fans out (see handleFanOut) and ends this run's own linear
|
|
390
|
+
* progress; a row matching zero elements is treated as no match, same as a
|
|
391
|
+
* false condition. No row matching, and no blank catch-all row, fails the
|
|
392
|
+
* step — a silent fall-through would hide a branch nobody accounted for.
|
|
393
|
+
*/
|
|
394
|
+
async function routeAfterStep(workflow, steps, run, step, output) {
|
|
395
|
+
var byKey = stepMap(steps);
|
|
396
|
+
|
|
397
|
+
if (!step.transitions || !step.transitions.length) {
|
|
398
|
+
var next = nextByPosition(steps, step);
|
|
399
|
+
if (!next) { await finishRun(run, 'success', null); return { done: true }; }
|
|
400
|
+
return { done: false, next: next };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
var row = { output: output, item: run.item, params: run.params };
|
|
404
|
+
|
|
405
|
+
for (var i = 0; i < step.transitions.length; i++) {
|
|
406
|
+
var t = step.transitions[i];
|
|
407
|
+
var matches = !t.condition || xf.evaluate(t.condition, row);
|
|
408
|
+
if (!matches) continue;
|
|
409
|
+
|
|
410
|
+
if (t.mode === 'each') {
|
|
411
|
+
var fannedOut = await handleFanOut(workflow, steps, run, step, t, output);
|
|
412
|
+
if (fannedOut) return { done: true };
|
|
413
|
+
continue; // 0 elements matched — not a real match, keep looking
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (t.target === 'end_success') { await finishRun(run, 'success', null); return { done: true }; }
|
|
417
|
+
if (t.target === 'end_failed') { await finishRun(run, 'failed', { message: 'Ended by transition on ' + step.stepKey }); return { done: true }; }
|
|
418
|
+
if (!byKey[t.target]) { await finishRun(run, 'failed', { message: 'Transition on ' + step.stepKey + ' targets unknown step: ' + t.target }); return { done: true }; }
|
|
419
|
+
return { done: false, next: t.target };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
await finishRun(run, 'failed', { message: 'No transition matched on step ' + step.stepKey + ' and there is no catch-all row' });
|
|
423
|
+
return { done: true };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* `t.source` is a dotted path to the array to fan over (resolved against
|
|
428
|
+
* { output, item, params } — usually `output.<field>`). Each element that
|
|
429
|
+
* also satisfies `t.condition` becomes its own child run, seeded with that
|
|
430
|
+
* element as `item`, starting at `t.target`. The parent's own progress stops
|
|
431
|
+
* here: it either finishes immediately (no joinStep — its job was just to
|
|
432
|
+
* spawn the children) or sits at status 'waiting' until every child reaches
|
|
433
|
+
* a terminal state, at which point maybeCompleteParent resumes it at
|
|
434
|
+
* `step.joinStep`.
|
|
435
|
+
*/
|
|
436
|
+
async function handleFanOut(workflow, steps, run, step, t, output) {
|
|
437
|
+
var array = t.source ? getPath({ output: output, item: run.item, params: run.params }, t.source) : null;
|
|
438
|
+
if (!Array.isArray(array)) return false;
|
|
439
|
+
|
|
440
|
+
var matches = [];
|
|
441
|
+
array.forEach(function(el, idx) {
|
|
442
|
+
var elRow = { output: output, item: el, params: run.params };
|
|
443
|
+
if (!t.condition || xf.evaluate(t.condition, elRow)) matches.push({ el: el, idx: idx });
|
|
444
|
+
});
|
|
445
|
+
if (!matches.length) return false;
|
|
446
|
+
|
|
447
|
+
var childIds = [];
|
|
448
|
+
for (var i = 0; i < matches.length; i++) {
|
|
449
|
+
var m = matches[i];
|
|
450
|
+
var itemKey = m.el && typeof m.el === 'object' ? (m.el.id || m.el.messageId || m.el.key) : null;
|
|
451
|
+
var child = await db.model('WorkflowRun').query().insert(Object.assign({
|
|
452
|
+
id: uid(), workflowId: workflow.id, status: 'queued', params: run.params,
|
|
453
|
+
item: m.el, trigger: 'fanout', startedAt: new Date().toISOString()
|
|
454
|
+
}, mtOf(run)));
|
|
455
|
+
await db.model('WorkflowRunEdge').query().insert({
|
|
456
|
+
id: uid(), parentRunId: run.id, childRunId: child.id,
|
|
457
|
+
sourceStepKey: step.stepKey, itemIndex: m.idx,
|
|
458
|
+
itemKey: itemKey != null ? String(itemKey) : null, item: m.el
|
|
459
|
+
});
|
|
460
|
+
childIds.push(child.id);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (step.joinStep) {
|
|
464
|
+
await db.model('WorkflowRun').query().findById(run.id).patch({ status: 'waiting' });
|
|
465
|
+
} else {
|
|
466
|
+
await finishRun(run, 'success', null);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Not awaited — a child may sit on a wait step for days, and the parent's
|
|
470
|
+
// own fate (done above, or waiting on the join) does not depend on how
|
|
471
|
+
// long that takes.
|
|
472
|
+
childIds.forEach(function(childId) {
|
|
473
|
+
executeChildRun(workflow, steps, childId, t.target, run.id, step.stepKey).catch(function(err) {
|
|
474
|
+
console.error('[workflowRunner] child run ' + childId + ' failed to start:', err.message);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function executeChildRun(workflow, steps, runId, startStepKey, parentRunId, sourceStepKey) {
|
|
482
|
+
var run = await db.model('WorkflowRun').unscopedQuery().findById(runId);
|
|
483
|
+
await runWithMt(mtOf(run), async function() {
|
|
484
|
+
await db.model('WorkflowRun').query().findById(runId).patch({ status: 'running' });
|
|
485
|
+
var fresh = await db.model('WorkflowRun').query().findById(runId);
|
|
486
|
+
await executeFrom(workflow, steps, fresh, startStepKey);
|
|
487
|
+
});
|
|
488
|
+
await maybeCompleteParent(parentRunId, sourceStepKey, workflow, steps);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Called by every sibling as it finishes. Only the sibling that observes ALL
|
|
493
|
+
* of them terminal actually proceeds — guarded by an atomic
|
|
494
|
+
* waiting → running patch (same compare-and-swap shape as @xeplr/jobs'
|
|
495
|
+
* per-job lock) so two children finishing near-simultaneously cannot both
|
|
496
|
+
* run the join step.
|
|
497
|
+
*/
|
|
498
|
+
async function maybeCompleteParent(parentRunId, sourceStepKey, workflow, steps) {
|
|
499
|
+
var parent = await db.model('WorkflowRun').unscopedQuery().findById(parentRunId);
|
|
500
|
+
if (!parent || parent.status !== 'waiting') return; // no join configured, or already handled
|
|
501
|
+
|
|
502
|
+
return runWithMt(mtOf(parent), async function() {
|
|
503
|
+
var edges = await db.model('WorkflowRunEdge').query().where({ parentRunId: parentRunId, sourceStepKey: sourceStepKey });
|
|
504
|
+
var childIds = edges.map(function(e) { return e.childRunId; });
|
|
505
|
+
var children = childIds.length ? await db.model('WorkflowRun').query().whereIn('id', childIds) : [];
|
|
506
|
+
var allDone = children.length > 0 && children.every(function(c) { return c.status === 'success' || c.status === 'failed'; });
|
|
507
|
+
if (!allDone) return;
|
|
508
|
+
|
|
509
|
+
var claimed = await db.model('WorkflowRun').query().patch({ status: 'running' }).where({ id: parentRunId, status: 'waiting' });
|
|
510
|
+
if (claimed === 0) return; // another sibling already won the race
|
|
511
|
+
|
|
512
|
+
var byKey = stepMap(steps);
|
|
513
|
+
var sourceStep = byKey[sourceStepKey];
|
|
514
|
+
if (!sourceStep || !sourceStep.joinStep) {
|
|
515
|
+
await finishRun({ id: parentRunId, startedAt: parent.startedAt }, 'success', null);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
var fresh = await db.model('WorkflowRun').query().findById(parentRunId);
|
|
520
|
+
await executeFrom(workflow, steps, fresh, sourceStep.joinStep);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The entire consumer-side contract for a wait step. The caller supplies
|
|
526
|
+
* only what it was handed — the key — and whatever output the resolution
|
|
527
|
+
* carries (an approval decision, submitted form fields; can be omitted for a
|
|
528
|
+
* bare "this happened" signal like an email confirmation click).
|
|
529
|
+
*/
|
|
530
|
+
function invalidKey() {
|
|
531
|
+
var err = new Error('This link has already been used or is not valid.');
|
|
532
|
+
// So a HOST can tell the two apart. Its activation route is called for
|
|
533
|
+
// every user, including ones who never came from a workflow, and "no
|
|
534
|
+
// workflow was waiting on this" is a normal outcome there — not something
|
|
535
|
+
// to surface as a failure. Without a code the only way to classify it is
|
|
536
|
+
// matching on the message, which is a sentence written to be read by a
|
|
537
|
+
// person and will be reworded.
|
|
538
|
+
err.code = 'RESUME_KEY_INVALID';
|
|
539
|
+
return err;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function notReadyKey() {
|
|
543
|
+
var err = new Error('This step is still running — try again in a moment.');
|
|
544
|
+
err.code = 'RESUME_KEY_NOT_READY';
|
|
545
|
+
return err;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A RESUME CAN BE A FAILURE.
|
|
550
|
+
*
|
|
551
|
+
* `opts.status === 'failed'` resolves the parked step as failed instead of
|
|
552
|
+
* succeeded, and from there the step's ordinary `onError` decides what
|
|
553
|
+
* happens — 'stop' (the default) fails the run, 'continue' moves on by
|
|
554
|
+
* position. No new concept: it is the same treatment a step gets when its own
|
|
555
|
+
* action fails, arriving down a different road.
|
|
556
|
+
*
|
|
557
|
+
* This is what a job chain needs. A workflow step that starts a twenty-minute
|
|
558
|
+
* movement parks on its key; the job fails, times out, or is refused because
|
|
559
|
+
* the job was already running; and without this the step would resolve as
|
|
560
|
+
* SUCCESSFUL carrying a failure in its output. The next step would then run
|
|
561
|
+
* against data that was never moved — an aggregation over a period nothing
|
|
562
|
+
* loaded, a deletion of rows that were never replaced. Silently, because
|
|
563
|
+
* every status on the screen would read green.
|
|
564
|
+
*
|
|
565
|
+
* The caller says which it is, because only the caller knows. @xeplr/jobs
|
|
566
|
+
* decides that 'skipped', 'timedOut' and 'interrupted' all mean "the work did
|
|
567
|
+
* not happen" and sends `status: 'failed'`; this engine never learns jobs'
|
|
568
|
+
* vocabulary.
|
|
569
|
+
*
|
|
570
|
+
* OPTIONAL, and absent means success — every existing caller (an approval
|
|
571
|
+
* click, a confirmation link) resolves a step that succeeded, and none of
|
|
572
|
+
* them should have to start saying so.
|
|
573
|
+
*/
|
|
574
|
+
async function resumeByKey(key, output, opts) {
|
|
575
|
+
opts = opts || {};
|
|
576
|
+
var pending = await db.model('WorkflowResumeKey').unscopedQuery().where({ key: key, consumedDate: null }).first();
|
|
577
|
+
if (!pending) throw invalidKey();
|
|
578
|
+
|
|
579
|
+
var run = await db.model('WorkflowRun').unscopedQuery().findById(pending.runId);
|
|
580
|
+
if (!run) throw invalidKey();
|
|
581
|
+
|
|
582
|
+
return runWithMt(mtOf(run), async function() {
|
|
583
|
+
var step = await db.model('WorkflowStep').query().findById(pending.stepId);
|
|
584
|
+
var workflow = await db.model('Workflow').query().findById(run.workflowId);
|
|
585
|
+
var steps = await db.model('WorkflowStep').query().where({ workflowId: run.workflowId }).orderBy('position');
|
|
586
|
+
|
|
587
|
+
// READ BEFORE CLAIMING, and refuse if the step has not actually parked
|
|
588
|
+
// yet. The key row is written before the action runs (see executeFrom),
|
|
589
|
+
// so between email-send handing off and the step being marked 'waiting'
|
|
590
|
+
// there is a live key attached to a step that is still executing. A
|
|
591
|
+
// resume in that window would route the run onward WHILE the wait step's
|
|
592
|
+
// own action is mid-flight. Rejecting without consuming is what makes it
|
|
593
|
+
// recoverable: the caller retries in a moment and it works.
|
|
594
|
+
var existingStepRun = await db.model('WorkflowStepRun').query()
|
|
595
|
+
.where({ runId: run.id, stepId: step.id, status: 'waiting' }).first();
|
|
596
|
+
if (!existingStepRun) throw notReadyKey();
|
|
597
|
+
|
|
598
|
+
// Claim is still the atomic conditional patch, so two concurrent calls
|
|
599
|
+
// that both saw 'waiting' cannot both win.
|
|
600
|
+
var claim = await db.model('WorkflowResumeKey').query()
|
|
601
|
+
.patch({ consumedDate: new Date().toISOString() })
|
|
602
|
+
.where({ id: pending.id, consumedDate: null });
|
|
603
|
+
if (claim === 0) throw invalidKey();
|
|
604
|
+
|
|
605
|
+
var mergedOutput = Object.assign({}, existingStepRun.output, output || {});
|
|
606
|
+
var failed = opts.status === 'failed';
|
|
607
|
+
|
|
608
|
+
await db.model('WorkflowStepRun').query().findById(existingStepRun.id).patch({
|
|
609
|
+
status: failed ? 'failed' : 'success',
|
|
610
|
+
output: mergedOutput,
|
|
611
|
+
// KEPT even on a failure. The job's own output — rows moved, the window
|
|
612
|
+
// it actually covered — is what makes the failure diagnosable, and on a
|
|
613
|
+
// partial movement it is also what says how far it got.
|
|
614
|
+
error: failed ? (opts.error || { message: 'The waiting step was resolved as failed.' }) : null
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await db.model('WorkflowRun').query().findById(run.id).patch({ status: 'running' });
|
|
618
|
+
var fresh = await db.model('WorkflowRun').query().findById(run.id);
|
|
619
|
+
|
|
620
|
+
if (failed) {
|
|
621
|
+
// EXACTLY the branch executeFrom takes when a step's own action fails —
|
|
622
|
+
// written out rather than shared, because the two arrive with different
|
|
623
|
+
// things already done (there is no action result here, and the step run
|
|
624
|
+
// is already patched above). The RULE is what matters and it is the
|
|
625
|
+
// same one: onError decides, and it defaults to stopping.
|
|
626
|
+
if (step.onError !== 'continue') {
|
|
627
|
+
await finishRun(fresh, 'failed', opts.error || { message: 'Step ' + step.stepKey + ' was resolved as failed.' });
|
|
628
|
+
return { runId: run.id, stepKey: step.stepKey, status: 'failed' };
|
|
629
|
+
}
|
|
630
|
+
var after = nextByPosition(steps, step);
|
|
631
|
+
if (!after) {
|
|
632
|
+
await finishRun(fresh, 'success', null);
|
|
633
|
+
return { runId: run.id, stepKey: step.stepKey, status: 'failed' };
|
|
634
|
+
}
|
|
635
|
+
await executeFrom(workflow, steps, fresh, after);
|
|
636
|
+
return { runId: run.id, stepKey: step.stepKey, status: 'failed' };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
var routed = await routeAfterStep(workflow, steps, fresh, step, mergedOutput);
|
|
640
|
+
if (!routed.done) await executeFrom(workflow, steps, fresh, routed.next);
|
|
641
|
+
|
|
642
|
+
return { runId: run.id, stepKey: step.stepKey, status: 'success' };
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// resolveParams and collectParams are exported for their own sake: they are
|
|
647
|
+
// the pieces of the engine that are pure — rows in, a schema or a resolved
|
|
648
|
+
// set out, or a throw — and testing them needs no database.
|
|
649
|
+
module.exports = { startRun: startRun, resumeByKey: resumeByKey, resolveParams: resolveParams, collectParams: collectParams };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- 0001_companies.sql
|
|
2
|
+
-- companies — the top-level tenant (a customer of the workflow product). A
|
|
3
|
+
-- company is the hard multi-tenancy boundary: mtId1 = company.id on every
|
|
4
|
+
-- workspace-scoped row (see registerMTs() in db/setup.js — l1 = companyId).
|
|
5
|
+
-- Access grants live in the auth DB (userTenantsMapping), not filtered here.
|
|
6
|
+
|
|
7
|
+
CREATE TABLE "companies" (
|
|
8
|
+
"id" varchar(25) PRIMARY KEY,
|
|
9
|
+
"name" varchar(255) NOT NULL,
|
|
10
|
+
"code" varchar(50),
|
|
11
|
+
"description" varchar(500),
|
|
12
|
+
"isActive" boolean DEFAULT true,
|
|
13
|
+
"mtId1" varchar(25),
|
|
14
|
+
"mtId2" varchar(25),
|
|
15
|
+
"mtId3" varchar(25),
|
|
16
|
+
"mtId4" varchar(25),
|
|
17
|
+
"recordCreatedDate" timestamp,
|
|
18
|
+
"recordModifiedDate" timestamp,
|
|
19
|
+
"recordCreatedBy" varchar(25),
|
|
20
|
+
"recordModifiedBy" varchar(25)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX "companies_code_index" ON "companies" ("code");
|