@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
@@ -0,0 +1,113 @@
1
+ // THE FLOWS FACADE, as HTTP.
2
+ //
3
+ // Nothing but plumbing: each route reads its parameters, calls one function in
4
+ // lib/flows.js, and answers. Everything that could be wrong — a comparison the
5
+ // engine does not have, a transition pointing at nothing, a run belonging to
6
+ // another company — is decided there, where it can be tested without a socket.
7
+ //
8
+ // ── what this answers with, and why it is not `dataArray` ───────────────
9
+ //
10
+ // Every other route in this package answers `{ dataArray: [...] }`, because
11
+ // every other route is talked to by this product's own builder through
12
+ // @xeplr/base-apis' generic controller. This one is a FACADE for a different
13
+ // app: it hands back the object the caller asked about, and an error as the
14
+ // same `{ message, code, details }` body the rest of the package already uses.
15
+ // A client of this router should not have to learn one product's response
16
+ // envelope to ask what screen a run is on.
17
+ //
18
+ // ── the gate ────────────────────────────────────────────────────────────
19
+ //
20
+ // Applied per route here rather than at the mount, so this router carries its
21
+ // own authorization wherever it is mounted — inside the workflow router at
22
+ // <mount>/flows, or by a host at a path of its own. See buildWorkflowRouter's
23
+ // note on the same `config.mtMembershipGate`.
24
+
25
+ var express = require('express');
26
+ var flows = require('./flows');
27
+
28
+ function noop(req, res, next) { next(); }
29
+
30
+ // One shape for every refusal: the status the service decided, and the body
31
+ // the rest of this package already answers errors with. `code` is what a
32
+ // client branches on — FLOW_NOT_FOUND, FLOW_PUBLISHED, RUN_NOT_WAITING — and
33
+ // `message` is the sentence somebody reads.
34
+ function sendError(res, err) {
35
+ res.status(err.status || 400).json({
36
+ message: err.message,
37
+ code: err.code || null,
38
+ details: err.details || []
39
+ });
40
+ }
41
+
42
+ function handle(fn) {
43
+ return async function(req, res) {
44
+ try {
45
+ var result = await fn(req);
46
+ if (result && result.created) return res.status(201).json(result.body);
47
+ res.json(result);
48
+ } catch (err) {
49
+ sendError(res, err);
50
+ }
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @param {object} [config]
56
+ * @param {Function} [config.mtMembershipGate] - the same gate the rest of this
57
+ * package's authenticated routes use. Omit to leave every route ungated,
58
+ * which is only ever right for local dev.
59
+ * @returns {import('express').Router}
60
+ */
61
+ function buildFlowsRouter(config) {
62
+ config = config || {};
63
+ var gate = config.mtMembershipGate || noop;
64
+ var router = express.Router();
65
+
66
+ // BEFORE /:key. A flow keyed "runs" would otherwise shadow these two, which
67
+ // is why normaliseKey reserves the word rather than leaving it to routing
68
+ // order to decide.
69
+ router.get('/runs/:runId', gate, handle(function(req) {
70
+ return flows.getRun(req.params.runId);
71
+ }));
72
+
73
+ // The browser never handles a resume key — see flows.submitRun. This is an
74
+ // ordinary gated route that looks the key up from the run it was given.
75
+ router.post('/runs/:runId/submit', gate, handle(function(req) {
76
+ return flows.submitRun(req.params.runId, req.body || {});
77
+ }));
78
+
79
+ router.get('/', gate, handle(function() {
80
+ return flows.listFlows();
81
+ }));
82
+
83
+ router.post('/', gate, handle(async function(req) {
84
+ return { created: true, body: await flows.createFlow(req.body || {}) };
85
+ }));
86
+
87
+ router.get('/:key', gate, handle(function(req) {
88
+ return flows.getFlow(req.params.key);
89
+ }));
90
+
91
+ router.put('/:key', gate, handle(function(req) {
92
+ return flows.putFlow(req.params.key, req.body || {});
93
+ }));
94
+
95
+ router.post('/:key/publish', gate, handle(function(req) {
96
+ return flows.publishFlow(req.params.key);
97
+ }));
98
+
99
+ router.post('/:key/runs', gate, handle(async function(req) {
100
+ return { created: true, body: await flows.startFlowRun(req.params.key, req.body || {}, req.user) };
101
+ }));
102
+
103
+ // ?mine=1 — started by whoever is asking. Anything else is every run of this
104
+ // flow that is still going, within the caller's own tenant.
105
+ router.get('/:key/runs', gate, handle(function(req) {
106
+ var mine = req.query && (req.query.mine === '1' || req.query.mine === 'true');
107
+ return flows.listRuns(req.params.key, { mine: mine, userId: req.user && req.user.id });
108
+ }));
109
+
110
+ return router;
111
+ }
112
+
113
+ module.exports = buildFlowsRouter;
package/lib/router.js ADDED
@@ -0,0 +1,260 @@
1
+ var express = require('express');
2
+ var { genericRoute } = require('@xeplr/base-apis');
3
+ // Both used ONLY by the builder's try-run route below, and both are the same
4
+ // calls the engine itself makes (see workflowRunner) — deliberately, so a
5
+ // try-run resolves references and validates input exactly the way a real run
6
+ // would, rather than approximating it.
7
+ var { runAction } = require('@xeplr/actions');
8
+ var { interpolateAll } = require('@xeplr/schema-handler');
9
+ var db = require('./db');
10
+ var actionCatalog = require('./actionCatalog');
11
+ var workflowRunner = require('./workflowRunner');
12
+ var flows = require('./flows');
13
+ var buildFlowsRouter = require('./flowsRouter');
14
+ var { exposedEnv } = require('./envExposed');
15
+
16
+ function noop(req, res, next) { next(); }
17
+
18
+ /**
19
+ * Build the workflow HTTP router. Pure config in, Express Router out —
20
+ * NEVER reads process.env FOR ITS CONFIGURATION and never requires
21
+ * @xeplr/auth directly, so the exact same router works whether
22
+ * orchestration/standalone.js built it for a solo process, or a host app's
23
+ * own registerWorkflow() call built it to mount into itself.
24
+ *
25
+ * The one process.env touch is inside the try-run handler: exposedEnv()
26
+ * resolves `{env.NAME}` bindings, and it is deliberately NOT lifted up here
27
+ * into config. It is per-request runtime DATA, identical embedded and
28
+ * standalone, and reading it at build time would freeze whatever the
29
+ * environment happened to be when the router was constructed. The invariant
30
+ * this comment protects is that no BEHAVIOUR differs between the two mounts,
31
+ * and that still holds.
32
+ *
33
+ * @param {object} [config]
34
+ * @param {Function} [config.mtMembershipGate] - middleware checking THIS
35
+ * product's own apis/menus grants (see
36
+ * migrations-auth/0001_workflow_access.sql). Distinct from basic
37
+ * authentication — by the time a request reaches here, something upstream
38
+ * (a host app's own middleware, or this package's own standalone
39
+ * orchestration) has already established WHO the request is from; this
40
+ * checks whether that identity is allowed to hit THIS specific route.
41
+ * Omit to leave every route ungated — fine for local, unauthenticated dev
42
+ * use, never for anything actually reachable by anyone else.
43
+ * @param {Function} [config.authMiddleware] - populates req.user; used only
44
+ * by GET /me as an "is auth actually wired" smoke check.
45
+ * @returns {import('express').Router}
46
+ */
47
+ function buildWorkflowRouter(config) {
48
+ config = config || {};
49
+ var gate = config.mtMembershipGate || noop;
50
+ var router = express.Router();
51
+
52
+ // BOUND to workflow's own connection, and resolved HERE rather than at
53
+ // require time — connectDb has run by the time registerWorkflow calls this,
54
+ // and a class captured before it would carry the process's global binding
55
+ // instead. See lib/db.js's model().
56
+ var Company = db.model('Company');
57
+ var Workspace = db.model('Workspace');
58
+ var Workflow = db.model('Workflow');
59
+ var WorkflowStep = db.model('WorkflowStep');
60
+
61
+ router.get('/', function(req, res) {
62
+ res.json({ service: 'api', status: 'running', name: 'xeplr-workflow-api' });
63
+ });
64
+
65
+ if (config.authMiddleware) {
66
+ router.get('/me', config.authMiddleware, function(req, res) {
67
+ res.json({ id: req.user.id, email: req.user.email, name: req.user.name, roles: req.user.roles || [] });
68
+ });
69
+ }
70
+
71
+ // ── the action catalog ───────────────────────────────────────────────────
72
+ //
73
+ // Served from the registry itself rather than from a list kept beside it —
74
+ // a step editor's form is generated from these schemas, so a second
75
+ // description of an action is a second thing to keep in step.
76
+ router.get('/actions', gate, function(req, res) {
77
+ res.json({ dataArray: actionCatalog.catalog() });
78
+ });
79
+
80
+ // ── tenancy ──────────────────────────────────────────────────────────────
81
+ //
82
+ // NO gate on /companies — Company opted out at the model level
83
+ // (multiTenant = false), and gating it would 403 the "pick a company"
84
+ // screen whenever a header is present but not yet authorized.
85
+ router.use('/companies', genericRoute({ key: 'company', model: Company }));
86
+ router.use('/workspaces', gate, genericRoute({ key: 'workspace', model: Workspace }));
87
+
88
+ // ── the workflow document ───────────────────────────────────────────────
89
+ //
90
+ // One POST /workflows/save writes the workflow and its whole step list in
91
+ // one transaction. GET /workflows/:id comes back with `steps` eager-loaded.
92
+ //
93
+ // BEFORE the genericRoute mount, and only on /save: a workflow of kind
94
+ // 'screens' is GENERATED from a design held in another app (see lib/flows.js
95
+ // and the /flows mount below), so an edit made here survives only until the
96
+ // next PUT /flows/:key and then disappears without a record. Reading one is
97
+ // untouched and so is deleting one — runs and history stay visible, and a
98
+ // flow nobody wants any more must still be removable from the list it is in.
99
+ router.post('/workflows/save', gate, flows.refuseScreensEdit);
100
+ router.use('/workflows', gate, genericRoute({
101
+ key: 'workflow',
102
+ model: Workflow,
103
+ children: [{ key: 'steps', model: WorkflowStep, foreignKey: 'workflowId' }]
104
+ }));
105
+
106
+ // `details` is passed through when the engine refuses on the workflow's own
107
+ // declared params — [{ field, message }], the same shape applySchema throws
108
+ // everywhere else. Without it the dialog can only print one sentence; with
109
+ // it, it can mark the field that is actually missing.
110
+ router.post('/workflows/:id/run', gate, async function(req, res) {
111
+ try {
112
+ var run = await workflowRunner.startRun(req.params.id, req.body || {}, { user: req.user });
113
+ res.json({ dataArray: [run] });
114
+ } catch (err) {
115
+ res.status(400).json({ message: err.message, code: err.code, details: err.details || [] });
116
+ }
117
+ });
118
+
119
+ // ── flows: a screen is a step ───────────────────────────────────────────
120
+ //
121
+ // A small facade over the same engine for an app that designs screens: it
122
+ // says "this screen, then that one if the answer was X", and never learns
123
+ // what a workflow, a wait step or a resume key is. See lib/flows.js.
124
+ //
125
+ // Mounted HERE, inside this router, so a host that already mounts
126
+ // registerWorkflow()'s router gets <mount>/flows with no further wiring.
127
+ // registerWorkflow ALSO returns it on its own (`flowsRouter`) for a host
128
+ // that would rather serve it from a path of its own — it is the same
129
+ // builder, gating itself, so both mounts behave identically.
130
+ router.use('/flows', buildFlowsRouter(config));
131
+
132
+ // ── email templates ─────────────────────────────────────────────────────
133
+ //
134
+ // Served FROM @xeplr/email, not reimplemented here: the templates belong to
135
+ // the email service, which is what auth and jobs also send through. This
136
+ // mount just makes them reachable from the workflow builder, so an
137
+ // email-send step can offer a dropdown of real template names instead of
138
+ // asking somebody to type one exactly right.
139
+ //
140
+ // Conditional because templates are optional: an install that never called
141
+ // initTemplates() serves no such routes rather than serving routes that
142
+ // always 500.
143
+ try {
144
+ var emailPkg = require('@xeplr/email');
145
+ if (emailPkg.templatesReady && emailPkg.templatesReady()) {
146
+ router.use(emailPkg.templatesRouter({ auth: gate }));
147
+ }
148
+ } catch (_) {
149
+ // @xeplr/email not installed — nothing to mount, and the email-send
150
+ // action says so specifically if a step names a template.
151
+ }
152
+
153
+ // ── filling in a step's SAMPLE OUTPUT ───────────────────────────────────
154
+ //
155
+ // Two ways, because they trade off differently and the builder offers both.
156
+ //
157
+ // The SAFE one: whatever this step actually returned the last time a real
158
+ // run reached it. No side effects at all — it is a read of history.
159
+ router.get('/workflows/:id/steps/:stepKey/last-output', gate, async function(req, res) {
160
+ try {
161
+ var runs = await db.model('WorkflowRun').query().where({ workflowId: req.params.id }).select('id');
162
+ if (!runs.length) return res.json({ dataArray: [] });
163
+ var last = await db.model('WorkflowStepRun').query()
164
+ .whereIn('runId', runs.map(function(r) { return r.id; }))
165
+ .where({ stepKey: req.params.stepKey })
166
+ // 'waiting' counts: a wait step's action has already run and produced
167
+ // its output by then — that is exactly the shape the builder wants.
168
+ .whereIn('status', ['success', 'waiting'])
169
+ .orderBy('recordCreatedDate', 'desc')
170
+ .first();
171
+ res.json({ dataArray: last ? [{ output: last.output || {}, at: last.recordCreatedDate }] : [] });
172
+ } catch (err) {
173
+ res.status(400).json({ message: err.message });
174
+ }
175
+ });
176
+
177
+ // The LIVE one: run the action for real, right now, with the values the
178
+ // builder currently has on screen (which may be unsaved — hence they come in
179
+ // the body rather than being read back off the step).
180
+ //
181
+ // THIS IS NOT A SIMULATION and there is nothing here that could make it one.
182
+ // runAction is the same call the engine makes, so email-send sends,
183
+ // email-delete expunges, db-push writes. The UI confirms before calling
184
+ // this; that confirmation is the only thing standing between a builder
185
+ // click and a real side effect, so it must not be removed on the grounds
186
+ // that it is "just a preview".
187
+ //
188
+ // References are interpolated against the OTHER steps' recorded sample
189
+ // outputs rather than a live context — there is no run here to have a
190
+ // context. So a value bound to {steps.x.output.id} resolves to whatever
191
+ // sample step x carries, which is the point: it is what makes a try-run
192
+ // produce a realistic result instead of posting the literal string
193
+ // "{steps.x.output.id}" to somebody's API.
194
+ router.post('/workflows/:id/steps/try', gate, async function(req, res) {
195
+ var body = req.body || {};
196
+ if (!body.actionName) return res.status(400).json({ message: 'actionName is required' });
197
+ try {
198
+ var steps = await db.model('WorkflowStep').query()
199
+ .where({ workflowId: req.params.id }).orderBy('position');
200
+
201
+ // Same `env` branch the real engine builds, for the same reason the
202
+ // sample outputs are here: a try-run that resolves a binding
203
+ // differently from the run is worse than no try-run at all.
204
+ var context = { params: body.params || {}, steps: {}, previous_step: {}, item: null, env: exposedEnv(), resumeKey: null };
205
+ steps.forEach(function(s) {
206
+ if (s.stepKey) context.steps[s.stepKey] = { output: s.sampleOutput || {} };
207
+ });
208
+ var selfIndex = steps.findIndex(function(s) { return s.stepKey === body.stepKey; });
209
+ var prev = selfIndex > 0 ? steps[selfIndex - 1] : null;
210
+ if (prev) context.previous_step = { output: prev.sampleOutput || {} };
211
+
212
+ var input = interpolateAll(body.values || {}, context);
213
+ var result = await runAction({ name: body.actionName, input: input });
214
+ res.json({
215
+ dataArray: [{
216
+ status: result.status,
217
+ output: result.output,
218
+ error: result.error,
219
+ durationMs: result.durationMs,
220
+ // Echoed back so the builder can show what it actually sent — a
221
+ // try-run that fails is usually a binding that resolved to
222
+ // something unexpected, and guessing at that is the slow way.
223
+ input: input
224
+ }]
225
+ });
226
+ } catch (err) {
227
+ res.status(400).json({ message: err.message });
228
+ }
229
+ });
230
+
231
+ // Deliberately NOT gated — see workflowRunner.resumeByKey's own doc
232
+ // comment. Whatever mounts this router (this package's own standalone app,
233
+ // or a host's) must exempt "<mountPath>/public/*" from its OWN
234
+ // authentication middleware too, the same way orchestration/standalone.js
235
+ // does for itself — by the time a request reaches this router, any
236
+ // app-level auth gate has already run and this route can no longer opt out
237
+ // of it from in here.
238
+ router.post('/public/resume/:key', async function(req, res) {
239
+ try {
240
+ var body = req.body || {};
241
+ // `status: 'failed'` resolves the parked step as FAILED rather than
242
+ // succeeded — see resumeByKey. Absent means success, which is what every
243
+ // caller that predates this means: an approval click, a confirmation
244
+ // link. @xeplr/jobs sends it when a job ended any way other than
245
+ // completing, so a chain does not walk on past a movement that never
246
+ // happened.
247
+ var result = await workflowRunner.resumeByKey(req.params.key, body.output, {
248
+ status: body.status,
249
+ error: body.error
250
+ });
251
+ res.json({ dataArray: [result] });
252
+ } catch (err) {
253
+ res.status(400).json({ message: err.message });
254
+ }
255
+ });
256
+
257
+ return router;
258
+ }
259
+
260
+ module.exports = buildWorkflowRouter;