@xeplr/workflow 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +352 -0
- package/bin/www +7 -0
- package/db/xcfgSetup.js +8 -0
- package/env.required.js +41 -0
- package/index.js +313 -0
- package/lib/actionCatalog.js +193 -0
- package/lib/actions/jobRun.js +149 -0
- package/lib/actions/screenShow.js +55 -0
- package/lib/db.js +82 -0
- package/lib/envExposed.js +87 -0
- package/lib/flows.js +832 -0
- package/lib/flowsRouter.js +113 -0
- package/lib/router.js +260 -0
- package/lib/workflowRunner.js +649 -0
- package/migrations/0001_companies.sql +23 -0
- package/migrations/0002_workspaces.sql +22 -0
- package/migrations/0003_workflows.sql +35 -0
- package/migrations/0004_workflow_steps.sql +79 -0
- package/migrations/0005_workflow_runs.sql +49 -0
- package/migrations/0006_workflow_step_runs.sql +42 -0
- package/migrations/0007_workflow_resume_keys.sql +42 -0
- package/migrations/0008_workflow_run_edges.sql +43 -0
- package/migrations/0009_workflow_steps_layout.sql +11 -0
- package/migrations/0010_workflow_steps_sample_output.sql +17 -0
- package/migrations/0011_workflow_steps_params.sql +27 -0
- package/migrations/0012_workflows_kind.sql +27 -0
- package/migrations/0013_workflows_key.sql +48 -0
- package/migrations-auth/0001_workflow_access.sql +129 -0
- package/migrations-auth/0003_nav_menus.sql +62 -0
- package/migrations-auth/0004_flows_access.sql +76 -0
- package/models/Company.js +82 -0
- package/models/Workflow.js +63 -0
- package/models/WorkflowResumeKey.js +32 -0
- package/models/WorkflowRun.js +64 -0
- package/models/WorkflowRunEdge.js +49 -0
- package/models/WorkflowStep.js +66 -0
- package/models/WorkflowStepRun.js +49 -0
- package/models/Workspace.js +75 -0
- package/models/index.js +25 -0
- package/orchestration/standalone.js +123 -0
- package/package.json +69 -0
package/index.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
var path = require('path');
|
|
2
|
+
var createApp = require('@xeplr/base-apis/express');
|
|
3
|
+
var { up } = require('@xeplr/db').sqlMigrator;
|
|
4
|
+
var { resolveConfig, registerApplication, getApplicationId, migrationsFor, ensureDatabaseFor } = require('@xeplr/db');
|
|
5
|
+
var buildWorkflowRouter = require('./lib/router');
|
|
6
|
+
var buildFlowsRouter = require('./lib/flowsRouter');
|
|
7
|
+
var connectDb = require('./lib/db');
|
|
8
|
+
var actionCatalog = require('./lib/actionCatalog');
|
|
9
|
+
var envExposed = require('./lib/envExposed');
|
|
10
|
+
var xcfgSetup = require('./db/xcfgSetup');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register workflow into a host app, or stand up its own.
|
|
14
|
+
*
|
|
15
|
+
* // Embedded — mounted into a caller's existing Express app:
|
|
16
|
+
* await registerWorkflow({
|
|
17
|
+
* app: myExpressApp,
|
|
18
|
+
* mountPath: '/workflow',
|
|
19
|
+
* db: { name: 'xeplr_bi_workflow', connection: myDbConnectionString },
|
|
20
|
+
* mtMembershipGate: myAlreadyBuiltGate // omit → every route ungated
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* // Standalone — this package creates and starts its own app via
|
|
24
|
+
* // @xeplr/base-apis' createApp (the only thing that ever builds an app
|
|
25
|
+
* // here, embedded or standalone). See orchestration/standalone.js, which
|
|
26
|
+
* // is exactly this call with its config sourced from development.env.
|
|
27
|
+
* await registerWorkflow({ db: {...}, port: 19122, middleware: [...] });
|
|
28
|
+
*
|
|
29
|
+
* @param {object} config
|
|
30
|
+
* @param {import('express').Express} [config.app] - an existing Express app
|
|
31
|
+
* to mount onto. Omit WITH config.port to have this package create and
|
|
32
|
+
* start its own; omit BOTH to get the router back and mount it yourself.
|
|
33
|
+
*
|
|
34
|
+
* That third mode is not a convenience — for some hosts it is the only
|
|
35
|
+
* thing that works. @xeplr/base-apis' createApp registers a catch-all 404
|
|
36
|
+
* AFTER the routes it was given, so anything app.use()'d afterwards sits
|
|
37
|
+
* behind it and is never reached: the mount succeeds, and every path under
|
|
38
|
+
* it answers 404. A host built that way has to hand the router in with its
|
|
39
|
+
* own route map rather than bolt it on after.
|
|
40
|
+
* @param {object} config.db - { name, connection, mts? } — see lib/db.js.
|
|
41
|
+
* Always required: workflow's own domain data is always its own
|
|
42
|
+
* responsibility, however it's mounted.
|
|
43
|
+
* @param {string} [config.applicationId] - the PRODUCT mounting this
|
|
44
|
+
* ('xeplr-bi', 'xeplr-erp'; 'xeplr-workflow' standalone). OPTIONAL: a host
|
|
45
|
+
* normally calls registerApplication() once at its own startup and every
|
|
46
|
+
* sub-product it mounts inherits that, so passing this again is only for a
|
|
47
|
+
* host that prefers to be explicit.
|
|
48
|
+
*
|
|
49
|
+
* It does NOT separate workflow's own data — config.db does that. Each host
|
|
50
|
+
* gives workflow its own database (xeplr_bi_workflow, xeplr_erp_workflow),
|
|
51
|
+
* which is the whole boundary, and is why none of these tables carries an
|
|
52
|
+
* applicationId column.
|
|
53
|
+
*
|
|
54
|
+
* What it IS for: xeplr_configs, the one store that cannot be split per
|
|
55
|
+
* consumer because every app opens it. This mount writes movement metadata
|
|
56
|
+
* there, and those rows are attributed by applicationId. So an identity has
|
|
57
|
+
* to be registered by the time this is called — hence the throw below when
|
|
58
|
+
* neither this nor a prior registerApplication() supplied one.
|
|
59
|
+
* @param {string} [config.mountPath='/workflow'] - only meaningful when this
|
|
60
|
+
* package creates its own app; a host app's own app.use() call decides its
|
|
61
|
+
* own mount path when embedding.
|
|
62
|
+
* @param {number} [config.port] - only used when creating a standalone app.
|
|
63
|
+
* @param {string} [config.appName='xeplr_workflow_api']
|
|
64
|
+
* @param {Function} [config.mtMembershipGate] - see lib/router.js
|
|
65
|
+
* @param {Function} [config.authMiddleware] - see lib/router.js
|
|
66
|
+
* @param {Function[]} [config.middleware] - only used when creating a
|
|
67
|
+
* standalone app — passed straight to createApp.
|
|
68
|
+
* @param {object|false} [config.log] - only used when creating a standalone app.
|
|
69
|
+
* @returns {Promise<{ router: import('express').Router, flowsRouter: import('express').Router, app: import('express').Express, server?: import('http').Server }>}
|
|
70
|
+
* `router` carries every route including the flows facade at
|
|
71
|
+
* `<mount>/flows`. `flowsRouter` is that facade on its own, for a host that
|
|
72
|
+
* would rather mount it somewhere else as well — see the note where it is
|
|
73
|
+
* built.
|
|
74
|
+
*/
|
|
75
|
+
async function registerWorkflow(config) {
|
|
76
|
+
config = config || {};
|
|
77
|
+
if (!config.db) {
|
|
78
|
+
throw new Error('registerWorkflow: config.db is required — workflow owns its own domain data regardless of how it is mounted');
|
|
79
|
+
}
|
|
80
|
+
// WORKFLOW'S OWN TABLES CARRY NO applicationId. Each host supplies its own
|
|
81
|
+
// database (config.db), so that database is the boundary — a column holding
|
|
82
|
+
// one constant value in every row would buy nothing, which is the same
|
|
83
|
+
// reason xeplr_bi and the auth DBs do not have one either.
|
|
84
|
+
//
|
|
85
|
+
// The identity is still REQUIRED, because this mount also writes to
|
|
86
|
+
// xeplr_configs — the one store that genuinely is shared by every app and
|
|
87
|
+
// cannot be split per consumer (reference data, plus import_meta). Rows
|
|
88
|
+
// there are attributed by applicationId, so a mount that skipped this would
|
|
89
|
+
// quietly file its movement metadata under the package name instead of the
|
|
90
|
+
// product's.
|
|
91
|
+
//
|
|
92
|
+
// Inherited by default: a host registers itself once at boot and every
|
|
93
|
+
// sub-product it mounts picks it up with no further wiring. config
|
|
94
|
+
// .applicationId is accepted for a host that would rather be explicit, and
|
|
95
|
+
// is idempotent under the same value.
|
|
96
|
+
if (config.applicationId) registerApplication(config.applicationId);
|
|
97
|
+
if (!getApplicationId()) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'registerWorkflow: no application registered. Call registerApplication("<this-product>") ' +
|
|
100
|
+
'from @xeplr/db once at host startup (or pass config.applicationId). It attributes the ' +
|
|
101
|
+
'rows this mount writes to the SHARED xeplr_configs database; workflow\'s own tables are ' +
|
|
102
|
+
'separated by having their own database, not by this.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await connectDb(config.db);
|
|
106
|
+
|
|
107
|
+
// ITS OWN TABLES, ITS OWN JOB — for the same reason config.db is required in
|
|
108
|
+
// both modes. This lived in orchestration/standalone.js, so a host that
|
|
109
|
+
// embedded workflow got a connection to a database with none of workflow's
|
|
110
|
+
// tables in it, and the first request failed on a missing relation.
|
|
111
|
+
//
|
|
112
|
+
// Idempotent, so a host that also runs them loses nothing by this.
|
|
113
|
+
//
|
|
114
|
+
// The migrator resolves its connection BY NAME from a registry that
|
|
115
|
+
// getConnection above does not populate, so the name has to be resolved
|
|
116
|
+
// explicitly first. Without this an embedded mount died on
|
|
117
|
+
// 'No resolved config for "WORKFLOW"' and the whole product silently did
|
|
118
|
+
// not appear — while every /workflow/* path still answered 401, because the
|
|
119
|
+
// host's auth middleware runs before routing and a missing route never got
|
|
120
|
+
// the chance to 404.
|
|
121
|
+
var migrationConnection = config.db.connectionName || 'workflow';
|
|
122
|
+
await resolveConfig(migrationConnection, config.db.connection);
|
|
123
|
+
|
|
124
|
+
// CREATE IT IF IT IS NOT THERE. Each host gives workflow its own database
|
|
125
|
+
// (xeplr_bi_workflow, xeplr_erp_workflow) — that separation is the entire
|
|
126
|
+
// isolation model, so pointing a host at a name that does not exist yet is
|
|
127
|
+
// the NORMAL first boot, not an error state. Without this it failed on
|
|
128
|
+
// "database does not exist" and every host had to run a manual createdb
|
|
129
|
+
// that nothing in the config hinted at. Same as @xeplr/email does.
|
|
130
|
+
var ensured = await ensureDatabaseFor(config.db.connection, config.db.name);
|
|
131
|
+
if (ensured.created) console.log('[workflow] created database ' + config.db.name);
|
|
132
|
+
// XEPLR_WORKFLOW_MIGRATIONS — a host adds its own tables or seed rows to
|
|
133
|
+
// workflow's database without editing this package. Same convention as
|
|
134
|
+
// every other xeplr library (see @xeplr/db's app-migrations.js); config
|
|
135
|
+
// .migrations lets an embedding host pass them directly instead, since a
|
|
136
|
+
// host that already has the paths in code should not have to route them
|
|
137
|
+
// back out through the environment.
|
|
138
|
+
var migrated = await up({
|
|
139
|
+
db: config.db.name,
|
|
140
|
+
dir: path.join(__dirname, 'migrations'),
|
|
141
|
+
extDir: config.migrations || migrationsFor('workflow'),
|
|
142
|
+
type: 'precede',
|
|
143
|
+
connectionName: migrationConnection
|
|
144
|
+
});
|
|
145
|
+
if (migrated.migrations.length) {
|
|
146
|
+
console.log('[workflow] ran ' + migrated.migrations.length + ' migrations');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
// THE ACTION CATALOG, for the same reason the migrations above moved here.
|
|
151
|
+
// This lived only in orchestration/standalone.js, so an embedded host got a
|
|
152
|
+
// mounted product whose registry was empty: GET /actions answered 200 with
|
|
153
|
+
// an empty list, the step editor's Action dropdown drew nothing, and there
|
|
154
|
+
// was no error anywhere to explain why — the one failure mode the whitelist
|
|
155
|
+
// in lib/actionCatalog.js is otherwise careful to make loud.
|
|
156
|
+
//
|
|
157
|
+
// Idempotent (register() overwrites by name), so a host that somehow also
|
|
158
|
+
// registers them loses nothing.
|
|
159
|
+
//
|
|
160
|
+
// xeplr_configs is best-effort HERE and fail-fast in standalone, and the
|
|
161
|
+
// difference is deliberate: standalone owns its whole process and should
|
|
162
|
+
// refuse to boot half-configured, whereas failing a host's mount over one
|
|
163
|
+
// action that records movement metadata would take down a product that is
|
|
164
|
+
// otherwise entirely functional. The warning names what was lost.
|
|
165
|
+
try {
|
|
166
|
+
await xcfgSetup.ready();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.warn('[workflow] xeplr_configs unavailable (' + err.message + ') — actions needing it are not offered');
|
|
169
|
+
}
|
|
170
|
+
var registered = actionCatalog.registerAll();
|
|
171
|
+
console.log('[workflow] registered ' + registered.length + ' actions: ' + registered.join(', '));
|
|
172
|
+
|
|
173
|
+
// Say out loud what step templates can read out of the environment. Silent
|
|
174
|
+
// by default (nothing exposed unless WORKFLOW_ENV_EXPOSED names it), so a
|
|
175
|
+
// line here means somebody opted in — and a list that accidentally names a
|
|
176
|
+
// secret is only catchable if it is visible somewhere.
|
|
177
|
+
envExposed.logExposure();
|
|
178
|
+
|
|
179
|
+
var router = buildWorkflowRouter(config);
|
|
180
|
+
|
|
181
|
+
// THE FLOWS FACADE, ALSO ON ITS OWN. It is already inside `router` at
|
|
182
|
+
// <mount>/flows, which is all most hosts need. This second, independent
|
|
183
|
+
// instance is for a host that wants to serve it from a path of its own —
|
|
184
|
+
// routes['/flows'] = flowsRouter, beside routes['/workflow'] = router —
|
|
185
|
+
// because the app designing the screens is not the app that mounted the
|
|
186
|
+
// workflow builder and should not have to reach through its URL space.
|
|
187
|
+
//
|
|
188
|
+
// Same builder, same config, and it gates itself, so the two mounts are
|
|
189
|
+
// interchangeable rather than merely similar. Mounting both is fine; they
|
|
190
|
+
// share nothing but the database.
|
|
191
|
+
var flowsRouter = buildFlowsRouter(config);
|
|
192
|
+
|
|
193
|
+
if (config.app) {
|
|
194
|
+
config.app.use(config.mountPath || '/workflow', router);
|
|
195
|
+
return { router: router, flowsRouter: flowsRouter, app: config.app };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// No app to mount onto and no port to listen on: the caller wants the
|
|
199
|
+
// router itself. See the note on config.app.
|
|
200
|
+
if (!config.port) return { router: router, flowsRouter: flowsRouter };
|
|
201
|
+
|
|
202
|
+
var mountPath = config.mountPath || '/workflow';
|
|
203
|
+
var routes = {};
|
|
204
|
+
routes[mountPath] = router;
|
|
205
|
+
|
|
206
|
+
var built = createApp(config.port, config.appName || 'xeplr_workflow_api', {
|
|
207
|
+
// Passed through, NOT defaulted here: undefined means createApp's own
|
|
208
|
+
// default, which is gated. Only reached in STANDALONE mode — an embedded
|
|
209
|
+
// mount returns above, and its host's own app already carries a gate.
|
|
210
|
+
auth: config.auth,
|
|
211
|
+
log: config.log,
|
|
212
|
+
middleware: config.middleware || [],
|
|
213
|
+
routes: routes
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { router: router, flowsRouter: flowsRouter, app: built.app, server: built.server };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Env vars a HOST embedding workflow must supply — spread into the host's
|
|
220
|
+
// env.required.js so a missing one fails at startup rather than at mount:
|
|
221
|
+
//
|
|
222
|
+
// ...require('@xeplr/workflow').embedRequiredEnv, // DB_WORKFLOW
|
|
223
|
+
//
|
|
224
|
+
// Named `embedRequiredEnv`, not `requiredEnv`, because it is not the same
|
|
225
|
+
// list as this package's OWN standalone process needs (see env.required.js,
|
|
226
|
+
// which uses DB_API for the very same database — a standalone deployment
|
|
227
|
+
// names its own store, an embedded one is given a separate one by its host).
|
|
228
|
+
// Two deployments, two questions, two lists.
|
|
229
|
+
//
|
|
230
|
+
// DB_WORKFLOW only: the SERVER login comes from the shared
|
|
231
|
+
// XEPLR_DB_CONNECTION, and workflow never reads either itself — the host
|
|
232
|
+
// passes both into registerWorkflow({ db }). This list exists so the host is
|
|
233
|
+
// told what to pass before it boots.
|
|
234
|
+
var embedRequiredEnv = [
|
|
235
|
+
'DB_WORKFLOW'
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resume a run parked on a 'wait' step. THE ENTIRE consumer-side contract for
|
|
240
|
+
* waiting — the caller never needs to know which run or step the key belongs
|
|
241
|
+
* to, because that lookup lives in workflow_resume_keys alone.
|
|
242
|
+
*
|
|
243
|
+
* var { resumeByKey } = require('@xeplr/workflow');
|
|
244
|
+
* await resumeByKey(key, { confirmedBy: userId });
|
|
245
|
+
*
|
|
246
|
+
* SERVER-SIDE, IN-PROCESS, DELIBERATELY. This is not something a browser
|
|
247
|
+
* calls. The host's own route — the activation link's landing route, the
|
|
248
|
+
* confirm button's POST — does its own work (activate the account, record the
|
|
249
|
+
* approval) and then calls this in the same request. The key travels out
|
|
250
|
+
* bound into that route's URL via `{resumeKey}` and comes back to the host,
|
|
251
|
+
* never to workflow's own HTTP surface.
|
|
252
|
+
*
|
|
253
|
+
* That is the whole reason it is exported here rather than left as a route: a
|
|
254
|
+
* resume is a step in the host's transaction, not a separate thing a client
|
|
255
|
+
* is trusted to trigger.
|
|
256
|
+
*
|
|
257
|
+
* Throws with `err.code` set, which is what a host should branch on:
|
|
258
|
+
*
|
|
259
|
+
* RESUME_KEY_INVALID no live key — already used, unknown, or the run is
|
|
260
|
+
* gone. NORMAL for a host route that also serves
|
|
261
|
+
* users who never came from a workflow: catch it and
|
|
262
|
+
* carry on with your own work.
|
|
263
|
+
* RESUME_KEY_NOT_READY the key is real but its step has not parked yet
|
|
264
|
+
* (the action that delivers it is still running).
|
|
265
|
+
* Nothing is consumed — retry shortly.
|
|
266
|
+
*
|
|
267
|
+
* Requires registerWorkflow() to have run in this process: it is what
|
|
268
|
+
* connects workflow's database, and this reads through that same connection.
|
|
269
|
+
* Needs no ambient tenant context — the run carries its own mtIds and this
|
|
270
|
+
* re-enters them itself.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} key the value bound into the step as {resumeKey}
|
|
273
|
+
* @param {object} [output] merged into the wait step's output, so later
|
|
274
|
+
* steps can read it as {steps.<stepKey>.output.<name>}
|
|
275
|
+
* @param {object} [opts]
|
|
276
|
+
* @param {'success'|'failed'} [opts.status] resolve the step as FAILED rather
|
|
277
|
+
* than succeeded — from there the step's own `onError` decides, defaulting
|
|
278
|
+
* to stopping the run. Omit for success, which is what every caller that
|
|
279
|
+
* predates this means (an approval click, a confirmation link).
|
|
280
|
+
* @param {object} [opts.error] recorded on the step run when status is
|
|
281
|
+
* 'failed'.
|
|
282
|
+
* @returns {Promise<{ runId: string, stepKey: string, status: string }>}
|
|
283
|
+
*/
|
|
284
|
+
async function resumeByKey(key, output, opts) {
|
|
285
|
+
return require('./lib/workflowRunner').resumeByKey(key, output, opts);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// REQUIRED ONLY IF YOU USE STEPS THAT CALL SOMETHING OUTSIDE THIS PROCESS —
|
|
289
|
+
// today that is `job-run`. Kept as its own list, the same way @xeplr/email
|
|
290
|
+
// separates `requiredEnv` (sending) from `templatesRequiredEnv` (the template
|
|
291
|
+
// store): a host that only runs self-contained workflows is never made to
|
|
292
|
+
// configure something it does not use.
|
|
293
|
+
//
|
|
294
|
+
// WORKFLOW_PUBLIC_URL is the base a CALLER can reach this service on, mount
|
|
295
|
+
// path included — https://bi.example.com/workflow for an embedded mount. It is
|
|
296
|
+
// what {resumeUrl} is built from, so a step can hand a job an address to
|
|
297
|
+
// report back to.
|
|
298
|
+
//
|
|
299
|
+
// NOT DEFAULTED, and that is the point of it being here rather than having a
|
|
300
|
+
// fallback: a wrong-but-present address gives a job somewhere to POST that
|
|
301
|
+
// nothing is listening on, so the run waits forever on work that actually
|
|
302
|
+
// finished. Unset, {resumeUrl} is null and job-run refuses by name before
|
|
303
|
+
// starting anything.
|
|
304
|
+
var callbackRequiredEnv = [
|
|
305
|
+
'WORKFLOW_PUBLIC_URL'
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
registerWorkflow: registerWorkflow,
|
|
310
|
+
resumeByKey: resumeByKey,
|
|
311
|
+
embedRequiredEnv: embedRequiredEnv,
|
|
312
|
+
callbackRequiredEnv: callbackRequiredEnv
|
|
313
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
];
|
|
81
|
+
|
|
82
|
+
var HIDDEN_NAMES = HIDDEN.map(function(def) { return def.name; });
|
|
83
|
+
|
|
84
|
+
// Needs the control-plane connection to record what moved where, so it is
|
|
85
|
+
// registered separately once xcfgSetup has resolved.
|
|
86
|
+
var WANTED_WITH_META = ['fileUpload'];
|
|
87
|
+
|
|
88
|
+
// DELIBERATELY ABSENT: spawnProgram. It runs arbitrary executables, which is a
|
|
89
|
+
// different kind of permission from "call an HTTP endpoint" — named here so
|
|
90
|
+
// that leaving it out reads as a decision rather than an oversight.
|
|
91
|
+
|
|
92
|
+
/** A module the registry will actually accept, as opposed to a placeholder. */
|
|
93
|
+
function isReady(def) {
|
|
94
|
+
return Boolean(def && typeof def.execute === 'function' && def.name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function offer(key, def, registered, skipped) {
|
|
98
|
+
if (!isReady(def)) { skipped.push(key); return; }
|
|
99
|
+
actions.register(def);
|
|
100
|
+
registered.push(def.name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Registers this app's actions and returns their names.
|
|
105
|
+
*
|
|
106
|
+
* Idempotent — @xeplr/actions' register() overwrites by name, so calling this
|
|
107
|
+
* twice leaves the registry in the same state rather than doubling it.
|
|
108
|
+
*/
|
|
109
|
+
function registerAll() {
|
|
110
|
+
var registered = [];
|
|
111
|
+
var skipped = [];
|
|
112
|
+
|
|
113
|
+
WANTED.forEach(function(key) {
|
|
114
|
+
offer(key, actions.builtins[key], registered, skipped);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
LOCAL.forEach(function(def) {
|
|
118
|
+
offer(def.name, def, registered, skipped);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Same registration, no catalog entry — see HIDDEN.
|
|
122
|
+
HIDDEN.forEach(function(def) {
|
|
123
|
+
offer(def.name, def, registered, skipped);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
WANTED_WITH_META.forEach(function(key) {
|
|
127
|
+
var entry = actions.builtins[key];
|
|
128
|
+
// Factory-shaped in some versions, a plain module in others. Both are
|
|
129
|
+
// handled rather than asserted, because which one it is is the package's
|
|
130
|
+
// business and not worth breaking a boot over.
|
|
131
|
+
//
|
|
132
|
+
// Guarded because reading xcfgSetup.metaStore THROWS when the
|
|
133
|
+
// control-plane connection was never established — which is a real state
|
|
134
|
+
// for an embedded host (see registerWorkflow, where xeplr_configs is
|
|
135
|
+
// best-effort). Losing one action to that is correct; losing the whole
|
|
136
|
+
// catalog, and with it every other action, is not.
|
|
137
|
+
try {
|
|
138
|
+
var def = typeof entry === 'function' ? entry({ metaKnex: xcfgSetup.metaStore }) : entry;
|
|
139
|
+
offer(key, def, registered, skipped);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn('[actions] ' + key + ' could not be registered: ' + err.message);
|
|
142
|
+
skipped.push(key);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Said out loud, once, at startup. A name silently missing from the picker is
|
|
147
|
+
// a bug somebody spends an afternoon on; a line in the boot log is not.
|
|
148
|
+
if (skipped.length) {
|
|
149
|
+
console.warn('[actions] not yet implemented in @xeplr/actions, so not offered: ' + skipped.join(', '));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return registered;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The catalog, as the UI needs it: name, description and the INPUT SCHEMA that
|
|
157
|
+
* describes what each one takes.
|
|
158
|
+
*
|
|
159
|
+
* The schema is the point. A step editor told separately what an action accepts
|
|
160
|
+
* is a second description of it, and the day the two disagree the form collects
|
|
161
|
+
* a field the action ignores. Sending the registry's own schema means the form
|
|
162
|
+
* is generated from the thing it is a form for.
|
|
163
|
+
*
|
|
164
|
+
* The EXECUTOR is not sent, and could not usefully be — it is a function, it
|
|
165
|
+
* would not survive JSON, and an endpoint that hands out executors is an
|
|
166
|
+
* endpoint that explains how to bypass itself.
|
|
167
|
+
*/
|
|
168
|
+
function catalog() {
|
|
169
|
+
return actions.list().filter(function(a) {
|
|
170
|
+
// HIDDEN is registered, so the engine can run a step that names it, and
|
|
171
|
+
// absent here, so no palette ever draws it. Filtered at the point the list
|
|
172
|
+
// leaves the process rather than by not registering it — the two questions
|
|
173
|
+
// ("can this run" and "should anyone pick it") have different answers.
|
|
174
|
+
return HIDDEN_NAMES.indexOf(a.name) === -1;
|
|
175
|
+
}).map(function(a) {
|
|
176
|
+
return {
|
|
177
|
+
name: a.name,
|
|
178
|
+
description: a.description,
|
|
179
|
+
inputSchema: a.inputSchema,
|
|
180
|
+
outputSchema: a.outputSchema
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
registerAll: registerAll,
|
|
187
|
+
catalog: catalog,
|
|
188
|
+
isReady: isReady,
|
|
189
|
+
WANTED: WANTED,
|
|
190
|
+
WANTED_WITH_META: WANTED_WITH_META,
|
|
191
|
+
LOCAL: LOCAL,
|
|
192
|
+
HIDDEN: HIDDEN
|
|
193
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
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', required: true, order: 1,
|
|
48
|
+
description: 'Base URL of the jobs API — e.g. https://bi.example.com/api' },
|
|
49
|
+
{ name: 'jobId', type: 'string', required: true, order: 2,
|
|
50
|
+
description: 'The job to run.' },
|
|
51
|
+
{ name: 'callbackUrl', type: 'string', required: true, order: 3,
|
|
52
|
+
description: 'Where the job reports its outcome. Bind this to {resumeUrl} — the canvas ' +
|
|
53
|
+
'does it for you; it is the address of this step\'s own resume key.' },
|
|
54
|
+
{ name: 'inputs', type: 'object', order: 4, group: 'Advanced',
|
|
55
|
+
description: 'Values to override the job\'s saved inputs, for this run only. The job row ' +
|
|
56
|
+
'is not changed. Commonly a window: { window: { from, to } }.' },
|
|
57
|
+
{ name: 'timeoutMs', type: 'number', default: 30000, order: 5, group: 'Advanced',
|
|
58
|
+
description: 'How long to wait for the TRIGGER call to be accepted. Nothing to do with ' +
|
|
59
|
+
'how long the job itself may run — that is the job\'s own timeout.' }
|
|
60
|
+
],
|
|
61
|
+
|
|
62
|
+
execute: async function(ctx) {
|
|
63
|
+
var input = ctx.input || {};
|
|
64
|
+
|
|
65
|
+
// The binding resolved to nothing, which means WORKFLOW_PUBLIC_URL is
|
|
66
|
+
// unset (see workflowRunner's publicResumeUrl). Caught HERE, before the
|
|
67
|
+
// job is started, because the alternative is a job that runs perfectly for
|
|
68
|
+
// twenty minutes and then has nowhere to report to — leaving the step
|
|
69
|
+
// waiting forever on work that actually succeeded.
|
|
70
|
+
if (!input.callbackUrl) throw new Error(CALLBACK_UNREACHABLE);
|
|
71
|
+
|
|
72
|
+
// THE SAME CHECK FOR THE OTHER BINDING, and it needs its own message.
|
|
73
|
+
//
|
|
74
|
+
// The canvas fills jobsUrl with `{env.JOBS_API_URL}`, and the engine
|
|
75
|
+
// resolves an unknown binding to the EMPTY STRING rather than failing —
|
|
76
|
+
// so an unset (or unexposed) variable arrives here as '' and satisfies
|
|
77
|
+
// `required`, which only rejects null and undefined. Left alone that
|
|
78
|
+
// becomes fetch('/jobs/j1/trigger') and a "Failed to parse URL" nobody can
|
|
79
|
+
// trace back to an environment variable.
|
|
80
|
+
//
|
|
81
|
+
// Both halves are named because either one alone is the cause: the
|
|
82
|
+
// variable can be set and simply not listed in WORKFLOW_ENV_EXPOSED, which
|
|
83
|
+
// is exactly as invisible.
|
|
84
|
+
var jobsUrl = String(input.jobsUrl || '').trim();
|
|
85
|
+
if (!/^https?:\/\//i.test(jobsUrl)) {
|
|
86
|
+
throw new Error('This step needs the address of the jobs API and got ' +
|
|
87
|
+
(jobsUrl ? '"' + jobsUrl + '"' : 'nothing') + '. It is normally bound to ' +
|
|
88
|
+
'{env.JOBS_API_URL}, which resolves to an empty string unless JOBS_API_URL is set ' +
|
|
89
|
+
'AND named in WORKFLOW_ENV_EXPOSED — check both. It must be absolute, e.g. ' +
|
|
90
|
+
'https://bi.example.com/api');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var base = jobsUrl.replace(/\/+$/, '');
|
|
94
|
+
var url = base + '/jobs/' + encodeURIComponent(input.jobId) + '/trigger';
|
|
95
|
+
|
|
96
|
+
var controller = new AbortController();
|
|
97
|
+
var timer = setTimeout(function() { controller.abort(); }, input.timeoutMs || 30000);
|
|
98
|
+
if (timer.unref) timer.unref();
|
|
99
|
+
|
|
100
|
+
var res;
|
|
101
|
+
try {
|
|
102
|
+
res = await fetch(url, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
inputs: input.inputs || undefined,
|
|
107
|
+
callbackUrl: input.callbackUrl
|
|
108
|
+
}),
|
|
109
|
+
signal: controller.signal
|
|
110
|
+
});
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// BUSY IS A FAILURE, NOT A WAIT.
|
|
116
|
+
//
|
|
117
|
+
// A job holds one lock and runs one at a time. Treating 409 as "try again
|
|
118
|
+
// shortly" would mean a chain that quietly continues past a movement that
|
|
119
|
+
// never ran, and the step after it aggregating a period nothing loaded.
|
|
120
|
+
// Failing here stops the run, which is what the step's own onError already
|
|
121
|
+
// defaults to.
|
|
122
|
+
if (res.status === 409) {
|
|
123
|
+
throw new Error('Job ' + input.jobId + ' is already running, so this step cannot start it. ' +
|
|
124
|
+
'Wait for the current run to finish, or schedule these so they do not overlap.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
var detail = await res.text().catch(function() { return ''; });
|
|
129
|
+
throw new Error('Could not start job ' + input.jobId + ' — the jobs API answered ' +
|
|
130
|
+
res.status + (detail ? ': ' + detail.slice(0, 300) : '') + '.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
var body = await res.json().catch(function() { return {}; });
|
|
134
|
+
var row = (body.dataArray && body.dataArray[0]) || {};
|
|
135
|
+
|
|
136
|
+
// WHAT THIS STEP KNOWS SO FAR — the handle, not the result.
|
|
137
|
+
//
|
|
138
|
+
// The occurrence's outcome arrives later, through the callback, and
|
|
139
|
+
// resumeByKey MERGES it over this. So a downstream binding of
|
|
140
|
+
// {steps.<key>.output.occurrenceId} works from the moment the job starts,
|
|
141
|
+
// and {steps.<key>.output.output.totalRows} works once it has finished.
|
|
142
|
+
return {
|
|
143
|
+
jobId: input.jobId,
|
|
144
|
+
occurrenceId: row.occurrenceId || null,
|
|
145
|
+
startedAt: new Date().toISOString(),
|
|
146
|
+
status: 'running'
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
};
|