@peopl-health/nexus 5.44.0-dev.5113 → 5.44.0-dev.5116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,38 @@ const bus = nexus.getMessaging().getEventBus();
116
116
  bus.on('message:received', (m) => console.log('rx', m.id));
117
117
  ```
118
118
 
119
+ ## Workflows (Optional)
120
+
121
+ Register a named unit of work, deduplicated by a key you derive from the trigger.
122
+
123
+ ```js
124
+ const runner = nexus.getMessaging().getWorkflowRunner();
125
+
126
+ await runner.register({
127
+ kind: 'triage-projection',
128
+ dedupeKey: (trigger) => String(trigger.submissionId),
129
+ prepare: async (trigger, checkpoints, save) => { /* ... */ },
130
+ retrySchedule: [5, 10, 20, 40], // minutes; omit for fire-and-forget
131
+ });
132
+
133
+ await runner.enqueue('triage-projection', { submissionId });
134
+ ```
135
+
136
+ With `retrySchedule` the trigger and a checkpoint journal are stored in Mongo and the schedule runs
137
+ on an in-process timer — deferred workflows never touch the queue adapter, so they add no Redis load.
138
+ `prepare` resumes from `checkpoints`; call `save(patch)` before each external call so a retry does
139
+ not repeat completed work. Throw an error carrying `permanent: true` to give up immediately instead
140
+ of walking the schedule; `addRecord` and `addLinkedRecord` tag their own permanent 4xx errors that
141
+ way. The read and update helpers (`getRecords`, `getRecordByFilter`, `updateRecordByFilter`) return
142
+ `undefined` on failure instead of throwing, so `prepare` must check their results and throw itself —
143
+ otherwise an outage reads as success and the work is marked done.
144
+
145
+ Recovery is automatic: registering sweeps for work left behind by a previous process, and the runner
146
+ keeps sweeping so work whose worker died mid-`prepare` is reclaimed once its claim goes stale.
147
+ `enqueue` on an abandoned key revives it, keeping its checkpoints. Work is retained for 24 hours
148
+ after its last update and then expires — the dedupe key expires with it, so idempotency has the
149
+ same horizon as retention.
150
+
119
151
  ## Assistants (Optional)
120
152
 
121
153
  Register assistant classes and (optionally) a custom resolver. OpenAI is supported via `llm: 'openai'`.
@@ -32,6 +32,7 @@ const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
32
32
  const { createQueueAdapter } = require('../queue');
33
33
  const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
34
34
  const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
35
+ const { WorkflowRunner } = require('./WorkflowRunner');
35
36
 
36
37
  const { PhiProcessor } = require('./PhiProcessor');
37
38
 
@@ -116,6 +117,8 @@ class NexusMessaging {
116
117
  queueAdapter: this.queueAdapter,
117
118
  });
118
119
 
120
+ this.workflowRunner = new WorkflowRunner({ queueAdapter: this.queueAdapter });
121
+
119
122
  this.phiProcessor = new PhiProcessor({
120
123
  encode: config.phi?.encode || false,
121
124
  ner: config.phi?.ner || null,
@@ -286,6 +289,7 @@ class NexusMessaging {
286
289
  getAssistantProcessor() { return this.assistantProcessor; }
287
290
  getLlmMonitor() { return this.llmMonitor; }
288
291
  getQueueAdapter() { return this.queueAdapter; }
292
+ getWorkflowRunner() { return this.workflowRunner; }
289
293
  getPhiProcessor() { return this.phiProcessor; }
290
294
  isConnected() { return this.provider?.getConnectionStatus() ?? false; }
291
295
  isProcessing(chatId) { return this.batchingManager.isProcessing(chatId); }
@@ -717,6 +721,7 @@ class NexusMessaging {
717
721
  async disconnect() {
718
722
  this.queueReconciliation?.stop();
719
723
  if (this.provider) await this.provider.disconnect();
724
+ if (this.workflowRunner) this.workflowRunner.stop();
720
725
  if (this.queueAdapter) await this.queueAdapter.shutdown();
721
726
  this.events.removeAllListeners();
722
727
  }
@@ -0,0 +1,252 @@
1
+ const { logger } = require('../utils/logger');
2
+
3
+ const { DeferredWork, RETENTION_MS } = require('../models/deferredWorkModel');
4
+
5
+ const STALE_CLAIM_MS = 5 * 60 * 1000;
6
+ const MINUTE_MS = 60 * 1000;
7
+ const INDEX_TIMEOUT_MS = 1000;
8
+ const SWEEP_INTERVAL_MS = 60 * 1000;
9
+
10
+ let indexesReady = null;
11
+
12
+ function ensureIndexes() {
13
+ if (!indexesReady) {
14
+ indexesReady = DeferredWork.init().catch((error) => {
15
+ indexesReady = null;
16
+ logger.error('[WorkflowRunner] Index build failed, dedupe and retention are not guaranteed', { error: error.message });
17
+ });
18
+ }
19
+ return indexesReady;
20
+ }
21
+
22
+ function withIndexTimeout(promise) {
23
+ let timer;
24
+ const timeout = new Promise((resolve) => {
25
+ timer = setTimeout(resolve, INDEX_TIMEOUT_MS);
26
+ timer.unref();
27
+ });
28
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
29
+ }
30
+
31
+ class WorkflowRunner {
32
+ constructor({ queueAdapter, sweepIntervalMs = SWEEP_INTERVAL_MS } = {}) {
33
+ if (!queueAdapter) throw new Error('WorkflowRunner requires a queueAdapter');
34
+ this.queueAdapter = queueAdapter;
35
+ this.sweepIntervalMs = sweepIntervalMs;
36
+ this.sweepTimer = null;
37
+ this.timers = new Map();
38
+ this.workflows = new Map();
39
+ }
40
+
41
+ async register(workflow = {}) {
42
+ if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
43
+ if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
44
+ if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
45
+ if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
46
+ if (this.workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
47
+ if (workflow.retrySchedule !== undefined) {
48
+ if (!Array.isArray(workflow.retrySchedule) || !workflow.retrySchedule.length) {
49
+ throw new Error(`workflow '${workflow.kind}' requires a non-empty retrySchedule array`);
50
+ }
51
+ if (workflow.retrySchedule.some((minutes) => !Number.isFinite(minutes) || minutes <= 0)) {
52
+ throw new Error(`workflow '${workflow.kind}' requires retrySchedule entries to be positive minutes`);
53
+ }
54
+ if (workflow.retrySchedule.some((minutes) => minutes * MINUTE_MS >= RETENTION_MS)) {
55
+ throw new Error(`workflow '${workflow.kind}' requires retrySchedule entries under the ${RETENTION_MS / MINUTE_MS}m retention, or the record expires before the attempt fires`);
56
+ }
57
+ }
58
+
59
+ this.workflows.set(workflow.kind, workflow);
60
+ if (workflow.retrySchedule) {
61
+ await this.sweep([workflow.kind]).catch((error) => {
62
+ logger.error('[WorkflowRunner] Sweep on register failed', { kind: workflow.kind, error: error.message });
63
+ });
64
+ this._startSweeping();
65
+ return workflow;
66
+ }
67
+
68
+ try {
69
+ await this.queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
70
+ } catch (error) {
71
+ this.workflows.delete(workflow.kind);
72
+ throw error;
73
+ }
74
+ return workflow;
75
+ }
76
+
77
+ enqueue(kind, trigger, options = {}) {
78
+ const workflow = this.workflows.get(kind);
79
+ if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
80
+ const dedupeKey = workflow.dedupeKey(trigger);
81
+ if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
82
+ throw new Error(`workflow '${kind}' produced an empty dedupe key`);
83
+ }
84
+ if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
85
+ if (!workflow.retrySchedule) {
86
+ return this.queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
87
+ }
88
+ return this._armDeferred(workflow, trigger, dedupeKey);
89
+ }
90
+
91
+ async sweep(only = null) {
92
+ const deferredKinds = [...this.workflows.values()].filter((w) => w.retrySchedule).map((w) => w.kind);
93
+ const kinds = only ? deferredKinds.filter((kind) => only.includes(kind)) : deferredKinds;
94
+ if (!kinds.length) return [];
95
+
96
+ const orphaned = await DeferredWork.find({
97
+ kind: { $in: kinds },
98
+ $or: [
99
+ { status: 'pending' },
100
+ { status: 'processing', claimedAt: { $lt: new Date(Date.now() - STALE_CLAIM_MS) } }
101
+ ]
102
+ });
103
+
104
+ const rearmed = orphaned.map((work) => this._arm(
105
+ this.workflows.get(work.kind),
106
+ work._id,
107
+ this._delayUntilDue(work),
108
+ ));
109
+ if (rearmed.length) logger.info('[WorkflowRunner] Swept', { kinds, rearmed: rearmed.length });
110
+ return rearmed;
111
+ }
112
+
113
+ _startSweeping() {
114
+ if (this.sweepTimer || !this.sweepIntervalMs) return;
115
+ this.sweepTimer = setInterval(() => {
116
+ this.sweep().catch((error) => logger.error('[WorkflowRunner] Periodic sweep failed', { error: error.message }));
117
+ }, this.sweepIntervalMs);
118
+ this.sweepTimer.unref();
119
+ }
120
+
121
+ stop() {
122
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
123
+ this.sweepTimer = null;
124
+ for (const timer of this.timers.values()) clearTimeout(timer);
125
+ this.timers.clear();
126
+ }
127
+
128
+ async _armDeferred(workflow, trigger, dedupeKey) {
129
+ const { kind } = workflow;
130
+ await withIndexTimeout(ensureIndexes());
131
+ const work = await DeferredWork.findOne({ kind, dedupeKey })
132
+ || await DeferredWork.create({ kind, dedupeKey, trigger }).catch(async (error) => {
133
+ if (error.code !== 11000) throw error;
134
+ return await DeferredWork.findOne({ kind, dedupeKey });
135
+ });
136
+ if (work.status === 'abandoned') {
137
+ const { modifiedCount } = await DeferredWork.updateOne(
138
+ { _id: work._id, status: 'abandoned' },
139
+ { $set: { status: 'pending', attempt: 0, nextAttemptAt: new Date(), lastError: null, claimedAt: null } }
140
+ );
141
+ if (!modifiedCount) return null;
142
+ logger.info('[WorkflowRunner] Re-arming abandoned work', { kind, dedupeKey });
143
+ return this._arm(workflow, work._id, 0);
144
+ }
145
+ if (work.status !== 'pending') {
146
+ logger.info('[WorkflowRunner] Skipping, work is in flight or complete', { kind, dedupeKey, status: work.status });
147
+ return null;
148
+ }
149
+ return this._arm(workflow, work._id, this._delayUntilDue(work));
150
+ }
151
+
152
+ _delayUntilDue(work) {
153
+ return Math.max(0, new Date(work.nextAttemptAt).getTime() - Date.now());
154
+ }
155
+
156
+ _arm(workflow, workId, delayMs) {
157
+ const id = String(workId);
158
+ clearTimeout(this.timers.get(id));
159
+ const timer = setTimeout(() => {
160
+ this.timers.delete(id);
161
+ this._runDeferred(workflow, id).catch((error) => {
162
+ logger.error('[WorkflowRunner] Deferred run failed', { kind: workflow.kind, deferredWorkId: id, error: error.message });
163
+ });
164
+ }, delayMs);
165
+ timer.unref();
166
+ this.timers.set(id, timer);
167
+ return id;
168
+ }
169
+
170
+ async _runDeferred(workflow, deferredWorkId) {
171
+ const claimedAt = new Date();
172
+ const prior = await DeferredWork.findOneAndUpdate(
173
+ {
174
+ _id: deferredWorkId,
175
+ $or: [
176
+ { status: 'pending', nextAttemptAt: { $lte: claimedAt } },
177
+ { status: 'processing', claimedAt: { $lt: new Date(claimedAt.getTime() - STALE_CLAIM_MS) } }
178
+ ]
179
+ },
180
+ { $set: { status: 'processing', claimedAt } },
181
+ { new: false }
182
+ );
183
+ if (!prior) {
184
+ logger.info('[WorkflowRunner] Claimed elsewhere or not due', { kind: workflow.kind, deferredWorkId });
185
+ return { claimed: false };
186
+ }
187
+
188
+ const reclaimed = prior.status === 'processing';
189
+ const work = { ...prior.toObject(), attempt: prior.attempt + (reclaimed ? 1 : 0) };
190
+ if (reclaimed) await this._write(work, claimedAt, { attempt: work.attempt });
191
+
192
+ const checkpoints = { ...(work.checkpoints || {}) };
193
+ const save = async (patch) => {
194
+ Object.assign(checkpoints, patch);
195
+ const $set = {};
196
+ for (const [key, value] of Object.entries(patch || {})) $set[`checkpoints.${key}`] = value;
197
+ if (Object.keys($set).length) await this._write(work, claimedAt, $set);
198
+ };
199
+
200
+ try {
201
+ await workflow.prepare(work.trigger, checkpoints, save);
202
+ } catch (error) {
203
+ return await this._rescheduleOrAbandon(workflow, work, claimedAt, error);
204
+ }
205
+ await this._write(work, claimedAt, { status: 'done', claimedAt: null });
206
+ return { status: 'done' };
207
+ }
208
+
209
+ async _write(work, claimedAt, $set) {
210
+ const { modifiedCount } = await DeferredWork.updateOne({ _id: work._id, claimedAt }, { $set });
211
+ if (!modifiedCount) logger.warn('[WorkflowRunner] Claim lost, write discarded', { deferredWorkId: String(work._id) });
212
+ return modifiedCount > 0;
213
+ }
214
+
215
+ async _rescheduleOrAbandon(workflow, work, claimedAt, error) {
216
+ const delayMinutes = workflow.retrySchedule[work.attempt];
217
+ if (error.permanent === true || delayMinutes === undefined) {
218
+ await this._write(work, claimedAt, { status: 'abandoned', lastError: error.message, claimedAt: null });
219
+ logger.error('[WorkflowRunner] Abandoned', {
220
+ kind: workflow.kind,
221
+ dedupeKey: work.dedupeKey,
222
+ attempt: work.attempt,
223
+ permanent: error.permanent === true,
224
+ error: error.message
225
+ });
226
+ return { status: 'abandoned' };
227
+ }
228
+
229
+ const attempt = work.attempt + 1;
230
+ const delayMs = delayMinutes * MINUTE_MS;
231
+ const kept = await this._write(work, claimedAt, {
232
+ status: 'pending',
233
+ attempt,
234
+ nextAttemptAt: new Date(Date.now() + delayMs),
235
+ lastError: error.message,
236
+ claimedAt: null
237
+ });
238
+ if (!kept) return { claimed: false };
239
+
240
+ logger.warn('[WorkflowRunner] Rescheduled', {
241
+ kind: workflow.kind,
242
+ dedupeKey: work.dedupeKey,
243
+ attempt,
244
+ delayMinutes,
245
+ error: error.message
246
+ });
247
+ this._arm(workflow, work._id, delayMs);
248
+ return { status: 'pending', attempt };
249
+ }
250
+ }
251
+
252
+ module.exports = { WorkflowRunner };
package/lib/index.d.ts CHANGED
@@ -242,6 +242,7 @@ declare module '@peopl-health/nexus' {
242
242
  getAssistantProcessor(): AssistantProcessor;
243
243
  getLlmMonitor(): { start(options?: { scheduleDaily?: boolean }): Promise<{ enabled: boolean }> } | null;
244
244
  initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
245
+ getWorkflowRunner(): WorkflowRunner;
245
246
  processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string }): Promise<void>;
246
247
  processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<void>;
247
248
  isConnected(): boolean;
@@ -454,16 +455,17 @@ declare module '@peopl-health/nexus' {
454
455
  export interface Workflow {
455
456
  kind: string;
456
457
  dedupeKey: (trigger: any) => string;
457
- prepare: (trigger: any) => Promise<any>;
458
+ prepare: (trigger: any, checkpoints?: any, save?: (patch: any) => Promise<void>) => Promise<any>;
459
+ retrySchedule?: number[];
458
460
  }
459
461
 
460
462
  export interface WorkflowRunner {
461
463
  register(workflow: Workflow): Promise<Workflow>;
462
- enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string>;
464
+ enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string | null>;
465
+ sweep(only?: string[]): Promise<string[]>;
466
+ stop(): void;
463
467
  }
464
468
 
465
- export function createWorkflowRunner(options: { queueAdapter: QueueAdapter }): WorkflowRunner;
466
-
467
469
  // Memory System
468
470
  export interface PatientMemoryDocument {
469
471
  _id: any;
package/lib/index.js CHANGED
@@ -27,7 +27,6 @@ const { BaileysProvider } = require('./adapters/BaileysProvider');
27
27
  const { setPreprocessingHandler, hasPreprocessingHandler, invokePreprocessingHandler } = require('./services/preprocessingService');
28
28
  const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
29
29
  const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
30
- const { createWorkflowRunner } = require('./core/workflowRunner');
31
30
  const routes = require('./routes');
32
31
  const { resetAll } = require('./config/lifecycle');
33
32
  const { EvalProvider } = require('./eval/EvalProvider');
@@ -221,7 +220,6 @@ class Nexus {
221
220
  }
222
221
 
223
222
  module.exports = {
224
- createWorkflowRunner,
225
223
  Nexus,
226
224
  TwilioProvider,
227
225
  BaileysProvider,
@@ -0,0 +1,24 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const STATUS_VALUES = ['pending', 'processing', 'done', 'abandoned'];
4
+ const RETENTION_MS = 24 * 60 * 60 * 1000;
5
+
6
+ const deferredWorkSchema = new mongoose.Schema({
7
+ kind: { type: String, required: true },
8
+ dedupeKey: { type: String, required: true },
9
+ trigger: { type: mongoose.Schema.Types.Mixed, default: null },
10
+ checkpoints: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
11
+ status: { type: String, enum: STATUS_VALUES, default: 'pending' },
12
+ attempt: { type: Number, default: 0 },
13
+ nextAttemptAt: { type: Date, default: Date.now },
14
+ claimedAt: { type: Date, default: null },
15
+ lastError: { type: String, default: null }
16
+ }, { timestamps: true });
17
+
18
+ deferredWorkSchema.index({ kind: 1, dedupeKey: 1 }, { unique: true, name: 'dedupe_idx' });
19
+ deferredWorkSchema.index({ status: 1, nextAttemptAt: 1 }, { name: 'due_idx' });
20
+ deferredWorkSchema.index({ updatedAt: 1 }, { expireAfterSeconds: RETENTION_MS / 1000, name: 'ttl_idx' });
21
+
22
+ const DeferredWork = mongoose.model('DeferredWork', deferredWorkSchema);
23
+
24
+ module.exports = { DeferredWork, RETENTION_MS };
@@ -11,6 +11,11 @@ const TRANSIENT_ERROR_CODES = ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTF
11
11
  const MAX_ATTEMPTS = 3;
12
12
  const RETRY_BASE_DELAY_MS = 1000;
13
13
 
14
+ function isPermanentAirtableError(error) {
15
+ const status = error?.statusCode;
16
+ return typeof status === 'number' && status >= 400 && status < 500 && status !== 429;
17
+ }
18
+
14
19
  let isEvalMode = false;
15
20
 
16
21
  function setEvalMode(enabled) {
@@ -38,6 +43,7 @@ async function withAirtableRetry(operation, label, { retryNetworkErrors = true }
38
43
  const isTransient = TRANSIENT_STATUS_CODES.includes(error.statusCode)
39
44
  || (retryNetworkErrors && isTransientNetworkError(error));
40
45
  if (!isTransient || attempt === MAX_ATTEMPTS) {
46
+ if (isPermanentAirtableError(error)) error.permanent = true;
41
47
  throw error;
42
48
  }
43
49
  const baseDelay = error.statusCode === RATE_LIMITED_STATUS ? RATE_LIMIT_DELAY_MS : RETRY_BASE_DELAY_MS;
@@ -169,6 +175,7 @@ async function addLinkedRecord(baseID, targetTable, fields, linkConfig, context
169
175
 
170
176
  module.exports = {
171
177
  setEvalMode,
178
+ isPermanentAirtableError,
172
179
  addRecord,
173
180
  getRecords,
174
181
  getRecordByFilter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.44.0-dev.5113",
3
+ "version": "5.44.0-dev.5116",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -1,35 +0,0 @@
1
- function createWorkflowRunner({ queueAdapter } = {}) {
2
- if (!queueAdapter) throw new Error('createWorkflowRunner requires a queueAdapter');
3
- const workflows = new Map();
4
-
5
- async function register(workflow = {}) {
6
- if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
7
- if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
8
- if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
9
- if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
10
- if (workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
11
- workflows.set(workflow.kind, workflow);
12
- try {
13
- await queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
14
- } catch (error) {
15
- workflows.delete(workflow.kind);
16
- throw error;
17
- }
18
- return workflow;
19
- }
20
-
21
- function enqueue(kind, trigger, options = {}) {
22
- const workflow = workflows.get(kind);
23
- if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
24
- const dedupeKey = workflow.dedupeKey(trigger);
25
- if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
26
- throw new Error(`workflow '${kind}' produced an empty dedupe key`);
27
- }
28
- if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
29
- return queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
30
- }
31
-
32
- return { register, enqueue };
33
- }
34
-
35
- module.exports = { createWorkflowRunner };