@xeplr/workflow-engine 1.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
- | `jobChains` | `lib/jobChains.js`: a job's "After this job" rules as a workflow — `getChain`, `putChain`, `jobFinished`, `setJobStarter`, `touchesChains` ([Job chains](#job-chains-libjobchainsjs)) |
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
- │ ├─ jobChains.js a job's "After this job" rules, kept as a workflow
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, job-chain-decide/-next
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
- ├─ migrations/ 0003–0014, workflow's own tables
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,100 @@ 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
- ## Job chains (`lib/jobChains.js`)
133
+ ## Triggers (`lib/triggers.js`)
129
134
 
130
- **Every hand-off between jobs goes through this engine.** `@xeplr/jobs` never
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
- - `decide` (`job-chain-decide`, does nothing): its transitions are the rules, first match wins, and no match ends the run successfully;
136
- - `run_1 … run_n` (`job-chain-next`, wait steps): start that rule's job and park until it reports back.
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
- A rule is `{ when, run, inputs?, carry? }`:
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
- { "when": { "field": "output.totalRows", "op": ">", "value": 0 }, "run": "<jobId>",
143
- "inputs": { "region": "{output.region}" }, "carry": { "rows": "{output.totalRows}" } },
144
- { "when": { "all": [ { "field": "status", "op": "=", "value": "failed" } ] }, "run": "<jobId>" },
145
- { "run": "<jobId>" }
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
- - **`when`** is `{ field, op, value }`, `{ all: [...] }`, `{ any: [...] }`, or empty for "otherwise" (last only). Fields start with `status` (`success`/`failed`), `output.` (what the finished job's action returned), `window.`, `carry.`, `error.` or `jobId`. The comparisons are the flows facade's (`flows.comparisonNode`), so `=` means one thing everywhere.
150
- - **The period flows on.** The next job is started with the finished job's `window` unless the rule's `inputs` set one, so a chain covers one period from its first job to its last.
151
- - **`inputs`** fill inputs of the next job — only `window` and the names that job lists in `acceptsInputs` (`@xeplr/jobs` refuses the rest, and the run fails saying which).
152
- - **`carry`** adds values for later jobs, read as `carry.<name>`. Bindings like `{output.x}` keep their type when they are the whole value.
153
- - **Loops stop**: a job that has already run in this chain, or a chain 25 jobs long, fails the step with a message.
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
- `putChain` with an empty list sets the workflow back to draft (its runs stay). `getChain` answers the rules as written (kept on the `decide` step). A `job-chain` workflow cannot be saved through `/workflows/save` (`touchesChains`), like a screens flow.
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
+ - **Loops stop.** A job already on the path, or a path 25 jobs long, fails the step with a message.
211
+
212
+ The next job takes only `window` and the inputs it lists in `acceptsInputs`; `@xeplr/jobs` refuses the rest, naming them.
156
213
 
157
214
  **The host connects jobs, in-process.** The engine never imports jobs:
158
215
 
159
216
  ```js
160
217
  await engine.startEngine({ db, startJob: (jobId, body) => jobs.trigger(jobId, body) });
161
- await jobs.init({ /* … */ onFinish: (report) => engine.jobChains.jobFinished(report) });
218
+ await jobs.init({ /* … */ onFinish: (report) => engine.jobFinished(report) });
162
219
  ```
163
220
 
164
- `jobFinished(report)` takes `@xeplr/jobs`' finish envelope. If the run was started by a chain step it resumes that step (retrying while the step parks), and if the finished job has rules it starts its chain with params `{ jobId, status, error, output, window, carry, path }`. With `startJob` set, the canvas's `job-run` step starts jobs the same way, with no `jobsUrl` and no HTTP call.
221
+ `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).
222
+
223
+ **Job chains are gone.** Migration 0015 converted each "After this job" chain (kind `job-chain`) into an ordinary workflow:
224
+ - it gets a `job` trigger (`when: finished`);
225
+ - its `decide` and `job-run` steps keep the same behaviour;
226
+ - conditions and bindings now read `trigger.*`.
165
227
 
166
228
  ## Tests
167
229
 
@@ -169,12 +231,21 @@ await jobs.init({ /* … */ onFinish: (report) => engine.jobChains.jobFinished(r
169
231
  npm test # node test/run.mjs — each *.test.mjs in its own process
170
232
  ```
171
233
 
172
- No database or network needed, except `jobChains.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.
234
+ 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
235
 
174
236
  | Suite | Covers |
175
237
  |---|---|
176
- | `jobChains.test.mjs` | "After this job" with the real `@xeplr/jobs` (a dev dependency) wired as a host wires it: rules ⇄ workflow, every refusal, A → B → D with A's period and carried values, otherwise, no match, a failed job followed, loops and over-long chains stopped, an input the next job does not accept, `job-run` in-process, rules removed |
177
- | `actionCatalog.test.mjs` | Ready vs placeholder detection; registered names are kebab-case; `spawnProgram` not offered |
238
+ | `conditions.test.mjs` | `when` → condition: a comparison, otherwise, nested all/any, every refusal naming the step and arrow, `when` kept beside the compiled condition |
239
+ | `triggers.test.mjs` | Triggers with the real `@xeplr/jobs`, wired as a host wires it:
240
+ - every refusal when saved
241
+ - a job finishing starts the workflows it triggers, B and D running with A's period
242
+ - `success`, `failed` and `finished`
243
+ - a trigger switched off, and a draft, never firing
244
+ - a loop stopped
245
+ - a workflow that cannot start, recorded as a skip
246
+ - the schedule's first sight, its due time, two ticks starting it once, a changed cron
247
+ - migration 0015 turning an old job chain into a workflow that runs |
248
+ | `actionCatalog.test.mjs` | Ready vs placeholder detection; registered names are kebab-case; `spawnProgram` not offered; `decide` and `job-run` offered |
178
249
  | `envExposed.test.mjs` | Nothing exposed by default; exact, case-sensitive matching; globs fail closed (against real `interpolateAll`) |
179
250
  | `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
251
  | `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 jobChains = require('./lib/jobChains');
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 "After this job" chains and by the job-run step. Unset, those
43
- * steps fail and say so (job-run falls back to its jobsUrl over HTTP).
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) jobChains.setJobStarter(config.startJob);
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
- jobChains: jobChains,
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
  };
@@ -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
+ };
@@ -1,6 +1,6 @@
1
1
  // job-run — START A JOB AND PARK UNTIL IT REPORTS BACK.
2
2
  //
3
- // The step behind an arrow on the "connect jobs" canvas. Everything it does is
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: 'timeoutMs', type: 'number', default: 30000, order: 5, group: 'Advanced',
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,19 @@ 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 startJob = require('../jobChains').jobStarter();
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
+ var call = jobs.nextJobCall(ctx.system && ctx.system.trigger, input.jobId, input.inputs, input.carry);
81
+ var startJob = jobs.jobStarter();
73
82
  if (startJob) {
83
+ call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
74
84
  var started = await startJob(input.jobId, {
75
- inputs: input.inputs || null,
85
+ inputs: Object.keys(call.inputs).length ? call.inputs : null,
76
86
  callbackUrl: input.callbackUrl || null,
77
- chain: { resumeKey: (ctx.system && ctx.system.resumeKey) || null }
87
+ chain: call.chain
78
88
  });
79
89
  return {
80
90
  jobId: input.jobId,
@@ -125,8 +135,9 @@ module.exports = {
125
135
  method: 'POST',
126
136
  headers: { 'Content-Type': 'application/json' },
127
137
  body: JSON.stringify({
128
- inputs: input.inputs || undefined,
129
- callbackUrl: input.callbackUrl
138
+ inputs: Object.keys(call.inputs).length ? call.inputs : undefined,
139
+ callbackUrl: input.callbackUrl,
140
+ chain: call.chain
130
141
  }),
131
142
  signal: controller.signal
132
143
  });
@@ -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, job chains in jobChains.js), so
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
+ };