@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +352 -0
  3. package/bin/www +7 -0
  4. package/db/xcfgSetup.js +8 -0
  5. package/env.required.js +41 -0
  6. package/index.js +313 -0
  7. package/lib/actionCatalog.js +193 -0
  8. package/lib/actions/jobRun.js +149 -0
  9. package/lib/actions/screenShow.js +55 -0
  10. package/lib/db.js +82 -0
  11. package/lib/envExposed.js +87 -0
  12. package/lib/flows.js +832 -0
  13. package/lib/flowsRouter.js +113 -0
  14. package/lib/router.js +260 -0
  15. package/lib/workflowRunner.js +649 -0
  16. package/migrations/0001_companies.sql +23 -0
  17. package/migrations/0002_workspaces.sql +22 -0
  18. package/migrations/0003_workflows.sql +35 -0
  19. package/migrations/0004_workflow_steps.sql +79 -0
  20. package/migrations/0005_workflow_runs.sql +49 -0
  21. package/migrations/0006_workflow_step_runs.sql +42 -0
  22. package/migrations/0007_workflow_resume_keys.sql +42 -0
  23. package/migrations/0008_workflow_run_edges.sql +43 -0
  24. package/migrations/0009_workflow_steps_layout.sql +11 -0
  25. package/migrations/0010_workflow_steps_sample_output.sql +17 -0
  26. package/migrations/0011_workflow_steps_params.sql +27 -0
  27. package/migrations/0012_workflows_kind.sql +27 -0
  28. package/migrations/0013_workflows_key.sql +48 -0
  29. package/migrations-auth/0001_workflow_access.sql +129 -0
  30. package/migrations-auth/0003_nav_menus.sql +62 -0
  31. package/migrations-auth/0004_flows_access.sql +76 -0
  32. package/models/Company.js +82 -0
  33. package/models/Workflow.js +63 -0
  34. package/models/WorkflowResumeKey.js +32 -0
  35. package/models/WorkflowRun.js +64 -0
  36. package/models/WorkflowRunEdge.js +49 -0
  37. package/models/WorkflowStep.js +66 -0
  38. package/models/WorkflowStepRun.js +49 -0
  39. package/models/Workspace.js +75 -0
  40. package/models/index.js +25 -0
  41. package/orchestration/standalone.js +123 -0
  42. 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
+ };