@xeplr/workflow-engine 3.0.0 → 3.0.2
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
|
@@ -62,7 +62,7 @@ engine/
|
|
|
62
62
|
├─ models/ Company, Workspace, Workflow, WorkflowStep, WorkflowRun,
|
|
63
63
|
│ WorkflowStepRun, WorkflowResumeKey, WorkflowRunEdge,
|
|
64
64
|
│ WorkflowTriggerState
|
|
65
|
-
├─ migrations/ 0003–
|
|
65
|
+
├─ migrations/ 0003–0016, workflow's own tables
|
|
66
66
|
├─ migrations-tenants/ 0001–0002: standalone workflow's companies / workspaces
|
|
67
67
|
└─ test/ run.mjs + *.test.mjs
|
|
68
68
|
```
|
|
@@ -207,6 +207,7 @@ Branch on how a job ended with a `decide` step: it does nothing itself, and the
|
|
|
207
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
208
|
- **The next job is offered both earlier ranges**, as `@xeplr/jobs`' `previous.given` / `previous.used`.
|
|
209
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.
|
|
210
211
|
- **Loops stop.** A job already on the path, or a path 25 jobs long, fails the step with a message.
|
|
211
212
|
|
|
212
213
|
The next job takes only `window` and the inputs it lists in `acceptsInputs`; `@xeplr/jobs` refuses the rest, naming them.
|
|
@@ -220,11 +221,13 @@ await jobs.init({ /* … */ onFinish: (report) => engine.jobFinished(report) });
|
|
|
220
221
|
|
|
221
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).
|
|
222
223
|
|
|
223
|
-
**Job chains are gone.** Migration 0015
|
|
224
|
-
- it gets a `job` trigger (`when: finished`);
|
|
225
|
-
- its `decide` and `job-run` steps keep the same behaviour
|
|
224
|
+
**Job chains are gone.** Migration 0015 converts each "After this job" chain (kind `job-chain`) into an ordinary workflow:
|
|
225
|
+
- it gets a `job` trigger (`when: finished`) and the key `after-job-<jobId>`, the one the jobs screen's shortcut uses;
|
|
226
|
+
- its `decide` and `job-run` steps keep the same behaviour, and each arrow keeps its rule's `when`;
|
|
226
227
|
- conditions and bindings now read `trigger.*`.
|
|
227
228
|
|
|
229
|
+
Migration 0016 repairs a database that ran 0015's first release, which left the key empty and the arrows without `when`. A single comparison gets its `when` back. A nested one still runs as before, and the jobs screen says it was changed elsewhere.
|
|
230
|
+
|
|
228
231
|
## Tests
|
|
229
232
|
|
|
230
233
|
```bash
|
|
@@ -244,7 +247,8 @@ No database or network needed, except `triggers.test.mjs`, which runs on a real
|
|
|
244
247
|
- a loop stopped
|
|
245
248
|
- a workflow that cannot start, recorded as a skip
|
|
246
249
|
- 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
|
|
250
|
+
- migration 0015 turning an old job chain into a workflow that runs, with its key and `when` arrows
|
|
251
|
+
- migration 0016 repairing one converted early, and changing nothing on a second run |
|
|
248
252
|
| `actionCatalog.test.mjs` | Ready vs placeholder detection; registered names are kebab-case; `spawnProgram` not offered; `decide` and `job-run` offered |
|
|
249
253
|
| `envExposed.test.mjs` | Nothing exposed by default; exact, case-sensitive matching; globs fail closed (against real `interpolateAll`) |
|
|
250
254
|
| `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) |
|
package/lib/actions/jobRun.js
CHANGED
|
@@ -77,7 +77,14 @@ module.exports = {
|
|
|
77
77
|
// this job unless the step sets one, carried values and the path travel
|
|
78
78
|
// with it, and a job already on the path is refused as a loop
|
|
79
79
|
// (jobs.nextJobCall). Any other run passes the step's inputs as they are.
|
|
80
|
-
|
|
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);
|
|
81
88
|
var startJob = jobs.jobStarter();
|
|
82
89
|
if (startJob) {
|
|
83
90
|
call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
|
package/lib/workflowRunner.js
CHANGED
|
@@ -112,6 +112,30 @@ async function finishRun(run, status, error) {
|
|
|
112
112
|
* the two can differ), params/item are the run's own, resumeKey is injected
|
|
113
113
|
* separately, only for the specific step that needs it.
|
|
114
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* Resolve `{path}` bindings KEEPING TYPES: a string that is exactly one
|
|
117
|
+
* binding takes the bound value as it is (an object stays an object, a number
|
|
118
|
+
* a number); anything else is text, as interpolate makes it.
|
|
119
|
+
*/
|
|
120
|
+
function resolveTyped(value, context) {
|
|
121
|
+
if (value == null) return value;
|
|
122
|
+
if (typeof value === 'string') {
|
|
123
|
+
var whole = /^\{([^{}]+)\}$/.exec(value.trim());
|
|
124
|
+
if (whole) {
|
|
125
|
+
var v = getPath(context, whole[1].trim());
|
|
126
|
+
return v === undefined ? null : v;
|
|
127
|
+
}
|
|
128
|
+
return interpolateAll(value, context);
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(value)) return value.map(function(v) { return resolveTyped(v, context); });
|
|
131
|
+
if (typeof value === 'object') {
|
|
132
|
+
var out = {};
|
|
133
|
+
Object.keys(value).forEach(function(k) { out[k] = resolveTyped(value[k], context); });
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
// What started a run, as {trigger.*} reads it — in bindings AND in the
|
|
116
140
|
// conditions on arrows. A run started by hand has just its kind.
|
|
117
141
|
function triggerOf(run) {
|
|
@@ -359,8 +383,14 @@ async function executeFrom(workflow, steps, run, startStepKey) {
|
|
|
359
383
|
// started in-process reports back through jobs.jobFinished).
|
|
360
384
|
// trigger: what started the run, for an action that carries it on
|
|
361
385
|
// (job-run passes a job's period to the next job).
|
|
386
|
+
// resolveTyped: a binding that is the WHOLE value keeps its type
|
|
387
|
+
// ("{trigger.output.totalRows}" stays 7, "{trigger.window}" an object),
|
|
388
|
+
// for an action that passes values on rather than printing them
|
|
389
|
+
// (job-run's inputs and carry). Every other action takes `input`, as
|
|
390
|
+
// resolved above, unchanged.
|
|
362
391
|
system: { runId: run.id, stepId: step.id, stepKey: step.stepKey, resumeKey: resumeKey,
|
|
363
|
-
trigger: triggerOf(run) }
|
|
392
|
+
trigger: triggerOf(run), rawValues: step.values || {},
|
|
393
|
+
resolveTyped: function(value) { return resolveTyped(value, context); } }
|
|
364
394
|
});
|
|
365
395
|
|
|
366
396
|
if (result.status !== 'success') {
|
|
@@ -664,4 +694,5 @@ async function resumeByKey(key, output, opts) {
|
|
|
664
694
|
// resolveParams and collectParams are exported for their own sake: they are
|
|
665
695
|
// the pieces of the engine that are pure — rows in, a schema or a resolved
|
|
666
696
|
// set out, or a throw — and testing them needs no database.
|
|
667
|
-
module.exports = {
|
|
697
|
+
module.exports = {
|
|
698
|
+
resolveTyped: resolveTyped, startRun: startRun, resumeByKey: resumeByKey, resolveParams: resolveParams, collectParams: collectParams };
|
|
@@ -53,8 +53,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS "workflow_trigger_states_workflow_unique" ON "
|
|
|
53
53
|
-- kind 'job-chain' → 'workflow', with trigger
|
|
54
54
|
-- { kind: 'job', jobId, when: 'finished' }
|
|
55
55
|
-- (the decide step still routes on status)
|
|
56
|
-
-- key 'job-chain-<jobId>' →
|
|
57
|
-
--
|
|
56
|
+
-- key 'job-chain-<jobId>' → 'after-job-<jobId>', the key the jobs
|
|
57
|
+
-- screen's "After this job" shortcut keeps
|
|
58
|
+
-- 'job-chain-decide' step → 'decide' (a plain branching step), each
|
|
59
|
+
-- arrow given its rule's `when` as written
|
|
58
60
|
-- 'job-chain-next' steps → 'job-run', which carries the period on
|
|
59
61
|
-- conditions on params.x → trigger.x (status, output, window, carry…)
|
|
60
62
|
-- bindings {output.x} etc. → {trigger.output.x}
|
|
@@ -65,7 +67,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS "workflow_trigger_states_workflow_unique" ON "
|
|
|
65
67
|
UPDATE "workflow_steps" s
|
|
66
68
|
SET "actionName" = 'decide',
|
|
67
69
|
"values" = '{}'::jsonb,
|
|
68
|
-
|
|
70
|
+
-- Arrow N is rule N, so it takes that rule's `when` (fields now read
|
|
71
|
+
-- the trigger); the closing "nothing matched" arrow is "otherwise".
|
|
72
|
+
"transitions" = (
|
|
73
|
+
SELECT COALESCE(jsonb_agg(
|
|
74
|
+
CASE WHEN x.ord <= COALESCE(jsonb_array_length(s."values"->'rules'), 0)
|
|
75
|
+
THEN x.t || jsonb_build_object('when', regexp_replace(
|
|
76
|
+
COALESCE(s."values"->'rules'->((x.ord - 1)::int)->'when', 'null'::jsonb)::text,
|
|
77
|
+
'"field"\s*:\s*"(status|error|output|carry|window|jobId|given|used)', '"field": "trigger.\1', 'g')::jsonb)
|
|
78
|
+
ELSE x.t || '{"when": null}'::jsonb END
|
|
79
|
+
ORDER BY x.ord), '[]'::jsonb)
|
|
80
|
+
FROM jsonb_array_elements(replace(s."transitions"::text, '"params.', '"trigger.')::jsonb) WITH ORDINALITY AS x(t, ord))
|
|
69
81
|
FROM "workflows" w
|
|
70
82
|
WHERE w."id" = s."workflowId" AND w."kind" = 'job-chain' AND s."actionName" = 'job-chain-decide';
|
|
71
83
|
|
|
@@ -81,5 +93,5 @@ UPDATE "workflow_steps" s
|
|
|
81
93
|
UPDATE "workflows"
|
|
82
94
|
SET "trigger" = jsonb_build_object('kind', 'job', 'jobId', substring("key" from 11), 'when', 'finished'),
|
|
83
95
|
"kind" = 'workflow',
|
|
84
|
-
"key" =
|
|
96
|
+
"key" = 'after-job-' || substring("key" from 11)
|
|
85
97
|
WHERE "kind" = 'job-chain' AND "key" LIKE 'job-chain-%';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- 0016_after_job_shortcut.sql
|
|
2
|
+
--
|
|
3
|
+
-- CONVERTED CHAINS, READABLE BY THE JOBS SCREEN AGAIN.
|
|
4
|
+
--
|
|
5
|
+
-- The first release of 0015 converted each job chain into an ordinary
|
|
6
|
+
-- workflow, but with no key and with arrows holding only their compiled
|
|
7
|
+
-- conditions. The jobs screen's "After this job" finds a job's rules by the
|
|
8
|
+
-- key `after-job-<jobId>` and reads each arrow's `when`, so a converted chain
|
|
9
|
+
-- showed as "changed in the workflow screens" instead of as its rules. 0015
|
|
10
|
+
-- now does both; this repairs a database that ran it before it did. On any
|
|
11
|
+
-- other database it changes nothing.
|
|
12
|
+
--
|
|
13
|
+
-- (No jsonb key-exists operator anywhere: the migrator's driver takes that
|
|
14
|
+
-- character as a parameter placeholder. jsonb_typeof(x->'k') is NULL exactly
|
|
15
|
+
-- when k is absent.)
|
|
16
|
+
--
|
|
17
|
+
-- 1. The key, for a converted chain that has none (and only if the job has
|
|
18
|
+
-- no shortcut already).
|
|
19
|
+
UPDATE "workflows" w
|
|
20
|
+
SET "key" = 'after-job-' || (w."trigger"->>'jobId')
|
|
21
|
+
WHERE w."key" IS NULL
|
|
22
|
+
AND w."trigger"->>'kind' = 'job'
|
|
23
|
+
AND w."description" LIKE 'Made from the "After this job" rules of job %'
|
|
24
|
+
AND NOT EXISTS (
|
|
25
|
+
SELECT 1 FROM "workflows" o
|
|
26
|
+
WHERE o."key" = 'after-job-' || (w."trigger"->>'jobId')
|
|
27
|
+
AND COALESCE(o."mtId1", '') = COALESCE(w."mtId1", '')
|
|
28
|
+
AND COALESCE(o."mtId2", '') = COALESCE(w."mtId2", ''));
|
|
29
|
+
|
|
30
|
+
-- 2. `when` on each of their decide step's arrows, rebuilt from the compiled
|
|
31
|
+
-- condition: none is "otherwise"; a single comparison is { field, op,
|
|
32
|
+
-- value } in the jobs screen's own words. A nested all/any is left as it
|
|
33
|
+
-- is — the jobs screen then says it was changed elsewhere, and it still
|
|
34
|
+
-- runs exactly as before.
|
|
35
|
+
UPDATE "workflow_steps" s
|
|
36
|
+
SET "transitions" = (
|
|
37
|
+
SELECT COALESCE(jsonb_agg(
|
|
38
|
+
CASE
|
|
39
|
+
WHEN jsonb_typeof(x.t->'when') IS NOT NULL THEN x.t
|
|
40
|
+
WHEN jsonb_typeof(x.t->'condition') IS NULL OR jsonb_typeof(x.t->'condition') = 'null' THEN x.t || '{"when": null}'::jsonb
|
|
41
|
+
WHEN jsonb_typeof(x.t->'condition'->'left') IS NOT NULL AND (CASE x.t->'condition'->>'op'
|
|
42
|
+
WHEN 'eq' THEN '=' WHEN 'neq' THEN '!=' WHEN 'gt' THEN '>' WHEN 'gte' THEN '>='
|
|
43
|
+
WHEN 'lt' THEN '<' WHEN 'lte' THEN '<=' WHEN 'contains' THEN 'contains'
|
|
44
|
+
WHEN 'notContains' THEN 'notContains' WHEN 'startsWith' THEN 'startsWith'
|
|
45
|
+
WHEN 'endsWith' THEN 'endsWith' WHEN 'in' THEN 'in' WHEN 'notIn' THEN 'notIn'
|
|
46
|
+
WHEN 'between' THEN 'between' WHEN 'isNull' THEN 'isEmpty' WHEN 'isNotNull' THEN 'isNotEmpty'
|
|
47
|
+
END) IS NOT NULL
|
|
48
|
+
THEN x.t || jsonb_build_object('when', jsonb_strip_nulls(jsonb_build_object(
|
|
49
|
+
'field', x.t->'condition'->'left'->>'field',
|
|
50
|
+
'op', CASE x.t->'condition'->>'op'
|
|
51
|
+
WHEN 'eq' THEN '=' WHEN 'neq' THEN '!=' WHEN 'gt' THEN '>' WHEN 'gte' THEN '>='
|
|
52
|
+
WHEN 'lt' THEN '<' WHEN 'lte' THEN '<=' WHEN 'contains' THEN 'contains'
|
|
53
|
+
WHEN 'notContains' THEN 'notContains' WHEN 'startsWith' THEN 'startsWith'
|
|
54
|
+
WHEN 'endsWith' THEN 'endsWith' WHEN 'in' THEN 'in' WHEN 'notIn' THEN 'notIn'
|
|
55
|
+
WHEN 'between' THEN 'between' WHEN 'isNull' THEN 'isEmpty' WHEN 'isNotNull' THEN 'isNotEmpty'
|
|
56
|
+
END,
|
|
57
|
+
'value', x.t->'condition'->'right'->'value')))
|
|
58
|
+
ELSE x.t
|
|
59
|
+
END ORDER BY x.ord), '[]'::jsonb)
|
|
60
|
+
FROM jsonb_array_elements(s."transitions") WITH ORDINALITY AS x(t, ord))
|
|
61
|
+
FROM "workflows" w
|
|
62
|
+
WHERE w."id" = s."workflowId"
|
|
63
|
+
AND w."key" LIKE 'after-job-%'
|
|
64
|
+
AND s."stepKey" = 'decide' AND s."actionName" = 'decide'
|
|
65
|
+
AND s."transitions" IS NOT NULL;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/workflow-engine",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "The workflow engine — workflow's own database and tables, the action catalog, starting and resuming runs, and the flows facade. No HTTP: @xeplr/workflow-api puts the routes on top.",
|
|
6
6
|
"main": "index.js",
|