@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/README.md
CHANGED
|
@@ -28,7 +28,7 @@ The shared `@xeplr/*` libraries (`actions`, `db`, `expression-handler`, `schema-
|
|
|
28
28
|
|
|
29
29
|
| Export | Purpose |
|
|
30
30
|
|---|---|
|
|
31
|
-
| `startEngine(config)` | Connect workflow's database (creating it if missing), run its migrations, ready `xeplr_configs`, register the action catalog. Idempotent. `config.db` is required; throws if no application id is registered. Config: `db` (`{ name, connection, connectionName?, mts?, migrationsTable? }`), `applicationId`, `tenantTables`, `migrations`, `migrate` (`false`: connect without migrating — a second process of an install whose API migrates), `startJob` (below) |
|
|
31
|
+
| `startEngine(config)` | Connect workflow's database (creating it if missing), run its migrations, ready `xeplr_configs`, register the action catalog. Idempotent. `config.db` is required; throws if no application id is registered. Config: `db` (`{ name, connection, connectionName?, mts?, migrationsTable? }`), `applicationId`, `tenantTables`, `migrations`, `migrate` (`false`: connect without migrating — a second process of an install whose API migrates), `startJob` (below), `schedule` (`false`: no schedule tick in this process), `scheduleIntervalMs` (default 30000) |
|
|
32
32
|
| `resumeByKey(key, output?, opts?)` | Server-side, in-process resume of a run parked on a `wait` step. `opts.status: 'failed'` resolves the step as failed. Throws `err.code` `RESUME_KEY_INVALID` (used/unknown, normal for host routes) or `RESUME_KEY_NOT_READY` (step still running; nothing consumed, retry) |
|
|
33
33
|
| `embedRequiredEnv` | `['DB_WORKFLOW']`, spread into a host's `env.required.js` |
|
|
34
34
|
| `callbackRequiredEnv` | `['WORKFLOW_PUBLIC_URL']`, only for hosts using steps that call out (`job-run`) |
|
|
@@ -36,7 +36,9 @@ The shared `@xeplr/*` libraries (`actions`, `db`, `expression-handler`, `schema-
|
|
|
36
36
|
| `db` | `connectDb`, with `.model(name)` bound to workflow's own connection |
|
|
37
37
|
| `runner` | `lib/workflowRunner.js`: `startRun`, `resumeByKey`, `resolveParams`, `collectParams` |
|
|
38
38
|
| `flows` | `lib/flows.js`: the flows facade (list, create, put, publish, start, submit, runs) and `touchesScreens` |
|
|
39
|
-
| `
|
|
39
|
+
| `triggers`, `registerTrigger` | `lib/triggers.js`: what starts a workflow — the registry of kinds, `validate`, `emit`, the schedule `tick`, `describe` ([Triggers](#triggers-libtriggersjs)) |
|
|
40
|
+
| `conditions` | `lib/conditions.js`: `whenToCondition`, `compileTransitions` ([Conditions](#conditions-written-simply-libconditionsjs)) |
|
|
41
|
+
| `jobs`, `jobFinished` | `lib/jobs.js`: jobs as steps and as triggers — `setJobStarter`, `jobFinished` ([Jobs](#jobs-as-steps-and-as-triggers-libjobsjs)) |
|
|
40
42
|
| `actionCatalog`, `envExposed`, `xcfgSetup` | The action whitelist, the `WORKFLOW_ENV_EXPOSED` allowlist, the shared `xeplr_configs` attachment |
|
|
41
43
|
|
|
42
44
|
The package reads `migrations/` and `migrations-tenants/` from disk (`"xeplr": { "readsFromDisk": [...] }` in `package.json`), so a build that compiles it must ship those folders beside it.
|
|
@@ -49,15 +51,18 @@ engine/
|
|
|
49
51
|
├─ lib/
|
|
50
52
|
│ ├─ workflowRunner.js startRun, resumeByKey, resolveParams, collectParams
|
|
51
53
|
│ ├─ flows.js the flows facade: screens as steps
|
|
52
|
-
│ ├─
|
|
54
|
+
│ ├─ triggers.js what starts a workflow: manual, api, schedule, job, and more
|
|
55
|
+
│ ├─ jobs.js jobs as steps (job-run) and as triggers (jobFinished)
|
|
56
|
+
│ ├─ conditions.js an arrow's `when` compiled into its condition
|
|
53
57
|
│ ├─ actionCatalog.js whitelist of @xeplr/actions built-ins + local actions
|
|
54
|
-
│ ├─ actions/ local actions: job-run, screen-show
|
|
58
|
+
│ ├─ actions/ local actions: job-run, decide, screen-show
|
|
55
59
|
│ ├─ envExposed.js WORKFLOW_ENV_EXPOSED allowlist for {env.*}
|
|
56
60
|
│ └─ db.js connectDb + model(name) bound to workflow's own connection
|
|
57
61
|
├─ db/xcfgSetup.js shared @xeplr/actions attachConfig({ service: 'xeplr-workflow' })
|
|
58
62
|
├─ models/ Company, Workspace, Workflow, WorkflowStep, WorkflowRun,
|
|
59
|
-
│ WorkflowStepRun, WorkflowResumeKey, WorkflowRunEdge
|
|
60
|
-
|
|
63
|
+
│ WorkflowStepRun, WorkflowResumeKey, WorkflowRunEdge,
|
|
64
|
+
│ WorkflowTriggerState
|
|
65
|
+
├─ migrations/ 0003–0015, workflow's own tables
|
|
61
66
|
├─ migrations-tenants/ 0001–0002: standalone workflow's companies / workspaces
|
|
62
67
|
└─ test/ run.mjs + *.test.mjs
|
|
63
68
|
```
|
|
@@ -125,43 +130,101 @@ host gives workflow is the boundary.
|
|
|
125
130
|
Not read by the runner: `workflows.status`, `workflows.kind`,
|
|
126
131
|
`workflow_steps.layout`, `workflow_steps.sampleOutput`, `workflow_steps.timeoutMs`.
|
|
127
132
|
|
|
128
|
-
##
|
|
133
|
+
## Triggers (`lib/triggers.js`)
|
|
129
134
|
|
|
130
|
-
**Every
|
|
131
|
-
starts another job; a job's screen keeps a list of "After this job" rules, and
|
|
132
|
-
`putChain(jobId, { rules, jobName })` turns them into one hidden workflow,
|
|
133
|
-
kind `job-chain`, key `job-chain-<jobId>`:
|
|
135
|
+
**What starts a workflow is its `trigger`**, one shape per kind, and the engine acts on it. Every kind goes through one registry, the way every step goes through the action registry:
|
|
134
136
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
| kind | settings | fires |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `manual` | — | someone presses Start (nothing to arm) |
|
|
140
|
+
| `api` | — | another system calls the run (nothing to arm) |
|
|
141
|
+
| `schedule` | `cron` (`"0 9 * * 1"`), `timezone?` | when the cron expression is due |
|
|
142
|
+
| `job` | `jobId`, `when`: `success` (default) \| `failed` \| `finished` | when that job finishes that way |
|
|
143
|
+
|
|
144
|
+
Every kind also takes `enabled: false`, which switches it off without losing it.
|
|
145
|
+
|
|
146
|
+
The same rules hold for every kind:
|
|
147
|
+
|
|
148
|
+
- **Only a published workflow fires.** A draft never does.
|
|
149
|
+
- **It is checked when it is saved.** The `Workflow` model validates `trigger` on every insert and update; an unknown kind, a job trigger without `jobId` or a cron expression that cannot be read is a 400 naming the problem.
|
|
150
|
+
- **A run gets what fired it.** It is stored as `workflow_runs.triggerData` and read by bindings and by the conditions on arrows as `{trigger.*}`: a job's `{trigger.status}`, `{trigger.window.from}`, `{trigger.output.totalRows}`; a schedule's `{trigger.dueAt}`. A run started by hand has `{trigger.kind}` only.
|
|
151
|
+
- **What it did is a row.** `workflow_trigger_states` keeps the last fire and its run, the next due time, and the last skip and why: a required parameter nobody supplies, a workflow with no steps. A workflow that cannot start is recorded and never thrown at the job that finished. `describe(workflow)` answers `{ trigger, on, lastFiredAt, lastRunId, lastSkippedAt, lastSkipReason, nextDueAt }`.
|
|
152
|
+
|
|
153
|
+
**The schedule tick.** `startEngine` runs it every 30 s, on a timer that does not keep the process alive (`schedule: false` turns it off in a process). It reads every tenant's timed triggers and starts each run in its own workflow's tenant context.
|
|
154
|
+
- A trigger seen for the first time, or whose settings changed, is given its next due time and does **not** fire, so publishing never starts a run for a time already past.
|
|
155
|
+
- A due trigger is **claimed** by a conditional update of its due time, so two processes running the tick start it once.
|
|
156
|
+
|
|
157
|
+
**A host's own kinds.** `registerTrigger(kind, { validate, due?, matches? })`:
|
|
158
|
+
- a time kind gives `due(trigger, from)`, its next due `Date`;
|
|
159
|
+
- an event kind gives `matches(trigger, event)`, and the host calls `triggers.emit(kind, event)` when the event happens.
|
|
160
|
+
|
|
161
|
+
Folder and mailbox watchers are next, as kinds of this registry.
|
|
162
|
+
|
|
163
|
+
## Conditions written simply (`lib/conditions.js`)
|
|
164
|
+
|
|
165
|
+
An arrow (a transition) can carry `when` instead of an engine `condition`. The engine compiles it into the `condition` every time the step is saved (`WorkflowStep`'s hooks) and keeps `when` beside it, so a screen reads back exactly what it wrote. `when` is one of:
|
|
137
166
|
|
|
138
|
-
|
|
167
|
+
- `{ "field": "trigger.status", "op": "=", "value": "success" }`
|
|
168
|
+
- `{ "all": [ … ] }` or `{ "any": [ … ] }`
|
|
169
|
+
- empty, meaning "otherwise"
|
|
170
|
+
|
|
171
|
+
The comparisons are the flows facade's: `=`, `!=`, `>`, `>=`, `<`, `<=`, `contains`, `notContains`, `startsWith`, `endsWith`, `in`, `notIn`, `between`, `isEmpty`, `isNotEmpty`.
|
|
172
|
+
|
|
173
|
+
A bad `when` is refused when it is saved, naming the step and the arrow. That is how a screen of your own (and the jobs screen's "After this job") writes branches without knowing the node format.
|
|
174
|
+
|
|
175
|
+
## Jobs as steps and as triggers (`lib/jobs.js`)
|
|
176
|
+
|
|
177
|
+
A job meets a workflow in two places, and both are ordinary workflows:
|
|
178
|
+
|
|
179
|
+
- **As a step:** `job-run` starts a job and parks (a `wait` step) until it reports back. The run carries on with its result.
|
|
180
|
+
- **As a trigger:** `{ "kind": "job", "jobId": "…", "when": "success" }` starts the workflow when that job finishes.
|
|
181
|
+
|
|
182
|
+
Branch on how a job ended with a `decide` step: it does nothing itself, and the conditions on its arrows read `{trigger.*}`.
|
|
139
183
|
|
|
140
184
|
```json
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
]
|
|
185
|
+
{ "name": "After the nightly load", "status": "published",
|
|
186
|
+
"trigger": { "kind": "job", "jobId": "<load>", "when": "finished" },
|
|
187
|
+
"steps": [
|
|
188
|
+
{ "stepKey": "decide", "actionName": "decide", "transitions": [
|
|
189
|
+
{ "when": { "field": "trigger.status", "op": "=", "value": "success" }, "target": "report" },
|
|
190
|
+
{ "when": null, "target": "alert" } ] },
|
|
191
|
+
{ "stepKey": "report", "actionName": "job-run", "kind": "wait",
|
|
192
|
+
"values": { "jobId": "<report job>", "callbackUrl": "{resumeUrl}" } },
|
|
193
|
+
{ "stepKey": "alert", "actionName": "send-email", "values": { "to": "ops@example.com", "subject": "Load failed: {trigger.error.message}" } }
|
|
194
|
+
] }
|
|
147
195
|
```
|
|
148
196
|
|
|
149
|
-
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
197
|
+
**A finished job gives the workflow it triggers** `{trigger.*}`:
|
|
198
|
+
- `jobId` and `status` (`success` / `failed`)
|
|
199
|
+
- `error`, when it failed
|
|
200
|
+
- `output`: what its action returned
|
|
201
|
+
- `window`: the period it ran for. That is the range it was given or, given none, the range its action reports it covered.
|
|
202
|
+
- `given` / `used`: those two ranges separately
|
|
203
|
+
- `carry`: values carried from the jobs before it
|
|
204
|
+
- `path`: the jobs that led here
|
|
154
205
|
|
|
155
|
-
|
|
206
|
+
**When a job started such a run, `job-run` carries it on:**
|
|
207
|
+
- **The period flows.** The next job gets the finished job's `window` unless the step's `inputs` set one, so a chain of workflows covers one period from its first job to its last.
|
|
208
|
+
- **The next job is offered both earlier ranges**, as `@xeplr/jobs`' `previous.given` / `previous.used`.
|
|
209
|
+
- **`carry`** (a `job-run` input) adds values for the jobs after it.
|
|
210
|
+
- **`inputs` and `carry` keep their types.** A binding that is the whole value (`"{trigger.output.totalRows}"`) passes `7`, not `"7"`, and an object stays an object. Every other step's input is text, as bindings make it.
|
|
211
|
+
- **Loops stop.** A job already on the path, or a path 25 jobs long, fails the step with a message.
|
|
212
|
+
|
|
213
|
+
The next job takes only `window` and the inputs it lists in `acceptsInputs`; `@xeplr/jobs` refuses the rest, naming them.
|
|
156
214
|
|
|
157
215
|
**The host connects jobs, in-process.** The engine never imports jobs:
|
|
158
216
|
|
|
159
217
|
```js
|
|
160
218
|
await engine.startEngine({ db, startJob: (jobId, body) => jobs.trigger(jobId, body) });
|
|
161
|
-
await jobs.init({ /* … */ onFinish: (report) => engine.
|
|
219
|
+
await jobs.init({ /* … */ onFinish: (report) => engine.jobFinished(report) });
|
|
162
220
|
```
|
|
163
221
|
|
|
164
|
-
`jobFinished(report)` takes `@xeplr/jobs`' finish envelope.
|
|
222
|
+
`jobFinished(report)` takes `@xeplr/jobs`' finish envelope. It resumes the `job-run` step that started the job (retrying while that step parks) and starts every workflow the job triggers. Without `startJob`, `job-run` starts jobs over HTTP (`jobsUrl`, and `WORKFLOW_PUBLIC_URL` for the callback).
|
|
223
|
+
|
|
224
|
+
**Job chains are gone.** Migration 0015 converted each "After this job" chain (kind `job-chain`) into an ordinary workflow:
|
|
225
|
+
- it gets a `job` trigger (`when: finished`);
|
|
226
|
+
- its `decide` and `job-run` steps keep the same behaviour;
|
|
227
|
+
- conditions and bindings now read `trigger.*`.
|
|
165
228
|
|
|
166
229
|
## Tests
|
|
167
230
|
|
|
@@ -169,12 +232,21 @@ await jobs.init({ /* … */ onFinish: (report) => engine.jobChains.jobFinished(r
|
|
|
169
232
|
npm test # node test/run.mjs — each *.test.mjs in its own process
|
|
170
233
|
```
|
|
171
234
|
|
|
172
|
-
No database or network needed, except `
|
|
235
|
+
No database or network needed, except `triggers.test.mjs`, which runs on a real Postgres when `WORKFLOW_TEST_PG='{"host":…,"port":…,"user":…,"password":…}'` is set (scratch databases, created and dropped) and is skipped otherwise. CI and the release start a throwaway Postgres and set it, so neither ships on a skipped suite. `npm install` first: the peers install with it.
|
|
173
236
|
|
|
174
237
|
| Suite | Covers |
|
|
175
238
|
|---|---|
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
239
|
+
| `conditions.test.mjs` | `when` → condition: a comparison, otherwise, nested all/any, every refusal naming the step and arrow, `when` kept beside the compiled condition |
|
|
240
|
+
| `triggers.test.mjs` | Triggers with the real `@xeplr/jobs`, wired as a host wires it:
|
|
241
|
+
- every refusal when saved
|
|
242
|
+
- a job finishing starts the workflows it triggers, B and D running with A's period
|
|
243
|
+
- `success`, `failed` and `finished`
|
|
244
|
+
- a trigger switched off, and a draft, never firing
|
|
245
|
+
- a loop stopped
|
|
246
|
+
- a workflow that cannot start, recorded as a skip
|
|
247
|
+
- the schedule's first sight, its due time, two ticks starting it once, a changed cron
|
|
248
|
+
- migration 0015 turning an old job chain into a workflow that runs |
|
|
249
|
+
| `actionCatalog.test.mjs` | Ready vs placeholder detection; registered names are kebab-case; `spawnProgram` not offered; `decide` and `job-run` offered |
|
|
178
250
|
| `envExposed.test.mjs` | Nothing exposed by default; exact, case-sensitive matching; globs fail closed (against real `interpolateAll`) |
|
|
179
251
|
| `requires.test.mjs` | Every relative `require` in this package points at a file (Node only notices a missing one when the line runs; a bundler refuses it up front) |
|
|
180
252
|
| `paths.test.mjs` | `MIGRATIONS_DIR` and `TENANT_MIGRATIONS_DIR` exist inside the package, hold SQL, and are listed in `readsFromDisk` (an undefined one makes the migrator run the HOST's `./migrations` against workflow's database) |
|
package/index.js
CHANGED
|
@@ -6,7 +6,8 @@ var actionCatalog = require('./lib/actionCatalog');
|
|
|
6
6
|
var envExposed = require('./lib/envExposed');
|
|
7
7
|
var workflowRunner = require('./lib/workflowRunner');
|
|
8
8
|
var flows = require('./lib/flows');
|
|
9
|
-
var
|
|
9
|
+
var jobs = require('./lib/jobs');
|
|
10
|
+
var triggers = require('./lib/triggers');
|
|
10
11
|
var xcfgSetup = require('./db/xcfgSetup');
|
|
11
12
|
|
|
12
13
|
// THE WORKFLOW ENGINE — everything that runs a workflow, with no HTTP in it:
|
|
@@ -39,12 +40,15 @@ var TENANT_MIGRATIONS_DIR = path.join(__dirname, 'migrations-tenants');
|
|
|
39
40
|
* @param {Function} [config.startJob] - async (jobId, { inputs, chain,
|
|
40
41
|
* callbackUrl }) → { occurrenceId }: how this host starts a job. With
|
|
41
42
|
* @xeplr/jobs in the same process, `(id, body) => jobs.trigger(id, body)`.
|
|
42
|
-
* Used by
|
|
43
|
-
*
|
|
43
|
+
* Used by the job-run step; unset, it falls back to its jobsUrl over HTTP.
|
|
44
|
+
* @param {boolean} [config.schedule=true] - false: do not run the schedule
|
|
45
|
+
* tick in this process (lib/triggers.js). Two processes may both run it —
|
|
46
|
+
* a due trigger is claimed, so it starts once — but one is enough.
|
|
47
|
+
* @param {number} [config.scheduleIntervalMs=30000] - how often it looks.
|
|
44
48
|
*/
|
|
45
49
|
async function startEngine(config) {
|
|
46
50
|
config = config || {};
|
|
47
|
-
if (config.startJob !== undefined)
|
|
51
|
+
if (config.startJob !== undefined) jobs.setJobStarter(config.startJob);
|
|
48
52
|
if (!config.db) {
|
|
49
53
|
throw new Error('startEngine: config.db is required — workflow owns its own domain data regardless of how it is mounted');
|
|
50
54
|
}
|
|
@@ -179,6 +183,9 @@ async function startEngine(config) {
|
|
|
179
183
|
// secret is only catchable if it is visible somewhere.
|
|
180
184
|
envExposed.logExposure();
|
|
181
185
|
|
|
186
|
+
// Scheduled workflows start themselves from here on. The timer does not
|
|
187
|
+
// keep the process alive.
|
|
188
|
+
if (config.schedule !== false) triggers.startTicking(config.scheduleIntervalMs);
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
// Env vars a HOST embedding workflow must supply — spread into the host's
|
|
@@ -284,7 +291,18 @@ module.exports = {
|
|
|
284
291
|
envExposed: envExposed,
|
|
285
292
|
runner: workflowRunner,
|
|
286
293
|
flows: flows,
|
|
287
|
-
|
|
294
|
+
// What starts a workflow: the registry of kinds (manual, api, schedule,
|
|
295
|
+
// job — and a host's own via registerTrigger), the schedule tick, and what
|
|
296
|
+
// a trigger last did.
|
|
297
|
+
triggers: triggers,
|
|
298
|
+
registerTrigger: triggers.registerTrigger,
|
|
299
|
+
// Jobs as steps and as triggers. A host with @xeplr/jobs passes jobs'
|
|
300
|
+
// onFinish to jobFinished, so a finished job starts the workflows it
|
|
301
|
+
// triggers and resumes the step that started it.
|
|
302
|
+
jobs: jobs,
|
|
303
|
+
jobFinished: jobs.jobFinished,
|
|
304
|
+
// An arrow's `when` ({ field, op, value }) → the engine's condition node.
|
|
305
|
+
conditions: require('./lib/conditions'),
|
|
288
306
|
xcfgSetup: xcfgSetup,
|
|
289
307
|
auth: require('./lib/auth')
|
|
290
308
|
};
|
package/lib/actionCatalog.js
CHANGED
|
@@ -59,7 +59,9 @@ var WANTED = [
|
|
|
59
59
|
// same way and appearing in the same catalog, so the step editor needs no
|
|
60
60
|
// notion that some actions come from here and some from there.
|
|
61
61
|
var LOCAL = [
|
|
62
|
-
require('./actions/jobRun')
|
|
62
|
+
require('./actions/jobRun'),
|
|
63
|
+
// A step that only branches: its arrows hold the conditions.
|
|
64
|
+
require('./actions/decide')
|
|
63
65
|
];
|
|
64
66
|
|
|
65
67
|
// REGISTERED BUT NOT OFFERED.
|
|
@@ -76,11 +78,7 @@ var LOCAL = [
|
|
|
76
78
|
// that facade knows how to show the screen or how to resume it. Hiding it is
|
|
77
79
|
// not secrecy; it is not offering half of something.
|
|
78
80
|
var HIDDEN = [
|
|
79
|
-
require('./actions/screenShow')
|
|
80
|
-
// A job chain's two steps, generated by lib/jobChains.js from a job's
|
|
81
|
-
// "After this job" rules — same reasoning as screen-show.
|
|
82
|
-
require('./actions/jobChainSteps').decide,
|
|
83
|
-
require('./actions/jobChainSteps').next
|
|
81
|
+
require('./actions/screenShow')
|
|
84
82
|
];
|
|
85
83
|
|
|
86
84
|
var HIDDEN_NAMES = HIDDEN.map(function(def) { return def.name; });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// decide — A STEP THAT ONLY CHOOSES WHERE TO GO.
|
|
2
|
+
//
|
|
3
|
+
// It does nothing itself; its arrows (transitions) carry the conditions, first
|
|
4
|
+
// match wins, and a blank one is "otherwise". The way to branch on what
|
|
5
|
+
// started the run ({trigger.status} after a job) or on anything earlier,
|
|
6
|
+
// before any other step has run.
|
|
7
|
+
module.exports = {
|
|
8
|
+
name: 'decide',
|
|
9
|
+
description: 'Choose the next step from conditions on its arrows — on what started the run, ' +
|
|
10
|
+
'or on anything earlier. Does nothing itself.',
|
|
11
|
+
requires: [],
|
|
12
|
+
inputSchema: [],
|
|
13
|
+
outputSchema: null,
|
|
14
|
+
execute: async function() { return {}; }
|
|
15
|
+
};
|
package/lib/actions/jobRun.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// job-run — START A JOB AND PARK UNTIL IT REPORTS BACK.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// A job as a STEP of any workflow (a job as a TRIGGER is lib/triggers.js). Everything it does is
|
|
4
4
|
// two HTTP calls' worth of glue between two products that do not import each
|
|
5
5
|
// other:
|
|
6
6
|
//
|
|
@@ -56,7 +56,10 @@ module.exports = {
|
|
|
56
56
|
{ name: 'inputs', type: 'object', order: 4, group: 'Advanced',
|
|
57
57
|
description: 'Values to override the job\'s saved inputs, for this run only. The job row ' +
|
|
58
58
|
'is not changed. Commonly a window: { window: { from, to } }.' },
|
|
59
|
-
{ name: '
|
|
59
|
+
{ name: 'carry', type: 'object', order: 5, group: 'Advanced',
|
|
60
|
+
description: 'Values carried along to the jobs after this one — a workflow those jobs trigger ' +
|
|
61
|
+
'reads them as {trigger.carry.*}.' },
|
|
62
|
+
{ name: 'timeoutMs', type: 'number', default: 30000, order: 6, group: 'Advanced',
|
|
60
63
|
description: 'How long to wait for the TRIGGER call to be accepted. Nothing to do with ' +
|
|
61
64
|
'how long the job itself may run — that is the job\'s own timeout.' }
|
|
62
65
|
],
|
|
@@ -69,12 +72,26 @@ module.exports = {
|
|
|
69
72
|
// jobsUrl and no credentials: the call runs inside this run's tenant
|
|
70
73
|
// context, which is the job's. Without it this step falls back to the
|
|
71
74
|
// jobs API over HTTP below.
|
|
72
|
-
var
|
|
75
|
+
var jobs = require('../jobs');
|
|
76
|
+
// WHEN THIS RUN WAS STARTED BY A JOB FINISHING, the period flows on to
|
|
77
|
+
// this job unless the step sets one, carried values and the path travel
|
|
78
|
+
// with it, and a job already on the path is refused as a loop
|
|
79
|
+
// (jobs.nextJobCall). Any other run passes the step's inputs as they are.
|
|
80
|
+
// Inputs and carry KEEP THEIR TYPES ("{trigger.output.totalRows}" is 7,
|
|
81
|
+
// not "7"): they are handed on to a job, not printed.
|
|
82
|
+
var sys = ctx.system || {};
|
|
83
|
+
var raw = sys.rawValues || {};
|
|
84
|
+
var typed = typeof sys.resolveTyped === 'function';
|
|
85
|
+
var call = jobs.nextJobCall(sys.trigger, input.jobId,
|
|
86
|
+
typed && raw.inputs !== undefined ? sys.resolveTyped(raw.inputs) : input.inputs,
|
|
87
|
+
typed && raw.carry !== undefined ? sys.resolveTyped(raw.carry) : input.carry);
|
|
88
|
+
var startJob = jobs.jobStarter();
|
|
73
89
|
if (startJob) {
|
|
90
|
+
call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
|
|
74
91
|
var started = await startJob(input.jobId, {
|
|
75
|
-
inputs:
|
|
92
|
+
inputs: Object.keys(call.inputs).length ? call.inputs : null,
|
|
76
93
|
callbackUrl: input.callbackUrl || null,
|
|
77
|
-
chain:
|
|
94
|
+
chain: call.chain
|
|
78
95
|
});
|
|
79
96
|
return {
|
|
80
97
|
jobId: input.jobId,
|
|
@@ -125,8 +142,9 @@ module.exports = {
|
|
|
125
142
|
method: 'POST',
|
|
126
143
|
headers: { 'Content-Type': 'application/json' },
|
|
127
144
|
body: JSON.stringify({
|
|
128
|
-
inputs:
|
|
129
|
-
callbackUrl: input.callbackUrl
|
|
145
|
+
inputs: Object.keys(call.inputs).length ? call.inputs : undefined,
|
|
146
|
+
callbackUrl: input.callbackUrl,
|
|
147
|
+
chain: call.chain
|
|
130
148
|
}),
|
|
131
149
|
signal: controller.signal
|
|
132
150
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// AN ARROW'S CONDITION, WRITTEN SIMPLY.
|
|
2
|
+
//
|
|
3
|
+
// The engine evaluates conditions as nodes — { left: { field }, op: 'eq',
|
|
4
|
+
// right: { value } }. A screen of anyone's (the jobs screen's "After this
|
|
5
|
+
// job", an app built on this API) can instead write an arrow's `when`:
|
|
6
|
+
//
|
|
7
|
+
// { "field": "trigger.status", "op": "=", "value": "success" }
|
|
8
|
+
// { "all": [ … ] } { "any": [ … ] } null — "otherwise"
|
|
9
|
+
//
|
|
10
|
+
// and the engine compiles it into `condition` when the step is saved
|
|
11
|
+
// (models/WorkflowStep.js), keeping `when` beside it so a screen reads back
|
|
12
|
+
// exactly what it wrote. One compiler, here — the comparisons are the flows
|
|
13
|
+
// facade's (flows.comparisonNode), so `=` means one thing everywhere.
|
|
14
|
+
var flows = require('./flows');
|
|
15
|
+
|
|
16
|
+
var FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_-]+)*$/;
|
|
17
|
+
|
|
18
|
+
function invalid(message) {
|
|
19
|
+
var err = flows.fail(400, 'CONDITION_INVALID', message);
|
|
20
|
+
// A model validation error by name, so the generic save answers it as one.
|
|
21
|
+
err.name = 'ValidationError';
|
|
22
|
+
err.type = 'ModelValidation';
|
|
23
|
+
err.statusCode = 400;
|
|
24
|
+
err.data = { transitions: [{ message: message, keyword: 'when' }] };
|
|
25
|
+
return err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `when` → the engine's condition node; null/undefined → null ("otherwise"). */
|
|
29
|
+
function whenToCondition(when, where) {
|
|
30
|
+
where = where || 'This condition';
|
|
31
|
+
if (when === null || when === undefined) return null;
|
|
32
|
+
if (typeof when !== 'object' || Array.isArray(when)) {
|
|
33
|
+
throw invalid(where + ': the condition must be an object such as ' +
|
|
34
|
+
'{ "field": "trigger.status", "op": "=", "value": "success" }, or empty for "otherwise".');
|
|
35
|
+
}
|
|
36
|
+
if (when.all !== undefined || when.any !== undefined) {
|
|
37
|
+
var key = when.all !== undefined ? 'all' : 'any';
|
|
38
|
+
var list = when[key];
|
|
39
|
+
if (!Array.isArray(list) || !list.length) throw invalid(where + ': `' + key + '` needs a non-empty list of conditions.');
|
|
40
|
+
var group = {};
|
|
41
|
+
group[key] = list.map(function(c, i) {
|
|
42
|
+
var node = whenToCondition(c, where + ', ' + key + ' ' + (i + 1));
|
|
43
|
+
if (!node) throw invalid(where + ': an empty condition inside `' + key + '`.');
|
|
44
|
+
return node;
|
|
45
|
+
});
|
|
46
|
+
return group;
|
|
47
|
+
}
|
|
48
|
+
var field = String(when.field == null ? '' : when.field).trim();
|
|
49
|
+
if (!field) throw invalid(where + ': `field` is required — e.g. "trigger.status" or "output.rowsMoved".');
|
|
50
|
+
if (!FIELD_PATTERN.test(field)) throw invalid(where + ': "' + field + '" is not a usable field.');
|
|
51
|
+
try {
|
|
52
|
+
return flows.comparisonNode(when, where, field, 'CONDITION_INVALID');
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw invalid(err.message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A step's transitions with every `when` compiled into its `condition`.
|
|
60
|
+
* Transitions without `when` are left exactly as they are.
|
|
61
|
+
*/
|
|
62
|
+
function compileTransitions(transitions, stepName) {
|
|
63
|
+
if (!Array.isArray(transitions)) return transitions;
|
|
64
|
+
return transitions.map(function(t, i) {
|
|
65
|
+
if (!t || typeof t !== 'object' || t.when === undefined) return t;
|
|
66
|
+
var out = Object.assign({}, t);
|
|
67
|
+
out.condition = whenToCondition(t.when, (stepName ? 'Step "' + stepName + '"' : 'A step') + ', arrow ' + (i + 1));
|
|
68
|
+
return out;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { whenToCondition: whenToCondition, compileTransitions: compileTransitions };
|
package/lib/flows.js
CHANGED
|
@@ -200,7 +200,7 @@ function whenToCondition(when, where) {
|
|
|
200
200
|
/**
|
|
201
201
|
* `{ op, value }` against the engine path `path` → the expression node the
|
|
202
202
|
* engine evaluates. The comparison half of a `when`, shared by every facade
|
|
203
|
-
* that writes conditions (screens flows here
|
|
203
|
+
* that writes conditions (screens flows here), so
|
|
204
204
|
* "=" means one thing however a condition arrives.
|
|
205
205
|
*
|
|
206
206
|
* @param {object} when { op, value }
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// JOBS AND WORKFLOWS — the two places they meet, and nothing more.
|
|
2
|
+
//
|
|
3
|
+
// a job as a STEP the job-run step starts a job and parks until it ends
|
|
4
|
+
// a job as a TRIGGER a workflow whose trigger is { kind: 'job', jobId }
|
|
5
|
+
// starts when that job finishes (lib/triggers.js)
|
|
6
|
+
//
|
|
7
|
+
// Both are ordinary workflows. The host tells this module two things, because
|
|
8
|
+
// only the host knows both products are present: how to start a job
|
|
9
|
+
// (setJobStarter — @xeplr/jobs' trigger()) and that a job has finished
|
|
10
|
+
// (jobFinished — @xeplr/jobs' onFinish hook). This module never imports jobs.
|
|
11
|
+
var triggers = require('./triggers');
|
|
12
|
+
|
|
13
|
+
// Supplied by the host: async (jobId, { inputs, chain, callbackUrl }) →
|
|
14
|
+
// { occurrenceId }. With @xeplr/jobs in the same process that is
|
|
15
|
+
// `jobs.trigger`. Unset, a job-run step falls back to the jobs API over HTTP.
|
|
16
|
+
var _startJob = null;
|
|
17
|
+
|
|
18
|
+
function setJobStarter(fn) {
|
|
19
|
+
_startJob = typeof fn === 'function' ? fn : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jobStarter() { return _startJob; }
|
|
23
|
+
|
|
24
|
+
function pause(ms) {
|
|
25
|
+
return new Promise(function(resolve) { var t = setTimeout(resolve, ms); if (t.unref) t.unref(); });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resume the job-run step that started this job.
|
|
30
|
+
*
|
|
31
|
+
* A job can finish before the step that started it has parked — the start is
|
|
32
|
+
* fire-and-forget, and a quick job ends in milliseconds — and the engine
|
|
33
|
+
* answers that with RESUME_KEY_NOT_READY rather than routing a step that is
|
|
34
|
+
* still running. So it is tried again for a few seconds, as @xeplr/jobs' HTTP
|
|
35
|
+
* callback retries. A key already used (that callback got there first) is a
|
|
36
|
+
* settled answer.
|
|
37
|
+
*/
|
|
38
|
+
async function resumeStep(key, output, opts) {
|
|
39
|
+
var runner = require('./workflowRunner');
|
|
40
|
+
for (var attempt = 0; attempt < 20; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
await runner.resumeByKey(key, output, opts);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'RESUME_KEY_INVALID') return false;
|
|
46
|
+
if (err.code !== 'RESUME_KEY_NOT_READY') throw err;
|
|
47
|
+
await pause(250);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error('The step waiting on this job never parked, so it could not be told the job finished.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a finished job hands a workflow it triggers, as {trigger.*}:
|
|
55
|
+
*
|
|
56
|
+
* jobId the job that finished
|
|
57
|
+
* status 'success' | 'failed'
|
|
58
|
+
* error its error, when it failed
|
|
59
|
+
* output what its action returned
|
|
60
|
+
* window the period it ran for: the range it was given, or — given
|
|
61
|
+
* none — the range its action reports it covered
|
|
62
|
+
* given / used those two ranges separately (@xeplr/jobs' previous.*)
|
|
63
|
+
* carry values carried along from the jobs before it
|
|
64
|
+
* path the jobs that led here, oldest first — for loop protection
|
|
65
|
+
*
|
|
66
|
+
* Pure — exported to test.
|
|
67
|
+
*/
|
|
68
|
+
function eventOf(report) {
|
|
69
|
+
report = report || {};
|
|
70
|
+
var out = report.output || {};
|
|
71
|
+
var chain = out.chain || {};
|
|
72
|
+
var path = Array.isArray(chain.path) ? chain.path.slice() : [];
|
|
73
|
+
if (out.jobId) path.push(out.jobId);
|
|
74
|
+
return {
|
|
75
|
+
jobId: out.jobId || null,
|
|
76
|
+
status: report.status === 'success' ? 'success' : 'failed',
|
|
77
|
+
error: report.error || null,
|
|
78
|
+
output: out.output || null,
|
|
79
|
+
window: out.window || (out.output && out.output.window) || null,
|
|
80
|
+
given: out.window || null,
|
|
81
|
+
used: (out.output && out.output.window) || null,
|
|
82
|
+
carry: chain.carry && typeof chain.carry === 'object' ? chain.carry : {},
|
|
83
|
+
path: path
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Called by the host for EVERY job run that ends (@xeplr/jobs' onFinish), in
|
|
89
|
+
* that job's tenant context, with the jobs envelope { status, error, output }.
|
|
90
|
+
*
|
|
91
|
+
* Two things, independently: if a job-run step started this job, resume it;
|
|
92
|
+
* and start every workflow whose trigger is this job finishing.
|
|
93
|
+
*/
|
|
94
|
+
async function jobFinished(report) {
|
|
95
|
+
report = report || {};
|
|
96
|
+
var out = report.output || {};
|
|
97
|
+
var chain = out.chain || {};
|
|
98
|
+
var result = { resumed: false, started: [] };
|
|
99
|
+
|
|
100
|
+
if (chain.resumeKey) {
|
|
101
|
+
result.resumed = await resumeStep(chain.resumeKey, out,
|
|
102
|
+
report.status === 'success' ? {} : { status: 'failed', error: report.error });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!out.jobId) return result;
|
|
106
|
+
result.started = await triggers.emit('job', eventOf(report));
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What a job-run step hands the job it starts, when its run was itself
|
|
112
|
+
* started by a job: the period flows on unless the step sets one, the carried
|
|
113
|
+
* values and the path travel with it, and both of the previous job's ranges
|
|
114
|
+
* are offered (@xeplr/jobs' previous.given / previous.used). A job already on
|
|
115
|
+
* the path, or a path at MAX_DEPTH, is refused — that is a loop.
|
|
116
|
+
*
|
|
117
|
+
* Pure — exported to test.
|
|
118
|
+
*/
|
|
119
|
+
function nextJobCall(trigger, jobId, inputs, carry) {
|
|
120
|
+
var t = trigger && trigger.kind === 'job' ? trigger : null;
|
|
121
|
+
var path = t && Array.isArray(t.path) ? t.path : [];
|
|
122
|
+
if (path.indexOf(jobId) > -1) {
|
|
123
|
+
throw new Error('Job ' + jobId + ' has already run in this chain (' + path.concat(jobId).join(' → ') +
|
|
124
|
+
'), so running it again would loop. Change the workflows so the chain ends.');
|
|
125
|
+
}
|
|
126
|
+
if (path.length >= triggers.MAX_DEPTH) {
|
|
127
|
+
throw new Error('This chain has run ' + path.length + ' jobs in a row, which is the most allowed. Check the workflows for a loop.');
|
|
128
|
+
}
|
|
129
|
+
var given = inputs && typeof inputs === 'object' && !Array.isArray(inputs) ? Object.assign({}, inputs) : {};
|
|
130
|
+
if (t && t.window && given.window === undefined) given = Object.assign({ window: t.window }, given);
|
|
131
|
+
var carried = Object.assign({}, (t && t.carry) || {}, carry && typeof carry === 'object' ? carry : {});
|
|
132
|
+
return {
|
|
133
|
+
inputs: given,
|
|
134
|
+
chain: { carry: carried, path: path, previous: { given: (t && t.given) || null, used: (t && t.used) || null } }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
setJobStarter: setJobStarter,
|
|
140
|
+
jobStarter: jobStarter,
|
|
141
|
+
jobFinished: jobFinished,
|
|
142
|
+
eventOf: eventOf,
|
|
143
|
+
nextJobCall: nextJobCall
|
|
144
|
+
};
|