@peopl-health/nexus 5.44.0-dev.6838 → 5.44.0-dev.6850
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 +0 -32
- package/lib/clinical/helpers/clinicalWorkflowGuard.js +1 -1
- package/lib/clinical/providers/BaseLLMProvider.js +4 -0
- package/lib/clinical/tools/extractClinicalInfoTool.js +33 -6
- package/lib/clinical/tools/reportUnresolvedRequestv2Tool.js +4 -4
- package/lib/core/NexusMessaging.js +0 -5
- package/lib/core/workflowRunner.js +35 -0
- package/lib/index.d.ts +4 -6
- package/lib/index.js +2 -0
- package/lib/services/airtableService.js +0 -7
- package/package.json +1 -1
- package/lib/core/WorkflowRunner.js +0 -265
- package/lib/models/deferredWorkModel.js +0 -24
package/README.md
CHANGED
|
@@ -116,38 +116,6 @@ 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
|
-
|
|
151
119
|
## Assistants (Optional)
|
|
152
120
|
|
|
153
121
|
Register assistant classes and (optionally) a custom resolver. OpenAI is supported via `llm: 'openai'`.
|
|
@@ -72,7 +72,7 @@ function validateClinicalDelivery(trace, { enabledSkills = [], skillActivationIt
|
|
|
72
72
|
if (signals.clinicalIntakeCompleted !== true) {
|
|
73
73
|
return {
|
|
74
74
|
error: REFUSAL.intakeBeforeDelivery,
|
|
75
|
-
recovery: 'Classify the complete patient message: call extractClinicalInfo with every supported tag it carries, or commit the workflow matching the clinical event it names. Do not file an administrative escalation for a turn that carries no pending request.',
|
|
75
|
+
recovery: 'Classify the complete patient message: call extractClinicalInfo with every supported tag it carries, or declare its non_clinical_request when it carries no clinical content and only asks for something you answer yourself, or commit the workflow matching the clinical event it names. Do not file an administrative escalation for a turn that carries no pending request, or for a request you answer yourself.',
|
|
76
76
|
requiredToolIds: [
|
|
77
77
|
'extractClinicalInfo', 'reportResultsReceived', 'reportCrisis',
|
|
78
78
|
'reportMedicalEscalation',
|
|
@@ -554,6 +554,10 @@ class BaseLLMProvider {
|
|
|
554
554
|
const value = calls.find(call => call.name === name)?.input?.[field];
|
|
555
555
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
556
556
|
};
|
|
557
|
+
const extraction = calls.find(call => call.name === EXTRACTION_TOOL)?.input;
|
|
558
|
+
if (pick(EXTRACTION_TOOL, 'non_clinical_request') && pick(EXTRACTION_TOOL, 'raw_message') && extraction?.detected_tags?.length === 0) {
|
|
559
|
+
return { text: null, reasoning: null };
|
|
560
|
+
}
|
|
557
561
|
const authored = pick(BRIDGE_TOOL, 'message_text');
|
|
558
562
|
if (authored) return { text: authored, reasoning: pick(BRIDGE_TOOL, 'reasoning') };
|
|
559
563
|
return { text: pick(EXTRACTION_TOOL, 'bridge_text'), reasoning: pick(EXTRACTION_TOOL, 'bridge_reasoning') };
|
|
@@ -12,11 +12,12 @@ const DETECTED_TAG_ENUM = [
|
|
|
12
12
|
'symptom', 'indication', 'treatment', 'adherence', 'medication', 'appointment',
|
|
13
13
|
'emotion', 'lab_value', 'diagnosis', 'allergy', 'comorbidity', 'performance_status',
|
|
14
14
|
];
|
|
15
|
+
const NON_CLINICAL_REQUESTS = ['external_booking'];
|
|
15
16
|
const MAX_CONTEXT_TURNS = 6;
|
|
16
17
|
|
|
17
18
|
const definition = {
|
|
18
19
|
name: 'extractClinicalInfo',
|
|
19
|
-
description: '**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient\'s current turn text, VERBATIM — the full contents of `<patient_message>`, every line of it when the turn carries more than one), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`. It is present whenever the term has a curated CTCAE ladder, and ABSENT when it does not — an absent `grade_scale` means there is no rubric for this term, not that one was withheld. An absent `grade_scale` means the term has no curated rubric, not that one was withheld — so there is nothing to grade against from memory. Call `lookupCtcaeEntry` when you suspect the term itself is wrong; otherwise say where the grade came from in `grade_estimate.source`.\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n**When the result asks you to confirm:** `data.pending_confirmation[]` means the patient reported a REAL clinical event but named it only generically («me suspendieron el tratamiento»), so it was deliberately NOT recorded. Each entry carries `{term, category, missing, quote}`; the list holds EVERY fact withheld this turn, but ask about the FIRST one only. Ask the patient for exactly what its `missing` names, in ONE short natural question, and never state or imply that anything was recorded. When they answer, call `extractClinicalInfo` again on their reply and it will be recorded then. `data.context_gaps[]` — only ever sent when there is nothing to confirm — names ONE stored variable that is stale or incomplete; raise it only if the conversation allows it naturally. Both lists are empty whenever the turn carries a safety flag: never ask housekeeping questions during a safety event.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated). Facts listed in `pending_confirmation` are NOT written to the patient record — they are held back until the patient confirms them.',
|
|
20
|
+
description: '**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient\'s current turn text, VERBATIM — the full contents of `<patient_message>`, every line of it when the turn carries more than one), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`; empty only for a purely conversational message or when declaring `non_clinical_request`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`. It is present whenever the term has a curated CTCAE ladder, and ABSENT when it does not — an absent `grade_scale` means there is no rubric for this term, not that one was withheld. An absent `grade_scale` means the term has no curated rubric, not that one was withheld — so there is nothing to grade against from memory. Call `lookupCtcaeEntry` when you suspect the term itself is wrong; otherwise say where the grade came from in `grade_estimate.source`.\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n**When the result asks you to confirm:** `data.pending_confirmation[]` means the patient reported a REAL clinical event but named it only generically («me suspendieron el tratamiento»), so it was deliberately NOT recorded. Each entry carries `{term, category, missing, quote}`; the list holds EVERY fact withheld this turn, but ask about the FIRST one only. Ask the patient for exactly what its `missing` names, in ONE short natural question, and never state or imply that anything was recorded. When they answer, call `extractClinicalInfo` again on their reply and it will be recorded then. `data.context_gaps[]` — only ever sent when there is nothing to confirm — names ONE stored variable that is stale or incomplete; raise it only if the conversation allows it naturally. Both lists are empty whenever the turn carries a safety flag: never ask housekeeping questions during a safety event.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated). Facts listed in `pending_confirmation` are NOT written to the patient record — they are held back until the patient confirms them.',
|
|
20
21
|
strict: true,
|
|
21
22
|
parameters: {
|
|
22
23
|
type: 'object',
|
|
@@ -86,8 +87,18 @@ const definition = {
|
|
|
86
87
|
anyOf: [{ type: 'string' }, { type: 'null' }],
|
|
87
88
|
description: 'Una frase: en qué detalle del inbound anclaste `bridge_text` y por qué ese detalle. INTERNO; el paciente nunca lo ve. null cuando `bridge_text` es null.',
|
|
88
89
|
},
|
|
90
|
+
non_clinical_request: {
|
|
91
|
+
anyOf: [{ type: 'string', enum: NON_CLINICAL_REQUESTS }, { type: 'null' }],
|
|
92
|
+
description: [
|
|
93
|
+
'Declara que el mensaje NO trae contenido clínico y solo pide algo que tú contestas directamente, sin el equipo.',
|
|
94
|
+
'- external_booking: agendar, reprogramar o cancelar una cita con un médico, una sesión de tratamiento (quimio, radio, infusión) o un estudio de laboratorio o imagen.',
|
|
95
|
+
'Va con `detected_tags` vacío. Si el mensaje también menciona un síntoma, emoción, medicamento, tratamiento o resultado, etiqueta esas variables y manda `null` aquí.',
|
|
96
|
+
'Manda `null` cuando la petición necesita que el equipo actúe (p. ej. una sesión del programa de acompañamiento de AUNA) y cuando el paciente solo cuenta que tiene una cita.',
|
|
97
|
+
'Con este campo, `bridge_text` va en `null`: la respuesta es un solo mensaje.',
|
|
98
|
+
].join('\n'),
|
|
99
|
+
},
|
|
89
100
|
},
|
|
90
|
-
required: ['raw_message', 'detected_tags', 'intake_source', 'response_format', 'context', 'bridge_text', 'bridge_reasoning'],
|
|
101
|
+
required: ['raw_message', 'detected_tags', 'intake_source', 'response_format', 'context', 'bridge_text', 'bridge_reasoning', 'non_clinical_request'],
|
|
91
102
|
additionalProperties: false,
|
|
92
103
|
},
|
|
93
104
|
};
|
|
@@ -116,7 +127,12 @@ async function handler(args, context = {}) {
|
|
|
116
127
|
|
|
117
128
|
if (!detectedTags.length) {
|
|
118
129
|
const supported = [...SUPPORTED_TAGS].sort();
|
|
119
|
-
const
|
|
130
|
+
const declaredRequest = !detectedTagsRaw.length && NON_CLINICAL_REQUESTS.includes(args?.non_clinical_request) ? args.non_clinical_request : null;
|
|
131
|
+
if (declaredRequest && trace?.signals?.clinicalFactsExtracted) {
|
|
132
|
+
return JSON.stringify({ success: false, error: 'This turn already extracted clinical facts, so it cannot be declared a non-clinical request. Continue the clinical workflow those facts require.' });
|
|
133
|
+
}
|
|
134
|
+
const scannedFlags = await scanSafetyFlags(rawMessage);
|
|
135
|
+
const safetyFlags = scannedFlags || [];
|
|
120
136
|
if (trace?.setSignals) trace.setSignals({ clinicalIntakeCompleted: false, clinicalMentions: [], safetyFlags });
|
|
121
137
|
|
|
122
138
|
const providerDecision = trace?.signals?.clinicalIntakeDecisionRequired;
|
|
@@ -130,15 +146,25 @@ async function handler(args, context = {}) {
|
|
|
130
146
|
}
|
|
131
147
|
conversationalInbound = isPurelyConversational(rawMessage, conversationalTokens);
|
|
132
148
|
}
|
|
133
|
-
if (
|
|
149
|
+
if (declaredRequest && scannedFlags?.length === 0) {
|
|
150
|
+
if (trace?.setSignals) trace.setSignals({ clinicalIntakeCompleted: true });
|
|
151
|
+
return JSON.stringify({
|
|
152
|
+
success: true,
|
|
153
|
+
data: { degraded: false, non_clinical_request: declaredRequest, clinical_facts: [] },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (!declaredRequest && !detectedTagsRaw.length && conversationalInbound) {
|
|
134
157
|
if (trace?.setSignals) trace.setSignals({ clinicalIntakeCompleted: true });
|
|
135
158
|
return JSON.stringify({
|
|
136
159
|
success: true,
|
|
137
160
|
data: { degraded: false, conversational_inbound: true, clinical_facts: [] },
|
|
138
161
|
});
|
|
139
162
|
}
|
|
163
|
+
const emptyTagsError = declaredRequest
|
|
164
|
+
? `The message matched a safety pattern or the safety scan was unavailable, so it cannot be declared a non-clinical request. Re-call with every applicable supported tag (${supported.join(', ')}).`
|
|
165
|
+
: `extractClinicalInfo was called with an empty \`detected_tags\` list and the inbound is not purely conversational. The intake decision is incomplete, so direct delivery remains blocked. Re-call with every applicable supported tag (${supported.join(', ')}). If the message carries no clinical content and only makes a request that \`non_clinical_request\` covers, re-call with empty tags and that field set. If it carries no clinical content but needs the team to act on a request, commit that workflow instead. Commit nothing when the turn carries neither.`;
|
|
140
166
|
const error = !detectedTagsRaw.length
|
|
141
|
-
?
|
|
167
|
+
? emptyTagsError
|
|
142
168
|
: `extractClinicalInfo received detected_tags=${JSON.stringify(detectedTagsRaw)} but none are supported. Use only these exact strings (lowercase): ${supported.join(', ')}. Re-call with the corrected tags.`;
|
|
143
169
|
return JSON.stringify({ success: false, error, supported_tags: supported, received_tags: detectedTagsRaw });
|
|
144
170
|
}
|
|
@@ -167,6 +193,7 @@ async function handler(args, context = {}) {
|
|
|
167
193
|
clinicalIntakeCompleted: intakeCompleted,
|
|
168
194
|
clinicalMentions: [...clinicalMentions, ...pendingMentions],
|
|
169
195
|
safetyFlags,
|
|
196
|
+
...(clinicalMentions.length || pendingMentions.length ? { clinicalFactsExtracted: true } : {}),
|
|
170
197
|
});
|
|
171
198
|
} else {
|
|
172
199
|
logger.warn('[extractClinicalInfo] no trace on runtime context; signals not recorded', { turnId: runtime?.turnId });
|
|
@@ -215,7 +242,7 @@ async function scanSafetyFlags(text) {
|
|
|
215
242
|
return detectSafetyFlags(text, await getSafetyFlagPatterns());
|
|
216
243
|
} catch (err) {
|
|
217
244
|
logger.warn('[extractClinicalInfo] deterministic safety scan failed', { error: err?.message });
|
|
218
|
-
return
|
|
245
|
+
return null;
|
|
219
246
|
}
|
|
220
247
|
}
|
|
221
248
|
|
|
@@ -4,14 +4,14 @@ const { logger } = require('../../utils/logger');
|
|
|
4
4
|
|
|
5
5
|
const definition = {
|
|
6
6
|
'name': 'reportUnresolvedRequestv2',
|
|
7
|
-
'description': '**Does:** Escalates ADMINISTRATIVE / OPERATIVE matters — and non-clinical referrals to other services (psico-oncología, paliativos, non-oncology medical) — to the appropriate team queue. Records what was left pending, why it needs the team, operational urgency, and what was already communicated to the patient. It carries NO clinical-escalation semantics for the patient\'s oncology condition — those go to `reportMedicalEscalation`.\n\n**Required inputs:** `unresolved_question`, `category` (operational routing — REQUIRED for the queue), `urgency` (`routine` | `soon` | `urgent`; default `routine`), `escalation_details`, `resolution_attempted` (bool).\n\n**When to call:**\n- Administrative matter
|
|
7
|
+
'description': '**Does:** Escalates ADMINISTRATIVE / OPERATIVE matters — and non-clinical referrals to other services (psico-oncología, paliativos, non-oncology medical) — to the appropriate team queue. Records what was left pending, why it needs the team, operational urgency, and what was already communicated to the patient. It carries NO clinical-escalation semantics for the patient\'s oncology condition — those go to `reportMedicalEscalation`.\n\n**Required inputs:** `unresolved_question`, `category` (operational routing — REQUIRED for the queue), `urgency` (`routine` | `soon` | `urgent`; default `routine`), `escalation_details`, `resolution_attempted` (bool).\n\n**When to call:**\n- Administrative matter the team has to act on (insurance / authorisations / letters / transport), including scheduling or rescheduling a session of the AUNA accompaniment program (psico-oncología, nutrición).\n- Patient request that needs human (non-clinical) intervention.\n- Outside the agent\'s scope or knowledge (non-clinical).\n- A routine referral (psico-oncología, paliativos).\n- Callback from a specific team member (`category=contact_team_member`), or enrollment in a program / workshop / support group (`category=program_enrollment`).\nAlways pair with a `DeliverPatientMessage` acknowledging the escalation.\n\n**When NOT to call:**\n- ANY medical escalation — confirmed emergency, same-day evaluation, OR an asynchronous clinical concern (a symptom persisting >72h, a worrying trend, a treatment concern, stale clinical context) — use `reportMedicalEscalation` (urgency_level stat | asap | urgent | routine).\n- Active mental-health crisis — use `reportCrisis`.\n- Patient SHARED results / values / files — use `reportResultsReceived`.\n- Booking, rescheduling or cancelling a doctor appointment, a treatment session (chemo / radiotherapy / infusion) or a lab / imaging study — these are not booked through this channel; answer the patient directly with where to book, as your instructions describe. When that turn carries no clinical content, declare it with `non_clinical_request` in `extractClinicalInfo`.\n- Questions answerable from the CTCAE catalog or skill protocols — answer directly.\n\n**Returns:** an ack ({} on the happy path). Out-of-enum `urgency`/`category` are coerced (to `routine`/`other`) and disclosed via `coerced_urgency`/`coerced_category`; inputs over 500 chars are truncated and disclosed via `truncated_fields`. If the escalation could not be queued to the team it comes back with `queued: false` — do NOT tell the patient the team was notified in that case; acknowledge and say you are still working on it.\n\n**Side effects:** Emits `OUT_OF_SCOPE_LOGGED` to the appropriate team queue.',
|
|
8
8
|
'strict': true,
|
|
9
9
|
'parameters': {
|
|
10
10
|
'type': 'object',
|
|
11
11
|
'properties': {
|
|
12
12
|
'unresolved_question': {
|
|
13
13
|
'type': 'string',
|
|
14
|
-
'description': 'El pendiente ADMINISTRATIVO / OPERATIVO o de DERIVACIÓN que requiere al equipo. Cubre: (1) administrativo —
|
|
14
|
+
'description': 'El pendiente ADMINISTRATIVO / OPERATIVO o de DERIVACIÓN que requiere al equipo. Cubre: (1) administrativo que el equipo tiene que resolver — seguros, autorizaciones, cartas, transporte, y agendar o reprogramar sesiones del programa de acompañamiento de AUNA (psico-oncología, nutrición); (2) solicitud que requiere intervención humana no clínica; (3) fuera de tus alcances o conocimientos; (4) derivación/referencia a otro servicio (psico-oncología, paliativos, médico no oncológico) — eso es COORDINACIÓN, no escalación clínica. NO uses esta herramienta para ESCALACIONES CLÍNICAS del cuadro oncológico — un síntoma que persiste, una tendencia preocupante, un treatment_concern o contexto clínico desactualizado van por `reportMedicalEscalation` (urgency_level `routine` para revisión asíncrona del equipo). Resultados ya realizados que compartió el paciente van por `reportResultsReceived`. Tampoco la uses para agendar, reprogramar o cancelar citas con médicos, sesiones de tratamiento (quimio, radio, infusión) o estudios de laboratorio o imagen: no se agendan por este medio y eso se le responde directamente al paciente.'
|
|
15
15
|
},
|
|
16
16
|
'escalation_details': {
|
|
17
17
|
'type': 'string',
|
|
@@ -32,7 +32,7 @@ const definition = {
|
|
|
32
32
|
'program_enrollment',
|
|
33
33
|
'other'
|
|
34
34
|
],
|
|
35
|
-
'description': 'Categoría operativa/administrativa o de derivación del pendiente para ruteo. `contact_team_member` = el paciente quiere hablar con / pasar un mensaje a / que lo llame de vuelta un miembro del equipo. `program_enrollment` = inscribirse a charlas, talleres, grupos de apoyo o check-ups. Las categorías de ESCALACIÓN CLÍNICA (symptom_persistent, treatment_concern, stale_context) se retiraron — esas escalaciones van por `reportMedicalEscalation`.'
|
|
35
|
+
'description': 'Categoría operativa/administrativa o de derivación del pendiente para ruteo. `scheduling` = agendar o reprogramar sesiones del programa de acompañamiento de AUNA (no citas médicas, sesiones de tratamiento ni estudios). `contact_team_member` = el paciente quiere hablar con / pasar un mensaje a / que lo llame de vuelta un miembro del equipo. `program_enrollment` = inscribirse a charlas, talleres, grupos de apoyo o check-ups. Las categorías de ESCALACIÓN CLÍNICA (symptom_persistent, treatment_concern, stale_context) se retiraron — esas escalaciones van por `reportMedicalEscalation`.'
|
|
36
36
|
},
|
|
37
37
|
'resolution_attempted': {
|
|
38
38
|
'anyOf': [
|
|
@@ -54,7 +54,7 @@ const definition = {
|
|
|
54
54
|
'soon',
|
|
55
55
|
'urgent'
|
|
56
56
|
],
|
|
57
|
-
'description': 'routine = sin plazo; soon = plazo en la semana; urgent = mismo día (transporte para hoy,
|
|
57
|
+
'description': 'routine = sin plazo; soon = plazo en la semana; urgent = mismo día (transporte para hoy, una sesión de programa que choca mañana).'
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
'type': 'null'
|
|
@@ -33,7 +33,6 @@ const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
|
|
|
33
33
|
const { createQueueAdapter } = require('../queue');
|
|
34
34
|
const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
|
|
35
35
|
const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
|
|
36
|
-
const { WorkflowRunner } = require('./WorkflowRunner');
|
|
37
36
|
|
|
38
37
|
const { PhiProcessor } = require('./PhiProcessor');
|
|
39
38
|
|
|
@@ -118,8 +117,6 @@ class NexusMessaging {
|
|
|
118
117
|
queueAdapter: this.queueAdapter,
|
|
119
118
|
});
|
|
120
119
|
|
|
121
|
-
this.workflowRunner = new WorkflowRunner({ queueAdapter: this.queueAdapter });
|
|
122
|
-
|
|
123
120
|
this.phiProcessor = new PhiProcessor({
|
|
124
121
|
encode: config.phi?.encode || false,
|
|
125
122
|
ner: config.phi?.ner || null,
|
|
@@ -303,7 +300,6 @@ class NexusMessaging {
|
|
|
303
300
|
getAssistantProcessor() { return this.assistantProcessor; }
|
|
304
301
|
getLlmMonitor() { return this.llmMonitor; }
|
|
305
302
|
getQueueAdapter() { return this.queueAdapter; }
|
|
306
|
-
getWorkflowRunner() { return this.workflowRunner; }
|
|
307
303
|
getPhiProcessor() { return this.phiProcessor; }
|
|
308
304
|
isConnected() { return this.provider?.getConnectionStatus() ?? false; }
|
|
309
305
|
isProcessing(chatId) { return this.batchingManager.isProcessing(chatId); }
|
|
@@ -744,7 +740,6 @@ class NexusMessaging {
|
|
|
744
740
|
async disconnect() {
|
|
745
741
|
this.queueReconciliation?.stop();
|
|
746
742
|
if (this.provider) await this.provider.disconnect();
|
|
747
|
-
if (this.workflowRunner) this.workflowRunner.stop();
|
|
748
743
|
if (this.queueAdapter) await this.queueAdapter.shutdown();
|
|
749
744
|
this.events.removeAllListeners();
|
|
750
745
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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 };
|
package/lib/index.d.ts
CHANGED
|
@@ -243,7 +243,6 @@ declare module '@peopl-health/nexus' {
|
|
|
243
243
|
getAssistantProcessor(): AssistantProcessor;
|
|
244
244
|
getLlmMonitor(): { start(options?: { scheduleDaily?: boolean }): Promise<{ enabled: boolean }> } | null;
|
|
245
245
|
initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
|
|
246
|
-
getWorkflowRunner(): WorkflowRunner;
|
|
247
246
|
processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string }): Promise<void>;
|
|
248
247
|
processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<void>;
|
|
249
248
|
isConnected(): boolean;
|
|
@@ -456,17 +455,16 @@ declare module '@peopl-health/nexus' {
|
|
|
456
455
|
export interface Workflow {
|
|
457
456
|
kind: string;
|
|
458
457
|
dedupeKey: (trigger: any) => string;
|
|
459
|
-
prepare: (trigger: any
|
|
460
|
-
retrySchedule?: number[];
|
|
458
|
+
prepare: (trigger: any) => Promise<any>;
|
|
461
459
|
}
|
|
462
460
|
|
|
463
461
|
export interface WorkflowRunner {
|
|
464
462
|
register(workflow: Workflow): Promise<Workflow>;
|
|
465
|
-
enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string
|
|
466
|
-
sweep(only?: string[]): Promise<string[]>;
|
|
467
|
-
stop(): void;
|
|
463
|
+
enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string>;
|
|
468
464
|
}
|
|
469
465
|
|
|
466
|
+
export function createWorkflowRunner(options: { queueAdapter: QueueAdapter }): WorkflowRunner;
|
|
467
|
+
|
|
470
468
|
// Memory System
|
|
471
469
|
export interface PatientMemoryDocument {
|
|
472
470
|
_id: any;
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,7 @@ 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');
|
|
30
31
|
const routes = require('./routes');
|
|
31
32
|
const { resetAll } = require('./config/lifecycle');
|
|
32
33
|
const { EvalProvider } = require('./eval/EvalProvider');
|
|
@@ -220,6 +221,7 @@ class Nexus {
|
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
module.exports = {
|
|
224
|
+
createWorkflowRunner,
|
|
223
225
|
Nexus,
|
|
224
226
|
TwilioProvider,
|
|
225
227
|
BaileysProvider,
|
|
@@ -15,11 +15,6 @@ 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
|
-
|
|
23
18
|
let isEvalMode = false;
|
|
24
19
|
|
|
25
20
|
function setEvalMode(enabled) {
|
|
@@ -47,7 +42,6 @@ async function withAirtableRetry(operation, label, { retryNetworkErrors = true }
|
|
|
47
42
|
const isTransient = TRANSIENT_STATUS_CODES.includes(error.statusCode)
|
|
48
43
|
|| (retryNetworkErrors && isTransientNetworkError(error));
|
|
49
44
|
if (!isTransient || attempt === MAX_ATTEMPTS) {
|
|
50
|
-
if (isPermanentAirtableError(error)) error.permanent = true;
|
|
51
45
|
throw error;
|
|
52
46
|
}
|
|
53
47
|
const baseDelay = error.statusCode === RATE_LIMITED_STATUS ? RATE_LIMIT_DELAY_MS : RETRY_BASE_DELAY_MS;
|
|
@@ -235,7 +229,6 @@ async function upsertRecord(baseID, tableName, fields, { mergeOn } = {}, context
|
|
|
235
229
|
|
|
236
230
|
module.exports = {
|
|
237
231
|
setEvalMode,
|
|
238
|
-
isPermanentAirtableError,
|
|
239
232
|
addRecord,
|
|
240
233
|
getRecords,
|
|
241
234
|
getRecordByFilter,
|
package/package.json
CHANGED
|
@@ -1,265 +0,0 @@
|
|
|
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
|
-
// An attempt still in flight when stop() lands must not re-arm after shutdown. The
|
|
162
|
-
// record stays pending in Mongo, so the next process recovers it on its sweep.
|
|
163
|
-
if (this.stopped) return id;
|
|
164
|
-
clearTimeout(this.timers.get(id));
|
|
165
|
-
const timer = setTimeout(() => {
|
|
166
|
-
this.timers.delete(id);
|
|
167
|
-
this._runDeferred(workflow, id).catch((error) => {
|
|
168
|
-
logger.error('[WorkflowRunner] Deferred run failed', { kind: workflow.kind, deferredWorkId: id, error: error.message });
|
|
169
|
-
});
|
|
170
|
-
}, delayMs);
|
|
171
|
-
timer.unref();
|
|
172
|
-
this.timers.set(id, timer);
|
|
173
|
-
return id;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async _runDeferred(workflow, deferredWorkId) {
|
|
177
|
-
const claimedAt = new Date();
|
|
178
|
-
const prior = await DeferredWork.findOneAndUpdate(
|
|
179
|
-
{
|
|
180
|
-
_id: deferredWorkId,
|
|
181
|
-
$or: [
|
|
182
|
-
{ status: 'pending', nextAttemptAt: { $lte: claimedAt } },
|
|
183
|
-
{ status: 'processing', claimedAt: { $lt: new Date(claimedAt.getTime() - STALE_CLAIM_MS) } }
|
|
184
|
-
]
|
|
185
|
-
},
|
|
186
|
-
{ $set: { status: 'processing', claimedAt } },
|
|
187
|
-
{ new: false }
|
|
188
|
-
);
|
|
189
|
-
if (!prior) {
|
|
190
|
-
logger.info('[WorkflowRunner] Claimed elsewhere or not due', { kind: workflow.kind, deferredWorkId });
|
|
191
|
-
return { claimed: false };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const reclaimed = prior.status === 'processing';
|
|
195
|
-
const work = { ...prior.toObject(), attempt: prior.attempt + (reclaimed ? 1 : 0) };
|
|
196
|
-
if (reclaimed) await this._write(work, claimedAt, { attempt: work.attempt });
|
|
197
|
-
|
|
198
|
-
const checkpoints = { ...(work.checkpoints || {}) };
|
|
199
|
-
const save = async (patch) => {
|
|
200
|
-
Object.assign(checkpoints, patch);
|
|
201
|
-
const $set = {};
|
|
202
|
-
for (const [key, value] of Object.entries(patch || {})) $set[`checkpoints.${key}`] = value;
|
|
203
|
-
if (Object.keys($set).length) await this._write(work, claimedAt, $set);
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
await workflow.prepare(work.trigger, checkpoints, save);
|
|
208
|
-
} catch (error) {
|
|
209
|
-
return await this._rescheduleOrAbandon(workflow, work, claimedAt, error);
|
|
210
|
-
}
|
|
211
|
-
await this._write(work, claimedAt, { status: 'done', claimedAt: null });
|
|
212
|
-
return { status: 'done' };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// matchedCount, not modifiedCount: the fence is "do we still hold the claim", and
|
|
216
|
-
// modifiedCount is also 0 for a write that matched but set an identical value - a
|
|
217
|
-
// save() rewriting an unchanged checkpoint is not a lost claim.
|
|
218
|
-
async _write(work, claimedAt, $set) {
|
|
219
|
-
const { matchedCount } = await DeferredWork.updateOne({ _id: work._id, claimedAt }, { $set });
|
|
220
|
-
if (!matchedCount) logger.warn('[WorkflowRunner] Claim lost, write discarded', { deferredWorkId: String(work._id) });
|
|
221
|
-
return matchedCount > 0;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async _rescheduleOrAbandon(workflow, work, claimedAt, error) {
|
|
225
|
-
// prepare() is consumer code and can throw anything, including null. Dereferencing
|
|
226
|
-
// it here used to raise a TypeError out of this handler, so neither branch ran and
|
|
227
|
-
// the record stayed `processing` until stale recovery hit the same failure again.
|
|
228
|
-
const lastError = describeThrown(error);
|
|
229
|
-
const delayMinutes = workflow.retrySchedule[work.attempt];
|
|
230
|
-
if (error?.permanent === true || delayMinutes === undefined) {
|
|
231
|
-
await this._write(work, claimedAt, { status: 'abandoned', lastError, claimedAt: null });
|
|
232
|
-
logger.error('[WorkflowRunner] Abandoned', {
|
|
233
|
-
kind: workflow.kind,
|
|
234
|
-
dedupeKey: work.dedupeKey,
|
|
235
|
-
attempt: work.attempt,
|
|
236
|
-
permanent: error?.permanent === true,
|
|
237
|
-
error: lastError
|
|
238
|
-
});
|
|
239
|
-
return { status: 'abandoned' };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const attempt = work.attempt + 1;
|
|
243
|
-
const delayMs = delayMinutes * MINUTE_MS;
|
|
244
|
-
const kept = await this._write(work, claimedAt, {
|
|
245
|
-
status: 'pending',
|
|
246
|
-
attempt,
|
|
247
|
-
nextAttemptAt: new Date(Date.now() + delayMs),
|
|
248
|
-
lastError,
|
|
249
|
-
claimedAt: null
|
|
250
|
-
});
|
|
251
|
-
if (!kept) return { claimed: false };
|
|
252
|
-
|
|
253
|
-
logger.warn('[WorkflowRunner] Rescheduled', {
|
|
254
|
-
kind: workflow.kind,
|
|
255
|
-
dedupeKey: work.dedupeKey,
|
|
256
|
-
attempt,
|
|
257
|
-
delayMinutes,
|
|
258
|
-
error: lastError
|
|
259
|
-
});
|
|
260
|
-
this._arm(workflow, work._id, delayMs);
|
|
261
|
-
return { status: 'pending', attempt };
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
module.exports = { WorkflowRunner };
|
|
@@ -1,24 +0,0 @@
|
|
|
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 };
|