@peopl-health/nexus 5.54.0-dev.7991 → 5.54.0-dev.7997

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; the Airtable helpers tag their own permanent 4xx errors that way, in
141
+ `withAirtableRetry`, which all five of them route through. They rethrow on failure too, so an
142
+ Airtable outage reaches `prepare` as an exception and walks the retry schedule on its own rather
143
+ than reading as success.
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'`.
@@ -0,0 +1,257 @@
1
+ const { describeThrown } = require('../utils/errorUtils');
2
+ const { logger } = require('../utils/logger');
3
+
4
+ const { DeferredWork, RETENTION_MS } = require('../models/deferredWorkModel');
5
+
6
+ const STALE_CLAIM_MS = 5 * 60 * 1000;
7
+ const MINUTE_MS = 60 * 1000;
8
+ const INDEX_TIMEOUT_MS = 1000;
9
+ const SWEEP_INTERVAL_MS = 60 * 1000;
10
+
11
+ let indexesReady = null;
12
+
13
+ function ensureIndexes() {
14
+ if (!indexesReady) {
15
+ indexesReady = DeferredWork.init().catch((error) => {
16
+ indexesReady = null;
17
+ logger.error('[WorkflowRunner] Index build failed, dedupe and retention are not guaranteed', { error: error.message });
18
+ });
19
+ }
20
+ return indexesReady;
21
+ }
22
+
23
+ function withIndexTimeout(promise) {
24
+ let timer;
25
+ const timeout = new Promise((resolve) => {
26
+ timer = setTimeout(resolve, INDEX_TIMEOUT_MS);
27
+ timer.unref();
28
+ });
29
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
30
+ }
31
+
32
+ class WorkflowRunner {
33
+ constructor({ queueAdapter, sweepIntervalMs = SWEEP_INTERVAL_MS } = {}) {
34
+ if (!queueAdapter) throw new Error('WorkflowRunner requires a queueAdapter');
35
+ this.queueAdapter = queueAdapter;
36
+ this.sweepIntervalMs = sweepIntervalMs;
37
+ this.sweepTimer = null;
38
+ this.timers = new Map();
39
+ this.workflows = new Map();
40
+ this.stopped = false;
41
+ }
42
+
43
+ async register(workflow = {}) {
44
+ if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
45
+ if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
46
+ if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
47
+ if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
48
+ if (this.workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
49
+ if (workflow.retrySchedule !== undefined) {
50
+ if (!Array.isArray(workflow.retrySchedule) || !workflow.retrySchedule.length) {
51
+ throw new Error(`workflow '${workflow.kind}' requires a non-empty retrySchedule array`);
52
+ }
53
+ if (workflow.retrySchedule.some((minutes) => !Number.isFinite(minutes) || minutes <= 0)) {
54
+ throw new Error(`workflow '${workflow.kind}' requires retrySchedule entries to be positive minutes`);
55
+ }
56
+ if (workflow.retrySchedule.some((minutes) => minutes * MINUTE_MS >= RETENTION_MS)) {
57
+ 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`);
58
+ }
59
+ }
60
+
61
+ this.workflows.set(workflow.kind, workflow);
62
+ if (workflow.retrySchedule) {
63
+ await this.sweep([workflow.kind]).catch((error) => {
64
+ logger.error('[WorkflowRunner] Sweep on register failed', { kind: workflow.kind, error: error.message });
65
+ });
66
+ this._startSweeping();
67
+ return workflow;
68
+ }
69
+
70
+ try {
71
+ await this.queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
72
+ } catch (error) {
73
+ this.workflows.delete(workflow.kind);
74
+ throw error;
75
+ }
76
+ return workflow;
77
+ }
78
+
79
+ enqueue(kind, trigger, options = {}) {
80
+ const workflow = this.workflows.get(kind);
81
+ if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
82
+ const dedupeKey = workflow.dedupeKey(trigger);
83
+ if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
84
+ throw new Error(`workflow '${kind}' produced an empty dedupe key`);
85
+ }
86
+ if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
87
+ if (!workflow.retrySchedule) {
88
+ return this.queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
89
+ }
90
+ return this._armDeferred(workflow, trigger, dedupeKey);
91
+ }
92
+
93
+ async sweep(only = null) {
94
+ const deferredKinds = [...this.workflows.values()].filter((w) => w.retrySchedule).map((w) => w.kind);
95
+ const kinds = only ? deferredKinds.filter((kind) => only.includes(kind)) : deferredKinds;
96
+ if (!kinds.length) return [];
97
+
98
+ const orphaned = await DeferredWork.find({
99
+ kind: { $in: kinds },
100
+ $or: [
101
+ { status: 'pending' },
102
+ { status: 'processing', claimedAt: { $lt: new Date(Date.now() - STALE_CLAIM_MS) } }
103
+ ]
104
+ });
105
+
106
+ const rearmed = orphaned.map((work) => this._arm(
107
+ this.workflows.get(work.kind),
108
+ work._id,
109
+ this._delayUntilDue(work),
110
+ ));
111
+ if (rearmed.length) logger.info('[WorkflowRunner] Swept', { kinds, rearmed: rearmed.length });
112
+ return rearmed;
113
+ }
114
+
115
+ _startSweeping() {
116
+ if (this.stopped || this.sweepTimer || !this.sweepIntervalMs) return;
117
+ this.sweepTimer = setInterval(() => {
118
+ this.sweep().catch((error) => logger.error('[WorkflowRunner] Periodic sweep failed', { error: error.message }));
119
+ }, this.sweepIntervalMs);
120
+ this.sweepTimer.unref();
121
+ }
122
+
123
+ stop() {
124
+ this.stopped = true;
125
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
126
+ this.sweepTimer = null;
127
+ for (const timer of this.timers.values()) clearTimeout(timer);
128
+ this.timers.clear();
129
+ }
130
+
131
+ async _armDeferred(workflow, trigger, dedupeKey) {
132
+ const { kind } = workflow;
133
+ await withIndexTimeout(ensureIndexes());
134
+ const work = await DeferredWork.findOne({ kind, dedupeKey })
135
+ || await DeferredWork.create({ kind, dedupeKey, trigger }).catch(async (error) => {
136
+ if (error.code !== 11000) throw error;
137
+ return await DeferredWork.findOne({ kind, dedupeKey });
138
+ });
139
+ if (work.status === 'abandoned') {
140
+ const { modifiedCount } = await DeferredWork.updateOne(
141
+ { _id: work._id, status: 'abandoned' },
142
+ { $set: { status: 'pending', attempt: 0, nextAttemptAt: new Date(), lastError: null, claimedAt: null } }
143
+ );
144
+ if (!modifiedCount) return null;
145
+ logger.info('[WorkflowRunner] Re-arming abandoned work', { kind, dedupeKey });
146
+ return this._arm(workflow, work._id, 0);
147
+ }
148
+ if (work.status !== 'pending') {
149
+ logger.info('[WorkflowRunner] Skipping, work is in flight or complete', { kind, dedupeKey, status: work.status });
150
+ return null;
151
+ }
152
+ return this._arm(workflow, work._id, this._delayUntilDue(work));
153
+ }
154
+
155
+ _delayUntilDue(work) {
156
+ return Math.max(0, new Date(work.nextAttemptAt).getTime() - Date.now());
157
+ }
158
+
159
+ _arm(workflow, workId, delayMs) {
160
+ const id = String(workId);
161
+ if (this.stopped) return id;
162
+ clearTimeout(this.timers.get(id));
163
+ const timer = setTimeout(() => {
164
+ this.timers.delete(id);
165
+ this._runDeferred(workflow, id).catch((error) => {
166
+ logger.error('[WorkflowRunner] Deferred run failed', { kind: workflow.kind, deferredWorkId: id, error: error.message });
167
+ });
168
+ }, delayMs);
169
+ timer.unref();
170
+ this.timers.set(id, timer);
171
+ return id;
172
+ }
173
+
174
+ async _runDeferred(workflow, deferredWorkId) {
175
+ const claimedAt = new Date();
176
+ const prior = await DeferredWork.findOneAndUpdate(
177
+ {
178
+ _id: deferredWorkId,
179
+ $or: [
180
+ { status: 'pending', nextAttemptAt: { $lte: claimedAt } },
181
+ { status: 'processing', claimedAt: { $lt: new Date(claimedAt.getTime() - STALE_CLAIM_MS) } }
182
+ ]
183
+ },
184
+ { $set: { status: 'processing', claimedAt } },
185
+ { new: false }
186
+ );
187
+ if (!prior) {
188
+ logger.info('[WorkflowRunner] Claimed elsewhere or not due', { kind: workflow.kind, deferredWorkId });
189
+ return { claimed: false };
190
+ }
191
+
192
+ const reclaimed = prior.status === 'processing';
193
+ const work = { ...prior.toObject(), attempt: prior.attempt + (reclaimed ? 1 : 0) };
194
+ if (reclaimed) await this._write(work, claimedAt, { attempt: work.attempt });
195
+
196
+ const checkpoints = { ...(work.checkpoints || {}) };
197
+ const save = async (patch) => {
198
+ Object.assign(checkpoints, patch);
199
+ const $set = {};
200
+ for (const [key, value] of Object.entries(patch || {})) $set[`checkpoints.${key}`] = value;
201
+ if (Object.keys($set).length) await this._write(work, claimedAt, $set);
202
+ };
203
+
204
+ try {
205
+ await workflow.prepare(work.trigger, checkpoints, save);
206
+ } catch (error) {
207
+ return await this._rescheduleOrAbandon(workflow, work, claimedAt, error);
208
+ }
209
+ await this._write(work, claimedAt, { status: 'done', claimedAt: null });
210
+ return { status: 'done' };
211
+ }
212
+
213
+ async _write(work, claimedAt, $set) {
214
+ const { matchedCount } = await DeferredWork.updateOne({ _id: work._id, claimedAt }, { $set });
215
+ if (!matchedCount) logger.warn('[WorkflowRunner] Claim lost, write discarded', { deferredWorkId: String(work._id) });
216
+ return matchedCount > 0;
217
+ }
218
+
219
+ async _rescheduleOrAbandon(workflow, work, claimedAt, error) {
220
+ const lastError = describeThrown(error);
221
+ const delayMinutes = workflow.retrySchedule[work.attempt];
222
+ if (error?.permanent === true || delayMinutes === undefined) {
223
+ await this._write(work, claimedAt, { status: 'abandoned', lastError, claimedAt: null });
224
+ logger.error('[WorkflowRunner] Abandoned', {
225
+ kind: workflow.kind,
226
+ dedupeKey: work.dedupeKey,
227
+ attempt: work.attempt,
228
+ permanent: error?.permanent === true,
229
+ error: lastError
230
+ });
231
+ return { status: 'abandoned' };
232
+ }
233
+
234
+ const attempt = work.attempt + 1;
235
+ const delayMs = delayMinutes * MINUTE_MS;
236
+ const kept = await this._write(work, claimedAt, {
237
+ status: 'pending',
238
+ attempt,
239
+ nextAttemptAt: new Date(Date.now() + delayMs),
240
+ lastError,
241
+ claimedAt: null
242
+ });
243
+ if (!kept) return { claimed: false };
244
+
245
+ logger.warn('[WorkflowRunner] Rescheduled', {
246
+ kind: workflow.kind,
247
+ dedupeKey: work.dedupeKey,
248
+ attempt,
249
+ delayMinutes,
250
+ error: lastError
251
+ });
252
+ this._arm(workflow, work._id, delayMs);
253
+ return { status: 'pending', attempt };
254
+ }
255
+ }
256
+
257
+ module.exports = { WorkflowRunner };
@@ -46,6 +46,7 @@ const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
46
46
  const { createQueueAdapter } = require('../queue');
47
47
  const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
48
48
  const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
49
+ const { WorkflowRunner } = require('./DeferredWorkRunner');
49
50
  const { invokeInteractiveRoute } = require('./interactiveRouteService');
50
51
 
51
52
  const { PhiProcessor } = require('./PhiProcessor');
@@ -135,6 +136,8 @@ class NexusMessaging {
135
136
  queueAdapter: this.queueAdapter,
136
137
  });
137
138
 
139
+ this.workflowRunner = new WorkflowRunner({ queueAdapter: this.queueAdapter });
140
+
138
141
  this.phiProcessor = new PhiProcessor({
139
142
  encode: config.phi?.encode || false,
140
143
  ner: config.phi?.ner || null,
@@ -319,6 +322,7 @@ class NexusMessaging {
319
322
  getAssistantProcessor() { return this.assistantProcessor; }
320
323
  getLlmMonitor() { return this.llmMonitor; }
321
324
  getQueueAdapter() { return this.queueAdapter; }
325
+ getWorkflowRunner() { return this.workflowRunner; }
322
326
  getPhiProcessor() { return this.phiProcessor; }
323
327
  isConnected() { return this.provider?.getConnectionStatus() ?? false; }
324
328
  isProcessing(chatId) { return this.batchingManager.isProcessing(chatId); }
@@ -1098,6 +1102,8 @@ class NexusMessaging {
1098
1102
  await withTimeout(this._reconciliationStopped, teardownMs, 'queueReconciliation.stop')
1099
1103
  .catch(error => logger.warn('[NexusMessaging] Reconciliation sweep did not settle in time', { error: error.message }));
1100
1104
 
1105
+ if (this.workflowRunner) this.workflowRunner.stop();
1106
+
1101
1107
  if (this.queueAdapter) {
1102
1108
  await withTimeout(this.queueAdapter.shutdown(), teardownMs, 'queueAdapter.shutdown')
1103
1109
  .catch(error => logger.warn('[NexusMessaging] Queue shutdown did not finish', { error: error.message }));
package/lib/index.d.ts CHANGED
@@ -254,6 +254,7 @@ declare module '@peopl-health/nexus' {
254
254
  getAssistantProcessor(): AssistantProcessor;
255
255
  getLlmMonitor(): { start(options?: { scheduleDaily?: boolean }): Promise<{ enabled: boolean }> } | null;
256
256
  initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
257
+ getWorkflowRunner(): WorkflowRunner;
257
258
  processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string }): Promise<void>;
258
259
  processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<void>;
259
260
  processOutreach(code: string, options?: { brief?: OutreachBrief | null; triggeredBy?: string | null; reason?: string | null; firstName?: string | null; dryRun?: boolean }): Promise<OutreachDecision>;
@@ -547,16 +548,17 @@ declare module '@peopl-health/nexus' {
547
548
  export interface Workflow {
548
549
  kind: string;
549
550
  dedupeKey: (trigger: any) => string;
550
- prepare: (trigger: any) => Promise<any>;
551
+ prepare: (trigger: any, checkpoints?: any, save?: (patch: any) => Promise<void>) => Promise<any>;
552
+ retrySchedule?: number[];
551
553
  }
552
554
 
553
555
  export interface WorkflowRunner {
554
556
  register(workflow: Workflow): Promise<Workflow>;
555
- enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string>;
557
+ enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string | null>;
558
+ sweep(only?: string[]): Promise<string[]>;
559
+ stop(): void;
556
560
  }
557
561
 
558
- export function createWorkflowRunner(options: { queueAdapter: QueueAdapter }): WorkflowRunner;
559
-
560
562
  // Memory System
561
563
  export interface PatientMemoryDocument {
562
564
  _id: any;
package/lib/index.js CHANGED
@@ -301,7 +301,6 @@ class Nexus {
301
301
  }
302
302
 
303
303
  module.exports = {
304
- createWorkflowRunner,
305
304
  Nexus,
306
305
  DrainingError,
307
306
  TwilioProvider,
@@ -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 };
@@ -15,6 +15,11 @@ const AIRTABLE_API_URL = 'https://api.airtable.com/v0';
15
15
  const AXIOS_TIMEOUT_CODE = 'ECONNABORTED';
16
16
  const RETRY_BASE_DELAY_MS = 1000;
17
17
 
18
+ function isPermanentAirtableError(error) {
19
+ const status = error?.statusCode;
20
+ return typeof status === 'number' && status >= 400 && status < 500 && status !== 429;
21
+ }
22
+
18
23
  let isEvalMode = false;
19
24
 
20
25
  function setEvalMode(enabled) {
@@ -42,6 +47,7 @@ async function withAirtableRetry(operation, label, { retryNetworkErrors = true }
42
47
  const isTransient = TRANSIENT_STATUS_CODES.includes(error.statusCode)
43
48
  || (retryNetworkErrors && isTransientNetworkError(error));
44
49
  if (!isTransient || attempt === MAX_ATTEMPTS) {
50
+ if (isPermanentAirtableError(error)) error.permanent = true;
45
51
  throw error;
46
52
  }
47
53
  const baseDelay = error.statusCode === RATE_LIMITED_STATUS ? RATE_LIMIT_DELAY_MS : RETRY_BASE_DELAY_MS;
@@ -236,6 +242,7 @@ async function upsertRecord(baseID, tableName, fields, options = {}, context = n
236
242
 
237
243
  module.exports = {
238
244
  setEvalMode,
245
+ isPermanentAirtableError,
239
246
  addRecord,
240
247
  getRecords,
241
248
  getRecordByFilter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.54.0-dev.7991",
3
+ "version": "5.54.0-dev.7997",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",