@xeplr/workflow-engine 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/db/xcfgSetup.js +8 -0
- package/index.js +290 -0
- package/lib/actionCatalog.js +197 -0
- package/lib/actions/jobChainSteps.js +79 -0
- package/lib/actions/jobRun.js +171 -0
- package/lib/actions/screenShow.js +71 -0
- package/lib/auth.js +19 -0
- package/lib/db.js +82 -0
- package/lib/envExposed.js +87 -0
- package/lib/flows.js +916 -0
- package/lib/jobChains.js +426 -0
- package/lib/workflowRunner.js +652 -0
- package/migrations/0003_workflows.sql +35 -0
- package/migrations/0004_workflow_steps.sql +79 -0
- package/migrations/0005_workflow_runs.sql +49 -0
- package/migrations/0006_workflow_step_runs.sql +42 -0
- package/migrations/0007_workflow_resume_keys.sql +42 -0
- package/migrations/0008_workflow_run_edges.sql +43 -0
- package/migrations/0009_workflow_steps_layout.sql +11 -0
- package/migrations/0010_workflow_steps_sample_output.sql +17 -0
- package/migrations/0011_workflow_steps_params.sql +27 -0
- package/migrations/0012_workflows_kind.sql +27 -0
- package/migrations/0013_workflows_key.sql +48 -0
- package/migrations/0014_workflows_trigger.sql +30 -0
- package/migrations-tenants/0001_companies.sql +23 -0
- package/migrations-tenants/0002_workspaces.sql +22 -0
- package/models/Company.js +82 -0
- package/models/Workflow.js +67 -0
- package/models/WorkflowResumeKey.js +32 -0
- package/models/WorkflowRun.js +64 -0
- package/models/WorkflowRunEdge.js +49 -0
- package/models/WorkflowStep.js +66 -0
- package/models/WorkflowStepRun.js +49 -0
- package/models/Workspace.js +75 -0
- package/models/index.js +25 -0
- package/package.json +47 -4
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// WHAT THIS PRODUCT CAN BIND TOGETHER.
|
|
2
|
+
//
|
|
3
|
+
// @xeplr/actions ships the actions; this file decides which of them THIS app
|
|
4
|
+
// offers, and registers them once at startup (see bin/www). Whatever a workflow
|
|
5
|
+
// step turns out to be, it will name one of these — so the registry is
|
|
6
|
+
// populated before the first request rather than lazily.
|
|
7
|
+
//
|
|
8
|
+
// A deliberate WHITELIST rather than "register everything the package exports",
|
|
9
|
+
// for two reasons. Some built-ins are more dangerous than others —
|
|
10
|
+
// spawnProgram runs arbitrary executables — and offering one should be a
|
|
11
|
+
// decision somebody made, visible in a diff, not a side effect of upgrading a
|
|
12
|
+
// dependency. And several entries in that package are PLACEHOLDERS: modules
|
|
13
|
+
// with a comment saying the implementation is pending and nothing else in them.
|
|
14
|
+
// Registering one would put a name in the catalog, draw it in the step picker,
|
|
15
|
+
// and fail at the moment somebody relied on it.
|
|
16
|
+
//
|
|
17
|
+
// So the list below is names this app WANTS, and readiness is checked against
|
|
18
|
+
// the package rather than assumed. A name that is not ready yet stays in the
|
|
19
|
+
// list, out of the catalog, and lands the day the package implements it.
|
|
20
|
+
var actions = require('@xeplr/actions');
|
|
21
|
+
var xcfgSetup = require('../db/xcfgSetup');
|
|
22
|
+
|
|
23
|
+
// The keys on @xeplr/actions' `builtins`, not the action names — those are
|
|
24
|
+
// kebab-case and assigned by the modules themselves (`dbFetch` registers as
|
|
25
|
+
// `db-fetch`). Naming the export is what makes this list checkable.
|
|
26
|
+
var WANTED = [
|
|
27
|
+
'dbFetch',
|
|
28
|
+
'dbPush',
|
|
29
|
+
'dbQuery',
|
|
30
|
+
// Calling a procedure is its own step now — it used to be reachable only by
|
|
31
|
+
// running a movement, which meant a procedure that returns nothing could not
|
|
32
|
+
// be run at all.
|
|
33
|
+
'dbProcedure',
|
|
34
|
+
'dbListTables',
|
|
35
|
+
'dbListViews',
|
|
36
|
+
'dbListProcedures',
|
|
37
|
+
'dbListColumns',
|
|
38
|
+
'fileMove',
|
|
39
|
+
|
|
40
|
+
// Email. The five inbound ones are what a workflow monitors a mailbox with;
|
|
41
|
+
// emailSend is also what a `wait` step uses to deliver its own resume link
|
|
42
|
+
// (see workflowRunner's mintResumeKey — the key is available to the step's
|
|
43
|
+
// own action as {resumeKey}).
|
|
44
|
+
//
|
|
45
|
+
// emailDelete is offered but worth knowing about: it is a permanent expunge,
|
|
46
|
+
// not a move to Trash. emailMove to a trash folder is the reversible one.
|
|
47
|
+
'emailRead',
|
|
48
|
+
'emailMove',
|
|
49
|
+
'emailDelete',
|
|
50
|
+
'emailDownloadEmail',
|
|
51
|
+
'emailDownloadAttachments',
|
|
52
|
+
'sendEmail'
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// THIS APP'S OWN ACTIONS — not from @xeplr/actions, and not placeholders.
|
|
56
|
+
//
|
|
57
|
+
// Glue between two products, which is exactly why it is not in the shared
|
|
58
|
+
// action library: @xeplr/actions must not learn what a job is. Registered the
|
|
59
|
+
// same way and appearing in the same catalog, so the step editor needs no
|
|
60
|
+
// notion that some actions come from here and some from there.
|
|
61
|
+
var LOCAL = [
|
|
62
|
+
require('./actions/jobRun')
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// REGISTERED BUT NOT OFFERED.
|
|
66
|
+
//
|
|
67
|
+
// An action a step can legitimately name, that no author should ever pick off
|
|
68
|
+
// a palette. The registry must have it — the engine resolves a step's
|
|
69
|
+
// actionName through the registry, so a step naming an unregistered action
|
|
70
|
+
// cannot run at all — but catalog() leaves it out, because the only thing that
|
|
71
|
+
// knows how to build one of these steps is the facade that owns them.
|
|
72
|
+
//
|
|
73
|
+
// `screen-show` is the case: a screens flow's steps are generated by
|
|
74
|
+
// lib/flows.js from a design held in another app, and one dropped by hand onto
|
|
75
|
+
// the ordinary workflow canvas would park its run forever — nothing outside
|
|
76
|
+
// that facade knows how to show the screen or how to resume it. Hiding it is
|
|
77
|
+
// not secrecy; it is not offering half of something.
|
|
78
|
+
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
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
var HIDDEN_NAMES = HIDDEN.map(function(def) { return def.name; });
|
|
87
|
+
|
|
88
|
+
// Needs the control-plane connection to record what moved where, so it is
|
|
89
|
+
// registered separately once xcfgSetup has resolved.
|
|
90
|
+
var WANTED_WITH_META = ['fileUpload'];
|
|
91
|
+
|
|
92
|
+
// DELIBERATELY ABSENT: spawnProgram. It runs arbitrary executables, which is a
|
|
93
|
+
// different kind of permission from "call an HTTP endpoint" — named here so
|
|
94
|
+
// that leaving it out reads as a decision rather than an oversight.
|
|
95
|
+
|
|
96
|
+
/** A module the registry will actually accept, as opposed to a placeholder. */
|
|
97
|
+
function isReady(def) {
|
|
98
|
+
return Boolean(def && typeof def.execute === 'function' && def.name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function offer(key, def, registered, skipped) {
|
|
102
|
+
if (!isReady(def)) { skipped.push(key); return; }
|
|
103
|
+
actions.register(def);
|
|
104
|
+
registered.push(def.name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Registers this app's actions and returns their names.
|
|
109
|
+
*
|
|
110
|
+
* Idempotent — @xeplr/actions' register() overwrites by name, so calling this
|
|
111
|
+
* twice leaves the registry in the same state rather than doubling it.
|
|
112
|
+
*/
|
|
113
|
+
function registerAll() {
|
|
114
|
+
var registered = [];
|
|
115
|
+
var skipped = [];
|
|
116
|
+
|
|
117
|
+
WANTED.forEach(function(key) {
|
|
118
|
+
offer(key, actions.builtins[key], registered, skipped);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
LOCAL.forEach(function(def) {
|
|
122
|
+
offer(def.name, def, registered, skipped);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Same registration, no catalog entry — see HIDDEN.
|
|
126
|
+
HIDDEN.forEach(function(def) {
|
|
127
|
+
offer(def.name, def, registered, skipped);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
WANTED_WITH_META.forEach(function(key) {
|
|
131
|
+
var entry = actions.builtins[key];
|
|
132
|
+
// Factory-shaped in some versions, a plain module in others. Both are
|
|
133
|
+
// handled rather than asserted, because which one it is is the package's
|
|
134
|
+
// business and not worth breaking a boot over.
|
|
135
|
+
//
|
|
136
|
+
// Guarded because reading xcfgSetup.metaStore THROWS when the
|
|
137
|
+
// control-plane connection was never established — which is a real state
|
|
138
|
+
// for an embedded host (see registerWorkflow, where xeplr_configs is
|
|
139
|
+
// best-effort). Losing one action to that is correct; losing the whole
|
|
140
|
+
// catalog, and with it every other action, is not.
|
|
141
|
+
try {
|
|
142
|
+
var def = typeof entry === 'function' ? entry({ metaKnex: xcfgSetup.metaStore }) : entry;
|
|
143
|
+
offer(key, def, registered, skipped);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn('[actions] ' + key + ' could not be registered: ' + err.message);
|
|
146
|
+
skipped.push(key);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Said out loud, once, at startup. A name silently missing from the picker is
|
|
151
|
+
// a bug somebody spends an afternoon on; a line in the boot log is not.
|
|
152
|
+
if (skipped.length) {
|
|
153
|
+
console.warn('[actions] not yet implemented in @xeplr/actions, so not offered: ' + skipped.join(', '));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return registered;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The catalog, as the UI needs it: name, description and the INPUT SCHEMA that
|
|
161
|
+
* describes what each one takes.
|
|
162
|
+
*
|
|
163
|
+
* The schema is the point. A step editor told separately what an action accepts
|
|
164
|
+
* is a second description of it, and the day the two disagree the form collects
|
|
165
|
+
* a field the action ignores. Sending the registry's own schema means the form
|
|
166
|
+
* is generated from the thing it is a form for.
|
|
167
|
+
*
|
|
168
|
+
* The EXECUTOR is not sent, and could not usefully be — it is a function, it
|
|
169
|
+
* would not survive JSON, and an endpoint that hands out executors is an
|
|
170
|
+
* endpoint that explains how to bypass itself.
|
|
171
|
+
*/
|
|
172
|
+
function catalog() {
|
|
173
|
+
return actions.list().filter(function(a) {
|
|
174
|
+
// HIDDEN is registered, so the engine can run a step that names it, and
|
|
175
|
+
// absent here, so no palette ever draws it. Filtered at the point the list
|
|
176
|
+
// leaves the process rather than by not registering it — the two questions
|
|
177
|
+
// ("can this run" and "should anyone pick it") have different answers.
|
|
178
|
+
return HIDDEN_NAMES.indexOf(a.name) === -1;
|
|
179
|
+
}).map(function(a) {
|
|
180
|
+
return {
|
|
181
|
+
name: a.name,
|
|
182
|
+
description: a.description,
|
|
183
|
+
inputSchema: a.inputSchema,
|
|
184
|
+
outputSchema: a.outputSchema
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
registerAll: registerAll,
|
|
191
|
+
catalog: catalog,
|
|
192
|
+
isReady: isReady,
|
|
193
|
+
WANTED: WANTED,
|
|
194
|
+
WANTED_WITH_META: WANTED_WITH_META,
|
|
195
|
+
LOCAL: LOCAL,
|
|
196
|
+
HIDDEN: HIDDEN
|
|
197
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// THE TWO STEPS OF A JOB CHAIN — see lib/jobChains.js.
|
|
2
|
+
//
|
|
3
|
+
// Registered but HIDDEN from GET /actions, like screen-show: only the facade
|
|
4
|
+
// that generates a chain's workflow knows how to build these steps, and one
|
|
5
|
+
// dropped onto the canvas by hand would have no rules and no finished job to
|
|
6
|
+
// read.
|
|
7
|
+
|
|
8
|
+
var db = require('../db');
|
|
9
|
+
var jobChains = require('../jobChains');
|
|
10
|
+
|
|
11
|
+
var NOT_CONNECTED =
|
|
12
|
+
'This step starts a job, but this install has not connected jobs to workflow. ' +
|
|
13
|
+
'The host must pass startJob to startEngine()/registerWorkflow() — with @xeplr/jobs ' +
|
|
14
|
+
'in the same process that is (jobId, body) => jobs.trigger(jobId, body).';
|
|
15
|
+
|
|
16
|
+
// `decide` does nothing. Its transitions are the job's rules, and the engine
|
|
17
|
+
// evaluates them against the run's params once it returns.
|
|
18
|
+
var decide = {
|
|
19
|
+
name: jobChains.DECIDE_ACTION,
|
|
20
|
+
description: 'Choose which job runs next from how the last one ended. Its transitions are the rules.',
|
|
21
|
+
requires: [],
|
|
22
|
+
inputSchema: [{ name: 'rules', type: 'array', order: 1, description: 'The rules as written on the job.' }],
|
|
23
|
+
outputSchema: null,
|
|
24
|
+
execute: async function() { return {}; }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// `run_n` starts one job and parks until it reports back. The rule's inputs and
|
|
28
|
+
// carry are resolved HERE against the run's own params (typed — see
|
|
29
|
+
// resolveTyped), not by the engine's text interpolation, so a window or a
|
|
30
|
+
// number passes as itself.
|
|
31
|
+
var next = {
|
|
32
|
+
name: jobChains.NEXT_ACTION,
|
|
33
|
+
description: 'Start the next job of a chain with the period and values carried so far, and wait for it.',
|
|
34
|
+
requires: [],
|
|
35
|
+
inputSchema: [
|
|
36
|
+
{ name: 'jobId', type: 'string', required: true, order: 1 },
|
|
37
|
+
{ name: 'inputs', type: 'object', order: 2 },
|
|
38
|
+
{ name: 'carry', type: 'object', order: 3 },
|
|
39
|
+
{ name: 'callbackUrl', type: 'string', order: 4 }
|
|
40
|
+
],
|
|
41
|
+
outputSchema: null,
|
|
42
|
+
|
|
43
|
+
execute: async function(ctx) {
|
|
44
|
+
var input = ctx.input || {};
|
|
45
|
+
var startJob = jobChains.jobStarter();
|
|
46
|
+
if (!startJob) throw new Error(NOT_CONNECTED);
|
|
47
|
+
|
|
48
|
+
// The STEP's values as saved, not the interpolated input: `inputs` and
|
|
49
|
+
// `carry` hold `{output.x}` bindings that only resolveTyped keeps typed.
|
|
50
|
+
var step = await db.model('WorkflowStep').query().findById(ctx.system.stepId);
|
|
51
|
+
var run = await db.model('WorkflowRun').query().findById(ctx.system.runId);
|
|
52
|
+
var call = jobChains.nextJobCall(run && run.params, {
|
|
53
|
+
jobId: input.jobId,
|
|
54
|
+
inputs: step && step.values ? step.values.inputs : input.inputs,
|
|
55
|
+
carry: step && step.values ? step.values.carry : input.carry
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The resume key travels in the chain, so a host can resume this step
|
|
59
|
+
// in-process when the job ends (jobChains.jobFinished); the callback URL
|
|
60
|
+
// is the same key over HTTP, for a jobs service in another process. Null
|
|
61
|
+
// when WORKFLOW_PUBLIC_URL is unset, which is fine in-process.
|
|
62
|
+
call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
|
|
63
|
+
var started = await startJob(input.jobId, {
|
|
64
|
+
inputs: call.inputs,
|
|
65
|
+
chain: call.chain,
|
|
66
|
+
callbackUrl: input.callbackUrl || null
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
jobId: input.jobId,
|
|
71
|
+
occurrenceId: (started && started.occurrenceId) || null,
|
|
72
|
+
status: 'running',
|
|
73
|
+
window: call.inputs.window || null,
|
|
74
|
+
carry: call.chain.carry
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
module.exports = { decide: decide, next: next };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// job-run — START A JOB AND PARK UNTIL IT REPORTS BACK.
|
|
2
|
+
//
|
|
3
|
+
// The step behind an arrow on the "connect jobs" canvas. Everything it does is
|
|
4
|
+
// two HTTP calls' worth of glue between two products that do not import each
|
|
5
|
+
// other:
|
|
6
|
+
//
|
|
7
|
+
// 1. POST <jobsUrl>/jobs/<jobId>/trigger { inputs, callbackUrl }
|
|
8
|
+
// 2. return — the STEP does not finish here. It is a `wait` step, so the run
|
|
9
|
+
// parks, and @xeplr/jobs POSTs the outcome to callbackUrl when the
|
|
10
|
+
// occurrence reaches a terminal state, which resumes it.
|
|
11
|
+
//
|
|
12
|
+
// ── why it does not wait for the job ─────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// A movement runs for twenty minutes. Holding an await open across that would
|
|
15
|
+
// tie a run's progress to one process staying alive, and a deploy in the
|
|
16
|
+
// middle would lose it with nothing recording why. The resume key is a row in
|
|
17
|
+
// a table: it survives a restart, and it is the same mechanism an emailed
|
|
18
|
+
// approval link has always used.
|
|
19
|
+
//
|
|
20
|
+
// ── THIS IS TEMPORARY, AND SHOULD BE DELETED ─────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// Nothing here is about jobs except the URL shape and the field named
|
|
23
|
+
// `callbackUrl`. The general form is @xeplr/actions' `http-request` (today a
|
|
24
|
+
// placeholder) called from a wait step with `{resumeUrl}` bound into its body,
|
|
25
|
+
// which would serve jobs, saved API calls and anything else that can honour
|
|
26
|
+
// the callback contract. This exists so the jobs canvas can be finished
|
|
27
|
+
// first; when http-request lands, a job step becomes a configuration of it and
|
|
28
|
+
// this file goes away.
|
|
29
|
+
//
|
|
30
|
+
// It lives in xeplr-workflow rather than in @xeplr/actions deliberately: it is
|
|
31
|
+
// glue between two products, and putting it in the shared action library would
|
|
32
|
+
// teach that library what a job is.
|
|
33
|
+
|
|
34
|
+
var CALLBACK_UNREACHABLE =
|
|
35
|
+
'This step starts a job and waits for it to report back, but WORKFLOW_PUBLIC_URL ' +
|
|
36
|
+
'is not set, so there is no address to give the job to call. Set it to the base ' +
|
|
37
|
+
'URL this service is reachable on (including any mount path — e.g. ' +
|
|
38
|
+
'https://bi.example.com/workflow) and run this again.';
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
name: 'job-run',
|
|
42
|
+
description: 'Run a saved job and wait for it to finish. The job reports its outcome back, ' +
|
|
43
|
+
'so the next step runs only once it has actually completed.',
|
|
44
|
+
requires: [],
|
|
45
|
+
|
|
46
|
+
inputSchema: [
|
|
47
|
+
{ name: 'jobsUrl', type: 'string', order: 1,
|
|
48
|
+
description: 'Base URL of the jobs API — e.g. https://bi.example.com/api. Not needed when ' +
|
|
49
|
+
'the host connected jobs in-process (startEngine\'s startJob).' },
|
|
50
|
+
{ name: 'jobId', type: 'string', required: true, order: 2,
|
|
51
|
+
description: 'The job to run.' },
|
|
52
|
+
{ name: 'callbackUrl', type: 'string', order: 3,
|
|
53
|
+
description: 'Where the job reports its outcome. Bind this to {resumeUrl}; it is the ' +
|
|
54
|
+
'address of this step\'s own resume key. Needed only when the job is started ' +
|
|
55
|
+
'over HTTP — an in-process start reports back through the same key directly.' },
|
|
56
|
+
{ name: 'inputs', type: 'object', order: 4, group: 'Advanced',
|
|
57
|
+
description: 'Values to override the job\'s saved inputs, for this run only. The job row ' +
|
|
58
|
+
'is not changed. Commonly a window: { window: { from, to } }.' },
|
|
59
|
+
{ name: 'timeoutMs', type: 'number', default: 30000, order: 5, group: 'Advanced',
|
|
60
|
+
description: 'How long to wait for the TRIGGER call to be accepted. Nothing to do with ' +
|
|
61
|
+
'how long the job itself may run — that is the job\'s own timeout.' }
|
|
62
|
+
],
|
|
63
|
+
|
|
64
|
+
execute: async function(ctx) {
|
|
65
|
+
var input = ctx.input || {};
|
|
66
|
+
|
|
67
|
+
// THE HOST'S OWN WAY OF STARTING A JOB, when it gave one (startEngine's
|
|
68
|
+
// startJob — @xeplr/jobs' trigger() in the same process). No HTTP, no
|
|
69
|
+
// jobsUrl and no credentials: the call runs inside this run's tenant
|
|
70
|
+
// context, which is the job's. Without it this step falls back to the
|
|
71
|
+
// jobs API over HTTP below.
|
|
72
|
+
var startJob = require('../jobChains').jobStarter();
|
|
73
|
+
if (startJob) {
|
|
74
|
+
var started = await startJob(input.jobId, {
|
|
75
|
+
inputs: input.inputs || null,
|
|
76
|
+
callbackUrl: input.callbackUrl || null,
|
|
77
|
+
chain: { resumeKey: (ctx.system && ctx.system.resumeKey) || null }
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
jobId: input.jobId,
|
|
81
|
+
occurrenceId: (started && started.occurrenceId) || null,
|
|
82
|
+
startedAt: new Date().toISOString(),
|
|
83
|
+
status: 'running'
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The binding resolved to nothing, which means WORKFLOW_PUBLIC_URL is
|
|
88
|
+
// unset (see workflowRunner's publicResumeUrl). Caught HERE, before the
|
|
89
|
+
// job is started, because the alternative is a job that runs perfectly for
|
|
90
|
+
// twenty minutes and then has nowhere to report to — leaving the step
|
|
91
|
+
// waiting forever on work that actually succeeded.
|
|
92
|
+
if (!input.callbackUrl) throw new Error(CALLBACK_UNREACHABLE);
|
|
93
|
+
|
|
94
|
+
// THE SAME CHECK FOR THE OTHER BINDING, and it needs its own message.
|
|
95
|
+
//
|
|
96
|
+
// The canvas fills jobsUrl with `{env.JOBS_API_URL}`, and the engine
|
|
97
|
+
// resolves an unknown binding to the EMPTY STRING rather than failing —
|
|
98
|
+
// so an unset (or unexposed) variable arrives here as '' and satisfies
|
|
99
|
+
// `required`, which only rejects null and undefined. Left alone that
|
|
100
|
+
// becomes fetch('/jobs/j1/trigger') and a "Failed to parse URL" nobody can
|
|
101
|
+
// trace back to an environment variable.
|
|
102
|
+
//
|
|
103
|
+
// Both halves are named because either one alone is the cause: the
|
|
104
|
+
// variable can be set and simply not listed in WORKFLOW_ENV_EXPOSED, which
|
|
105
|
+
// is exactly as invisible.
|
|
106
|
+
var jobsUrl = String(input.jobsUrl || '').trim();
|
|
107
|
+
if (!/^https?:\/\//i.test(jobsUrl)) {
|
|
108
|
+
throw new Error('This step needs the address of the jobs API and got ' +
|
|
109
|
+
(jobsUrl ? '"' + jobsUrl + '"' : 'nothing') + '. It is normally bound to ' +
|
|
110
|
+
'{env.JOBS_API_URL}, which resolves to an empty string unless JOBS_API_URL is set ' +
|
|
111
|
+
'AND named in WORKFLOW_ENV_EXPOSED — check both. It must be absolute, e.g. ' +
|
|
112
|
+
'https://bi.example.com/api');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
var base = jobsUrl.replace(/\/+$/, '');
|
|
116
|
+
var url = base + '/jobs/' + encodeURIComponent(input.jobId) + '/trigger';
|
|
117
|
+
|
|
118
|
+
var controller = new AbortController();
|
|
119
|
+
var timer = setTimeout(function() { controller.abort(); }, input.timeoutMs || 30000);
|
|
120
|
+
if (timer.unref) timer.unref();
|
|
121
|
+
|
|
122
|
+
var res;
|
|
123
|
+
try {
|
|
124
|
+
res = await fetch(url, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
inputs: input.inputs || undefined,
|
|
129
|
+
callbackUrl: input.callbackUrl
|
|
130
|
+
}),
|
|
131
|
+
signal: controller.signal
|
|
132
|
+
});
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// BUSY IS A FAILURE, NOT A WAIT.
|
|
138
|
+
//
|
|
139
|
+
// A job holds one lock and runs one at a time. Treating 409 as "try again
|
|
140
|
+
// shortly" would mean a chain that quietly continues past a movement that
|
|
141
|
+
// never ran, and the step after it aggregating a period nothing loaded.
|
|
142
|
+
// Failing here stops the run, which is what the step's own onError already
|
|
143
|
+
// defaults to.
|
|
144
|
+
if (res.status === 409) {
|
|
145
|
+
throw new Error('Job ' + input.jobId + ' is already running, so this step cannot start it. ' +
|
|
146
|
+
'Wait for the current run to finish, or schedule these so they do not overlap.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
var detail = await res.text().catch(function() { return ''; });
|
|
151
|
+
throw new Error('Could not start job ' + input.jobId + ' — the jobs API answered ' +
|
|
152
|
+
res.status + (detail ? ': ' + detail.slice(0, 300) : '') + '.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var body = await res.json().catch(function() { return {}; });
|
|
156
|
+
var row = (body.dataArray && body.dataArray[0]) || {};
|
|
157
|
+
|
|
158
|
+
// WHAT THIS STEP KNOWS SO FAR — the handle, not the result.
|
|
159
|
+
//
|
|
160
|
+
// The occurrence's outcome arrives later, through the callback, and
|
|
161
|
+
// resumeByKey MERGES it over this. So a downstream binding of
|
|
162
|
+
// {steps.<key>.output.occurrenceId} works from the moment the job starts,
|
|
163
|
+
// and {steps.<key>.output.output.totalRows} works once it has finished.
|
|
164
|
+
return {
|
|
165
|
+
jobId: input.jobId,
|
|
166
|
+
occurrenceId: row.occurrenceId || null,
|
|
167
|
+
startedAt: new Date().toISOString(),
|
|
168
|
+
status: 'running'
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// screen-show — PARK THE RUN AND LET A PERSON FILL IN A SCREEN.
|
|
2
|
+
//
|
|
3
|
+
// The step behind every node of a 'screens' flow (see lib/flows.js). It is a
|
|
4
|
+
// `wait` step whose action does nothing at all, and the nothing is the whole
|
|
5
|
+
// design:
|
|
6
|
+
//
|
|
7
|
+
// 1. the engine mints the resume key BEFORE the action runs (see
|
|
8
|
+
// workflowRunner's executeFrom) and parks the run at status 'waiting';
|
|
9
|
+
// 2. this returns immediately, so there is nothing to undo and nothing that
|
|
10
|
+
// can fail between "the person opened the screen" and "the run is
|
|
11
|
+
// waiting for them";
|
|
12
|
+
// 3. the person submits; POST /flows/runs/:runId/submit resumes the step
|
|
13
|
+
// with what they typed, and resumeByKey MERGES that onto this output.
|
|
14
|
+
//
|
|
15
|
+
// ── why it returns {} and not { screen } ─────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// The step's output IS the submitted values, and nothing else. Transitions on
|
|
18
|
+
// a screens flow read `output.<field>` where <field> is a field of the screen,
|
|
19
|
+
// so anything this action put in the output would occupy a name a screen might
|
|
20
|
+
// legitimately use — a screen with a field called `screen` would then branch on
|
|
21
|
+
// this action's own bookkeeping until the moment it was submitted.
|
|
22
|
+
//
|
|
23
|
+
// Which screen was shown is not lost by returning nothing: the resolved input
|
|
24
|
+
// is persisted on the step run (workflow_step_runs.input), so history records
|
|
25
|
+
// it, and the facade reads the screen off the STEP row (values.screen) rather
|
|
26
|
+
// than off a run, because that is where the flow's design lives.
|
|
27
|
+
//
|
|
28
|
+
// ── why it is not in the action palette ──────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// It is registered (the engine resolves a step's actionName through the
|
|
31
|
+
// registry, so a step could not run without it) but hidden from GET /actions —
|
|
32
|
+
// see lib/actionCatalog.js's HIDDEN. Dropping one onto the ordinary workflow
|
|
33
|
+
// canvas would produce a step that parks forever: nothing outside the flows
|
|
34
|
+
// facade knows how to show a screen or how to resume it.
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
name: 'screen-show',
|
|
38
|
+
description: 'Show a screen to a person and wait for them to submit it. The values they ' +
|
|
39
|
+
'submit become this step\'s output, so a transition can branch on them.',
|
|
40
|
+
requires: [],
|
|
41
|
+
|
|
42
|
+
inputSchema: [
|
|
43
|
+
{ name: 'screen', type: 'string', required: true, order: 1,
|
|
44
|
+
description: 'Key of the screen to show. The app that owns the screens resolves it; ' +
|
|
45
|
+
'this engine only carries it.' },
|
|
46
|
+
|
|
47
|
+
// WHAT THE SCREEN OPENS WITH — an ordinary step input, resolved by
|
|
48
|
+
// interpolateAll before this runs, like an email's `to`.
|
|
49
|
+
//
|
|
50
|
+
// A screen can declare that a field is filled in by whoever opens the form
|
|
51
|
+
// (@xeplr/ui-factory's `filledBy: 'caller'`): a containment's complaint is
|
|
52
|
+
// known by the flow and is not a question for the person. This is where
|
|
53
|
+
// the flow answers it — { complaintId: '{steps.d1.output.recordId}' } —
|
|
54
|
+
// and the values arrive already substituted, so the facade hands the app a
|
|
55
|
+
// plain object and nothing downstream knows what a token is.
|
|
56
|
+
//
|
|
57
|
+
// Not called "prefill": it is the step's input, and a screen step is an
|
|
58
|
+
// ordinary step whose action happens to park the run.
|
|
59
|
+
{ name: 'values', type: 'object', order: 2,
|
|
60
|
+
description: 'What the screen starts with — the fields it says are filled in by ' +
|
|
61
|
+
'whoever opens it. A person is not asked for these.' }
|
|
62
|
+
],
|
|
63
|
+
|
|
64
|
+
// Whatever the person submitted, which is not knowable from here — declared
|
|
65
|
+
// as nothing rather than as a lie. See the note above.
|
|
66
|
+
outputSchema: null,
|
|
67
|
+
|
|
68
|
+
execute: async function() {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
};
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// THE AUTH DATABASE, for the hooks that grant a creator access to the company
|
|
2
|
+
// or workspace they just made (models/Company.js, models/Workspace.js) — the
|
|
3
|
+
// standalone server's own tenant screens. An embedded host has its own
|
|
4
|
+
// tenants and never inserts these rows.
|
|
5
|
+
//
|
|
6
|
+
// One connection per process, opened on first use: @xeplr/auth's attach()
|
|
7
|
+
// opens a new one every time it is called, and the standalone server reads
|
|
8
|
+
// the same database (orchestration/standalone.js uses this too).
|
|
9
|
+
var _auth = null;
|
|
10
|
+
|
|
11
|
+
function get() {
|
|
12
|
+
if (!_auth) _auth = require('@xeplr/auth').attach();
|
|
13
|
+
return _auth;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
ready: function() { return get().ready(); },
|
|
18
|
+
model: function(name) { return get().model(name); }
|
|
19
|
+
};
|
package/lib/db.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
var { getConnection, registerMTs } = require('@xeplr/db');
|
|
2
|
+
var models = require('../models');
|
|
3
|
+
|
|
4
|
+
var _registered = false;
|
|
5
|
+
var _conn = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bind workflow's own models to a connection. Config-driven — never reads
|
|
9
|
+
* process.env; that is orchestration/standalone.js's job when nothing else
|
|
10
|
+
* supplies one.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} config
|
|
13
|
+
* @param {string} config.name - database name (workflow's OWN domain data —
|
|
14
|
+
* this is always workflow's responsibility, embedded or standalone; that
|
|
15
|
+
* data belongs to workflow, not whatever it's mounted into)
|
|
16
|
+
* @param {string} config.connection - connection string/encrypted blob, same
|
|
17
|
+
* shape @xeplr/db's getConnection already expects
|
|
18
|
+
* @param {object} [config.mts] - { l1: {name, header}, ... }. registerMTs()
|
|
19
|
+
* is PROCESS-GLOBAL — only pass this when nothing else in this process has
|
|
20
|
+
* already called it. A host app embedding workflow has already registered
|
|
21
|
+
* its own levels; passing config.mts in that case would silently overwrite
|
|
22
|
+
* them. Standalone mode passes it; registerWorkflow's embedded branch does
|
|
23
|
+
* not.
|
|
24
|
+
*/
|
|
25
|
+
async function connectDb(config) {
|
|
26
|
+
config = config || {};
|
|
27
|
+
if (!config.name || !config.connection) {
|
|
28
|
+
throw new Error('connectDb: { name, connection } are required');
|
|
29
|
+
}
|
|
30
|
+
if (config.mts && !_registered) {
|
|
31
|
+
registerMTs(config.mts);
|
|
32
|
+
_registered = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// bind:false — A SECONDARY CONNECTION, never the process's global one.
|
|
36
|
+
//
|
|
37
|
+
// @xeplr/db's bindModels does `Model.knex(instance)` on OBJECTION'S BASE
|
|
38
|
+
// CLASS, so it is global: the last getConnection with bind:true wins for
|
|
39
|
+
// every model in the process, whoever declared it. Standalone that is
|
|
40
|
+
// harmless, because workflow is the only thing here. Embedded it is not —
|
|
41
|
+
// connecting to workflow's own database re-pointed the HOST's models at it
|
|
42
|
+
// too, and the host's own routes started failing on tables that were never
|
|
43
|
+
// in this database.
|
|
44
|
+
//
|
|
45
|
+
// So workflow binds its OWN models to its OWN connection and leaves the
|
|
46
|
+
// global binding alone. Exactly what @xeplr/auth's attach() does to reach
|
|
47
|
+
// the auth database from the api process, and what @xeplr/actions'
|
|
48
|
+
// attachConfig() does for xeplr_configs — this is the third instance of the
|
|
49
|
+
// same shape, not a new idea.
|
|
50
|
+
_conn = await getConnection(config.name, config.connection, {
|
|
51
|
+
bind: false,
|
|
52
|
+
connectionName: config.connectionName || 'workflow'
|
|
53
|
+
});
|
|
54
|
+
return _conn;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function conn() {
|
|
58
|
+
if (!_conn) throw new Error('workflow: connection not ready — await connectDb() first');
|
|
59
|
+
return _conn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A workflow model BOUND to workflow's own connection.
|
|
64
|
+
*
|
|
65
|
+
* Every query in this package goes through here rather than through the class
|
|
66
|
+
* exported by models/index.js. An unbound class falls back to the global
|
|
67
|
+
* binding, which belongs to whatever else is in this process — the host app,
|
|
68
|
+
* embedded — so it would read the wrong database and mostly report that
|
|
69
|
+
* workflow's tables do not exist.
|
|
70
|
+
*
|
|
71
|
+
* Objection caches per (Model, knex) pair, so calling this per request costs a
|
|
72
|
+
* map lookup rather than a new class.
|
|
73
|
+
*/
|
|
74
|
+
function model(name) {
|
|
75
|
+
var M = models[name];
|
|
76
|
+
if (!M) throw new Error('workflow: unknown model "' + name + '"');
|
|
77
|
+
return M.bindKnex(conn());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = connectDb;
|
|
81
|
+
module.exports.conn = conn;
|
|
82
|
+
module.exports.model = model;
|