@hostwebhook/node-sdk 0.1.0

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 (59) hide show
  1. package/dist/code-runner.d.ts +20 -0
  2. package/dist/code-runner.js +138 -0
  3. package/dist/contratos.d.ts +121 -0
  4. package/dist/contratos.js +24 -0
  5. package/dist/dto/output-node.dto.d.ts +19 -0
  6. package/dist/dto/output-node.dto.js +96 -0
  7. package/dist/ensure-meta.d.ts +22 -0
  8. package/dist/ensure-meta.js +35 -0
  9. package/dist/execute-with-iteration.d.ts +18 -0
  10. package/dist/execute-with-iteration.js +66 -0
  11. package/dist/filter-utils.d.ts +22 -0
  12. package/dist/filter-utils.js +178 -0
  13. package/dist/handler-helpers.d.ts +21 -0
  14. package/dist/handler-helpers.js +53 -0
  15. package/dist/index.d.ts +51 -0
  16. package/dist/index.js +73 -0
  17. package/dist/log-metadata.d.ts +191 -0
  18. package/dist/log-metadata.js +375 -0
  19. package/dist/node-dispatch.registry.d.ts +32 -0
  20. package/dist/node-dispatch.registry.js +45 -0
  21. package/dist/node-executors.d.ts +299 -0
  22. package/dist/node-executors.js +555 -0
  23. package/dist/node-lifecycle.d.ts +399 -0
  24. package/dist/node-lifecycle.js +782 -0
  25. package/dist/normalize-nodes.d.ts +18 -0
  26. package/dist/normalize-nodes.js +22 -0
  27. package/dist/output-node-ref.schema.d.ts +82 -0
  28. package/dist/output-node-ref.schema.js +90 -0
  29. package/dist/output-webhook-scope.d.ts +36 -0
  30. package/dist/output-webhook-scope.js +42 -0
  31. package/dist/payload-preview.d.ts +10 -0
  32. package/dist/payload-preview.js +39 -0
  33. package/dist/pipeline.constants.d.ts +29 -0
  34. package/dist/pipeline.constants.js +51 -0
  35. package/dist/pre-request-pool.d.ts +58 -0
  36. package/dist/pre-request-pool.js +308 -0
  37. package/dist/pre-request-runner-source.d.ts +28 -0
  38. package/dist/pre-request-runner-source.js +411 -0
  39. package/dist/regex-de-inquilino.d.ts +15 -0
  40. package/dist/regex-de-inquilino.js +98 -0
  41. package/dist/request-context.d.ts +18 -0
  42. package/dist/request-context.js +34 -0
  43. package/dist/retry-transient.d.ts +54 -0
  44. package/dist/retry-transient.js +67 -0
  45. package/dist/retry-utils.d.ts +17 -0
  46. package/dist/retry-utils.js +23 -0
  47. package/dist/schema-validator-utils.d.ts +9 -0
  48. package/dist/schema-validator-utils.js +140 -0
  49. package/dist/ssrf-guard.d.ts +202 -0
  50. package/dist/ssrf-guard.js +917 -0
  51. package/dist/swallow.d.ts +52 -0
  52. package/dist/swallow.js +55 -0
  53. package/dist/template-render.d.ts +33 -0
  54. package/dist/template-render.js +43 -0
  55. package/dist/try-parse.d.ts +41 -0
  56. package/dist/try-parse.js +69 -0
  57. package/dist/workspace-payloads.d.ts +66 -0
  58. package/dist/workspace-payloads.js +496 -0
  59. package/package.json +35 -0
@@ -0,0 +1,782 @@
1
+ "use strict";
2
+ /**
3
+ * Unified Node Lifecycle — single dispatch function for all 3 execution paths.
4
+ *
5
+ * Each node type registers a NodeHandler. dispatchSingleNode() runs the
6
+ * standard lifecycle: fetch → validate → filter → execute → save → delivery → telemetry → downstream.
7
+ * Only the execute step is node-specific.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.registerNodeHandler = registerNodeHandler;
11
+ exports.getNodeHandler = getNodeHandler;
12
+ exports.getTelemetrySource = getTelemetrySource;
13
+ exports.mensajeDeFallo = mensajeDeFallo;
14
+ exports.veredictoDelDespacho = veredictoDelDespacho;
15
+ exports.veredictoDeResultado = veredictoDeResultado;
16
+ exports.conPasoRegistrado = conPasoRegistrado;
17
+ exports.dispatchSingleNode = dispatchSingleNode;
18
+ const mongoose_1 = require("mongoose");
19
+ const common_1 = require("@nestjs/common");
20
+ const filter_utils_1 = require("./filter-utils");
21
+ const workspace_payloads_1 = require("./workspace-payloads");
22
+ const pipeline_constants_1 = require("./pipeline.constants");
23
+ const log_metadata_1 = require("./log-metadata");
24
+ const node_dispatch_registry_1 = require("./node-dispatch.registry");
25
+ const node_executors_1 = require("./node-executors");
26
+ const output_webhook_scope_1 = require("./output-webhook-scope");
27
+ const node_types_1 = require("@hostwebhook/node-types");
28
+ const swallow_1 = require("./swallow");
29
+ const logger = new common_1.Logger('NodeLifecycle');
30
+ // ─── Handler registry ────────────────────────────────────────────
31
+ const handlers = new Map();
32
+ /**
33
+ * La clave del registro. Sin versión es el tipo pelado, que es como se ha
34
+ * llamado siempre; con versión lleva sufijo.
35
+ *
36
+ * Que la clave sea una cadena es lo que hace barato el versionado: no hubo
37
+ * que cambiar la estructura, sólo cómo se nombra lo que va dentro.
38
+ */
39
+ const claveDeHandler = (nodeType, version) => version == null ? nodeType : `${nodeType}@${version}`;
40
+ function registerNodeHandler(handler) {
41
+ handlers.set(claveDeHandler(handler.nodeType, handler.version), handler);
42
+ /* El handler de la versión vigente responde TAMBIÉN a la clave pelada.
43
+ Sin esto, versionar un tipo dejaría ciegos de golpe a los llamantes
44
+ que preguntan sin versión —los de MCP, sobre todo— y el síntoma sería
45
+ un toolkit que desaparece, no un error. */
46
+ if (handler.version == null ||
47
+ handler.version === (0, node_types_1.currentVersion)(handler.nodeType))
48
+ handlers.set(handler.nodeType, handler);
49
+ }
50
+ /**
51
+ * El handler de un tipo, opcionalmente el de una versión concreta.
52
+ *
53
+ * Sin `version` devuelve el de la versión vigente, que es exactamente lo
54
+ * que hacía antes de que existieran las versiones. Con una versión que
55
+ * nadie registró cae al vigente en vez de devolver nada: un nodo apuntando
56
+ * a una versión que ya se retiró debe seguir corriendo, no dejar de
57
+ * dispararse en silencio.
58
+ */
59
+ function getNodeHandler(nodeType, version) {
60
+ return (handlers.get(claveDeHandler(nodeType, version)) ?? handlers.get(nodeType));
61
+ }
62
+ /**
63
+ * Telemetry log source for a node type.
64
+ *
65
+ * This used to be a 33-entry `NODE_TYPE_TO_SOURCE` map maintained by hand in
66
+ * pipeline-run.service.ts — a fourth place to remember when adding a node,
67
+ * and one that had already grown duplicate aliases (`approval` /
68
+ * `approvalNode`, `delay` / `delayNode`, `merge` / `mergeNode`,
69
+ * `conditional` / `conditionalNode`) because nobody could tell which key the
70
+ * callers passed. Every handler already declares `telemetrySource`, so the
71
+ * map was a copy of information the registry owned.
72
+ *
73
+ * Types with no handler declare their source next to the reason they have no
74
+ * handler, in HANDLERLESS_NODE_TYPES. Anything else — the non-node sources
75
+ * the pipeline logs under, like 'delivery', 'replay' and 'chain' — passes
76
+ * through as-is, which is what the old map's `?? nodeType` fallback did.
77
+ */
78
+ function getTelemetrySource(nodeType) {
79
+ const registered = handlers.get(nodeType)?.telemetrySource;
80
+ if (registered)
81
+ return registered;
82
+ if ((0, node_executors_1.isHandlerless)(nodeType))
83
+ return node_executors_1.HANDLERLESS_NODE_TYPES[nodeType].telemetrySource;
84
+ return nodeType;
85
+ }
86
+ // ─── Default implementations ─────────────────────────────────────
87
+ function defaultPreFilter(entity, payload, wsPayloads) {
88
+ const filters = entity.filters ?? [];
89
+ const opts = wsPayloads ? { workspacePayloads: wsPayloads } : undefined;
90
+ if (filters.length > 0 && !(0, filter_utils_1.evaluateFilters)(filters, payload, 'and', opts)) {
91
+ return 'filtered out';
92
+ }
93
+ return null;
94
+ }
95
+ function defaultBuildLastPayload(_entity, _result, outputPayload) {
96
+ if (outputPayload._meta)
97
+ return outputPayload;
98
+ return { _meta: { iterable: false, count: 1 }, ...outputPayload };
99
+ }
100
+ /**
101
+ * Cuánto del mensaje de error cabe en un paso.
102
+ *
103
+ * Se enseña bajo el nombre del nodo en la línea de tiempo, así que tiene que
104
+ * caber en una o dos líneas. El cuerpo entero ya está en `run_payloads`, que
105
+ * es donde se va a mirar de verdad; esto es el titular.
106
+ */
107
+ const TOPE_DEL_ERROR = 500;
108
+ /**
109
+ * Saca del resultado la frase que explica el fallo.
110
+ *
111
+ * Los handlers devuelven el cuerpo como texto, y casi siempre es un JSON con
112
+ * `error` dentro — una frase ya escrita para leerse. Enseñar el JSON crudo en
113
+ * la línea de tiempo es enseñar llaves y comillas donde cabía la explicación.
114
+ * Si no es JSON, o no trae `error`, se enseña el cuerpo recortado; y si no hay
115
+ * cuerpo, el código HTTP, que al menos dice algo.
116
+ */
117
+ function mensajeDeFallo(result) {
118
+ if (!result)
119
+ return 'el nodo falló sin devolver resultado';
120
+ const cuerpo = result.responseBody?.trim();
121
+ if (!cuerpo)
122
+ return `HTTP ${result.statusCode}`;
123
+ let frase;
124
+ try {
125
+ const json = JSON.parse(cuerpo);
126
+ const candidata = json?.error ?? json?.message;
127
+ frase = typeof candidata === 'string' ? candidata : undefined;
128
+ }
129
+ catch {
130
+ /* No era JSON. Se enseña el cuerpo tal cual, que para eso está abajo. */
131
+ frase = undefined;
132
+ }
133
+ return (frase && frase.length > 0 ? frase : cuerpo).slice(0, TOPE_DEL_ERROR);
134
+ }
135
+ /**
136
+ * Cómo le fue al paso, a partir de lo que devolvió el despacho.
137
+ *
138
+ * ⚠️ El veredicto sale del `statusCode`, NO de si el nodo llegó a correr. Eso
139
+ * era el bug: `anotar(r.executed ? 'success' : 'skipped')` daba `success` a
140
+ * todo nodo que corriera, devolviese lo que devolviese. Un Email Action que
141
+ * lleva cinco días contestando 500 tenía cinco corridas en verde, con el
142
+ * `{"statusCode":500}` guardado dentro de su propio payload. La telemetría de
143
+ * este mismo fichero ya lo miraba bien (`result.statusCode < 400`): eran dos
144
+ * criterios distintos sobre el mismo resultado.
145
+ *
146
+ * Los tres estados, y por qué son tres y no dos:
147
+ *
148
+ * - `failed` — corrió y salió mal, o reventó. Es lo que cuenta `failures` y
149
+ * lo que hace que la corrida entera sea `failed` o `partial`.
150
+ * - `skipped` — no llegó a correr: inactivo, filtrado, sin handler. Un paso
151
+ * que no existe y uno que se saltó no se leen igual.
152
+ * - `success` — corrió y salió bien.
153
+ */
154
+ function veredictoDelDespacho(r) {
155
+ /* El fallo explícito manda sobre todo lo demás: `ejecutarNodo` devuelve
156
+ `executed: false` tanto cuando el nodo explotó como cuando nadie lo
157
+ llamó, y sólo este campo los separa. */
158
+ if (r.error !== undefined) {
159
+ return { status: 'failed', salida: r.result, error: r.error };
160
+ }
161
+ if (!r.executed)
162
+ return { status: 'skipped', salida: r.result };
163
+ return veredictoDeResultado(r.result);
164
+ }
165
+ /**
166
+ * El veredicto de un nodo que SÍ corrió, sacado de lo que devolvió.
167
+ *
168
+ * Aparte de `veredictoDelDespacho` porque las rutas que no pasan por el
169
+ * pipeline —el Chat Trigger, los tools del nodo AI, los de MCP— no tienen un
170
+ * `DispatchResult`: tienen el `NodeExecutionResult` a secas. El criterio de
171
+ * qué es un fallo tiene que ser UNO, o vuelven a ser dos.
172
+ *
173
+ * Sin `statusCode` numérico se da por bueno a propósito: un handler que no lo
174
+ * declara no está diciendo que falló, y pintar de rojo lo que no se sabe es
175
+ * peor que dejarlo verde — el rojo se mira.
176
+ */
177
+ function veredictoDeResultado(result) {
178
+ const statusCode = result?.statusCode;
179
+ if (typeof statusCode === 'number' && statusCode >= 400) {
180
+ return { status: 'failed', salida: result, error: mensajeDeFallo(result) };
181
+ }
182
+ return { status: 'success', salida: result };
183
+ }
184
+ /**
185
+ * Corre algo y lo deja escrito en el historial como un paso.
186
+ *
187
+ * Es una envoltura, y no un puñado de llamadas repartidas por dentro, a
188
+ * propósito: `ejecutarNodo` tiene seis salidas —no hay handler, no se pudo
189
+ * traer la entidad, está inactiva, la filtró el pre-filtro, reventó, salió
190
+ * bien— y anotar el paso en cada una es la clase de lista que se olvida en la
191
+ * séptima. Desde fuera se ven todas a la vez, con su duración de verdad.
192
+ *
193
+ * Vive fuera de `dispatchSingleNode` porque el pipeline de producción NO es la
194
+ * única forma de ejecutar un nodo. El Chat Trigger, los tools del nodo AI, los
195
+ * de MCP, Run Pipeline y Run Test llegan al nodo por su cuenta, y sin una
196
+ * función a la que llamar cada uno tendría que copiar este bloque — que es
197
+ * como se llega a seis copias que se desincronizan.
198
+ *
199
+ * `ficha` se lee DESPUÉS de correr, no antes: `correr` puede rellenarle el
200
+ * `nodeName` y el `workspaceId` en cuanto cargue la entidad. Son opcionales en
201
+ * `PasoEjecutado`, así que olvidarlos no rompe nada y sólo se nota en pantalla
202
+ * —sin `nodeName` la línea de tiempo enseña el tipo dos veces; sin
203
+ * `workspaceId` la corrida sale sin foto y diciendo que es anterior al sellado
204
+ * de versiones—.
205
+ *
206
+ * Nunca estorba: si no hay `historial` en el contexto —tests, modos que no lo
207
+ * montan— esto es una llamada directa y ya está.
208
+ */
209
+ async function conPasoRegistrado(ctx, ficha, correr, veredicto) {
210
+ if (!ctx.historial)
211
+ return correr();
212
+ const empezado = new Date();
213
+ const anotar = (v) => ctx.historial?.registrarPaso({
214
+ organizationId: ctx.orgId,
215
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
216
+ nodeId: ficha.nodeId,
217
+ nodeName: ficha.nodeName,
218
+ workspaceId: ficha.workspaceId,
219
+ nodeType: ficha.nodeType,
220
+ status: v.status,
221
+ startedAt: empezado,
222
+ durationMs: Date.now() - empezado.getTime(),
223
+ error: v.error,
224
+ sourceId: ctx.webhook.id,
225
+ sourceName: ctx.webhook.name,
226
+ entrada: ficha.entrada,
227
+ salida: v.salida,
228
+ modo: ctx.modoDeCorrida,
229
+ });
230
+ try {
231
+ const r = await correr();
232
+ anotar(veredicto(r));
233
+ return r;
234
+ }
235
+ catch (err) {
236
+ /* Que `correr` lance es la vía rara —`ejecutarNodo` captura lo suyo y
237
+ devuelve `error`—, pero los llamadores de fuera del pipeline sí lanzan,
238
+ y un paso que se pierde por reventar es justo el que hacía falta ver. */
239
+ anotar({
240
+ status: 'failed',
241
+ error: err instanceof Error ? err.message : String(err),
242
+ });
243
+ throw err;
244
+ }
245
+ }
246
+ /**
247
+ * Ejecuta un nodo y, de paso, lo deja escrito en el historial.
248
+ *
249
+ * El payload de entrada es un argumento, así que entra gratis. El de salida y
250
+ * el veredicto salen del resultado, en `veredictoDelDespacho`.
251
+ */
252
+ async function dispatchSingleNode(nodeType, id, payload, ctx) {
253
+ if (!ctx.historial)
254
+ return ejecutarNodo(nodeType, id, payload, ctx);
255
+ /* La misma ficha viaja a `ejecutarNodo`, que le pone el nombre y el
256
+ workspace en cuanto carga la entidad, y a la envoltura, que los lee al
257
+ anotar. Un solo objeto: si se copiara, lo rellenado se perdería. */
258
+ const ficha = { nodeType, nodeId: id, entrada: payload };
259
+ return conPasoRegistrado(ctx, ficha, () => ejecutarNodo(nodeType, id, payload, ctx, ficha), veredictoDelDespacho);
260
+ }
261
+ async function ejecutarNodo(nodeType, id, payload, ctx,
262
+ /** Se rellena en cuanto la entidad está cargada. Ver `FichaDelNodo`. */
263
+ ficha) {
264
+ const handler = handlers.get(nodeType);
265
+ if (!handler)
266
+ return { executed: false };
267
+ // 1. FETCH
268
+ let entity;
269
+ try {
270
+ entity = await handler.fetchEntity(id, ctx.orgId);
271
+ }
272
+ catch (err) {
273
+ const errorMsg = err instanceof Error ? err.message : String(err);
274
+ ctx.telemetry?.emit({
275
+ organizationId: ctx.orgId,
276
+ level: 'error',
277
+ source: handler.telemetrySource,
278
+ message: `Failed to fetch ${nodeType} ${id}: ${errorMsg}`,
279
+ eventId: ctx.event.id,
280
+ /* La corrida a la que pertenece la fila, con el mismo bautizo que usa
281
+ el pipeline en todas partes: la del evento raíz. Va en LAS SIETE
282
+ emisiones de este fichero, no sólo en la del final — una corrida a
283
+ la que le faltan sus filas de error es peor que no tenerla.
284
+ `architecture.spec.ts` exige que la próxima también lo lleve. */
285
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
286
+ webhookId: ctx.webhook.id,
287
+ webhookName: ctx.webhook.name,
288
+ nodeId: id,
289
+ });
290
+ /* No poder traer la entidad es un FALLO, no un salto: la base no
291
+ contestó, o contestó mal. Sin el `error` el historial lo guardaba como
292
+ `skipped`, indistinguible de un nodo apagado a propósito. */
293
+ return { executed: false, error: `no se pudo traer el nodo: ${errorMsg}` };
294
+ }
295
+ /* Antes de validar, no después: un nodo inactivo también se graba —como
296
+ `skipped`— y sin esto ese paso salía sin nombre ni workspace. Y es un paso
297
+ que se mira precisamente para entender por qué NO pasó nada. */
298
+ if (ficha && entity) {
299
+ ficha.nodeName = entity.name ?? id;
300
+ ficha.workspaceId = entity.workspaceId?.toString();
301
+ }
302
+ // 2. VALIDATE
303
+ if (!entity || !entity.isActive) {
304
+ ctx.telemetry?.emit({
305
+ organizationId: ctx.orgId,
306
+ level: 'warn',
307
+ source: handler.telemetrySource,
308
+ message: `${nodeType} ${id} ${!entity ? 'not found' : 'not active'} — skipped`,
309
+ eventId: ctx.event.id,
310
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
311
+ webhookId: ctx.webhook.id,
312
+ webhookName: ctx.webhook.name,
313
+ nodeId: id,
314
+ metadata: { nodeId: id },
315
+ });
316
+ return { executed: false };
317
+ }
318
+ const nodeName = entity.name ?? id;
319
+ // 2b. LOAD WORKSPACE PAYLOADS — only for nodes that use $() cross-node refs
320
+ (0, workspace_payloads_1.initPayloadRedis)();
321
+ const crossNodeRefs = (0, workspace_payloads_1.extractCrossNodeRefs)(entity);
322
+ if (entity.workspaceId && crossNodeRefs.size > 0) {
323
+ try {
324
+ // Always load fresh for nodes with $() refs — don't reuse from ctx
325
+ const db = ctx.eventModel?.db?.db;
326
+ if (db) {
327
+ const wsPayloads = await (0, workspace_payloads_1.loadWorkspacePayloads)(db, entity.workspaceId.toString(), ctx.orgId, crossNodeRefs);
328
+ // In-memory overrides take priority (e.g. loop iteration payload)
329
+ if (ctx.wsPayloadOverrides)
330
+ Object.assign(wsPayloads, ctx.wsPayloadOverrides);
331
+ ctx.executorContext = {
332
+ ...ctx.executorContext,
333
+ workspacePayloads: wsPayloads,
334
+ };
335
+ }
336
+ }
337
+ catch (err) {
338
+ logger.warn(`Failed to load workspace payloads for ${nodeType} ${id}: ${err instanceof Error ? err.message : err}`);
339
+ }
340
+ }
341
+ else if (ctx.wsPayloadOverrides &&
342
+ Object.keys(ctx.wsPayloadOverrides).length > 0) {
343
+ // No $() refs in entity but overrides exist — still inject them
344
+ ctx.executorContext = {
345
+ ...ctx.executorContext,
346
+ workspacePayloads: {
347
+ ...(ctx.executorContext?.workspacePayloads ?? {}),
348
+ ...ctx.wsPayloadOverrides,
349
+ },
350
+ };
351
+ }
352
+ // 3. PRE-FILTER
353
+ const filterFn = handler.preFilter ?? defaultPreFilter;
354
+ const filterReason = filterFn(entity, payload, ctx.executorContext?.workspacePayloads);
355
+ if (filterReason) {
356
+ ctx.telemetry?.emit({
357
+ organizationId: ctx.orgId,
358
+ level: 'info',
359
+ source: handler.telemetrySource,
360
+ message: `${nodeType} "${nodeName}" ${filterReason} — skipped`,
361
+ eventId: ctx.event.id,
362
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
363
+ webhookId: ctx.webhook.id,
364
+ webhookName: ctx.webhook.name,
365
+ nodeId: id,
366
+ nodeName,
367
+ });
368
+ return { executed: false };
369
+ }
370
+ // 3.5 CHECKPOINT — save BEFORE execute so crash during execute can resume this node
371
+ if (ctx.loopStateId && ctx.updateLoopCheckpoint) {
372
+ ctx
373
+ .updateLoopCheckpoint(ctx.loopStateId, {
374
+ nodeType,
375
+ nodeId: id,
376
+ outputPayload: payload,
377
+ })
378
+ .catch((err) => logger.warn(`[LoopCheckpoint] Pre-exec failed: ${err?.message ?? err}`));
379
+ }
380
+ else if (ctx.eventModel && ctx.event?.id) {
381
+ ctx.eventModel
382
+ .updateOne({ _id: ctx.event.id }, {
383
+ $set: {
384
+ pipelineCheckpoint: {
385
+ nodeType,
386
+ nodeId: id,
387
+ outputPayload: payload,
388
+ depth: ctx.depth,
389
+ updatedAt: new Date(),
390
+ },
391
+ },
392
+ })
393
+ .catch((err) => logger.warn(`[PipelineCheckpoint] Pre-exec failed: ${err?.message ?? err}`));
394
+ }
395
+ // 4. EXECUTE (with per-node timeout — AWS Step Functions pattern)
396
+ const timeoutSec = entity.timeoutSeconds ??
397
+ pipeline_constants_1.NODE_TIMEOUT_DEFAULTS[nodeType] ??
398
+ pipeline_constants_1.DEFAULT_NODE_TIMEOUT_SEC;
399
+ let result;
400
+ /* El temporizador se guarda para poder APAGARLO en el `finally`. Antes no se
401
+ limpiaba: la carrera la gana el nodo —el caso normal, en milisegundos— y
402
+ el `setTimeout` seguía vivo hasta cumplir su plazo, que por defecto son
403
+ decenas de segundos. O sea un temporizador colgando por CADA nodo
404
+ ejecutado, con su closure dentro, y bajo carga miles a la vez. Lo delató
405
+ un test: el worker de jest se negaba a cerrar. */
406
+ let reloj;
407
+ try {
408
+ result = await Promise.race([
409
+ handler.execute(entity, payload, ctx),
410
+ new Promise((_, reject) => {
411
+ reloj = setTimeout(() => reject(new Error(`Node timeout after ${timeoutSec}s`)), timeoutSec * 1000);
412
+ }),
413
+ ]);
414
+ }
415
+ catch (err) {
416
+ const errorMsg = err instanceof Error ? err.message : String(err);
417
+ const errorStack = err instanceof Error ? err.stack : undefined;
418
+ logger.error(`[Lifecycle] ${nodeType} "${nodeName}" FAILED: ${errorMsg}${errorStack ? `\n${errorStack}` : ''}`);
419
+ ctx.telemetry?.emit({
420
+ organizationId: ctx.orgId,
421
+ level: 'error',
422
+ source: handler.telemetrySource,
423
+ message: `${nodeType} "${nodeName}" FAILED: ${errorMsg}`,
424
+ eventId: ctx.event.id,
425
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
426
+ webhookId: ctx.webhook.id,
427
+ webhookName: ctx.webhook.name,
428
+ nodeId: id,
429
+ nodeName,
430
+ metadata: { error: errorMsg },
431
+ });
432
+ // Notify loop handler if inside a loop iteration
433
+ if (ctx.loopStateId && ctx.onLoopIterationError) {
434
+ await ctx
435
+ .onLoopIterationError(ctx.loopStateId, errorMsg, false)
436
+ .catch((e) => logger.warn(`[Lifecycle] onLoopIterationError failed: ${e?.message ?? e}`));
437
+ }
438
+ /* El nodo reventó o se le acabó el tiempo. Este `catch` se traga la
439
+ excepción y devuelve `executed: false`, así que el `try/catch` de la
440
+ envoltura NO se entera: el fallo tiene que viajar en el resultado o el
441
+ historial lo guarda como `skipped`. */
442
+ return { executed: false, error: errorMsg };
443
+ }
444
+ finally {
445
+ /* Gane quien gane la carrera. También en el camino del error: un nodo que
446
+ revienta a los 20 ms no tiene por qué dejar su reloj andando. */
447
+ if (reloj)
448
+ clearTimeout(reloj);
449
+ }
450
+ const success = result.statusCode < 400;
451
+ // ── Node Circuit Breaker — auto-pause after consecutive failures ──
452
+ if (ctx.mode === 'prod') {
453
+ const redis = (0, workspace_payloads_1.getPayloadRedis)();
454
+ if (redis) {
455
+ const cbKey = `node:cb:${nodeType}:${id}`;
456
+ const MAX_CONSECUTIVE_FAILURES = 5;
457
+ const CB_TTL_SEC = 300; // 5 minutes — resets if node succeeds within window
458
+ if (success) {
459
+ redis
460
+ .del(cbKey)
461
+ .catch((0, swallow_1.onFailure)(logger, `circuit-breaker reset ${cbKey}`));
462
+ }
463
+ else {
464
+ redis
465
+ .incr(cbKey)
466
+ .then(async (count) => {
467
+ if (count === 1)
468
+ await redis.expire(cbKey, CB_TTL_SEC);
469
+ if (count >= MAX_CONSECUTIVE_FAILURES) {
470
+ logger.error(`[CircuitBreaker] ${nodeType} "${nodeName}" failed ${count} times in ${CB_TTL_SEC}s — auto-pausing`);
471
+ ctx.telemetry?.emit({
472
+ organizationId: ctx.orgId,
473
+ level: 'error',
474
+ source: handler.telemetrySource,
475
+ message: `${nodeType} "${nodeName}" auto-paused after ${count} consecutive failures`,
476
+ eventId: ctx.event.id,
477
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
478
+ webhookId: ctx.webhook.id,
479
+ webhookName: ctx.webhook.name,
480
+ nodeId: id,
481
+ nodeName,
482
+ metadata: {
483
+ consecutiveFailures: count,
484
+ lastStatusCode: result.statusCode,
485
+ lastError: result.responseBody?.slice(0, 200),
486
+ },
487
+ });
488
+ // Auto-pause the node
489
+ try {
490
+ await handler
491
+ .fetchEntity(id, ctx.orgId)
492
+ .then(async (e) => {
493
+ if (e?.save) {
494
+ e.isActive = false;
495
+ await e.save();
496
+ }
497
+ });
498
+ }
499
+ catch (err) {
500
+ logger.warn(`[CircuitBreaker] Failed to auto-pause ${nodeType} ${id}: ${err.message}`);
501
+ }
502
+ await redis
503
+ .del(cbKey)
504
+ .catch((0, swallow_1.onFailure)(logger, `circuit-breaker clear ${cbKey}`));
505
+ }
506
+ })
507
+ .catch((0, swallow_1.onFailure)(logger, `circuit-breaker bookkeeping ${cbKey}`));
508
+ }
509
+ }
510
+ }
511
+ // If execution failed inside a loop, notify loop handler (skip/retry/stop)
512
+ if (!success && ctx.loopStateId && ctx.onLoopIterationError) {
513
+ const errorMsg = result.responseBody?.slice(0, 200) ?? `HTTP ${result.statusCode}`;
514
+ const retriedTransient = !!result?._retriedTransient;
515
+ await ctx
516
+ .onLoopIterationError(ctx.loopStateId, errorMsg, retriedTransient)
517
+ .catch((e) => logger.warn(`[Lifecycle] onLoopIterationError failed: ${e?.message ?? e}`));
518
+ /* Falló DENTRO de un loop. El `executed: false` es para que el pipeline no
519
+ siga aguas abajo —de eso se encarga la política del loop—, pero para el
520
+ historial es un fallo con todas las letras. Y el resultado viaja: es lo
521
+ que se mira para saber qué contestó. */
522
+ return { executed: false, result, error: mensajeDeFallo(result) };
523
+ }
524
+ // 5. GET OUTPUT PAYLOAD
525
+ const getOutput = handler.getOutputPayload ?? ((_e, r, inp) => r.outputPayload ?? inp);
526
+ const outputPayload = getOutput(entity, result, payload);
527
+ // 6. SAVE LAST PAYLOAD + EMIT REAL-TIME UPDATE
528
+ const buildLP = handler.buildLastPayload ?? defaultBuildLastPayload;
529
+ const lastPayload = buildLP(entity, result, outputPayload ?? {});
530
+ // Don't persist error responses as lastPayload — keeps last successful output on reload
531
+ // Schema validator always returns 200 (validity is in responseBody.valid), so this is safe
532
+ if (success) {
533
+ await handler
534
+ .saveLastPayload(id, lastPayload)
535
+ .catch((err) => logger.warn(`Failed to save lastPayload for ${nodeType} ${id}: ${err?.message ?? err}`));
536
+ // Warm Redis cache for $() cross-node lookups
537
+ if (entity.workspaceId && entity.name) {
538
+ (0, workspace_payloads_1.cacheNodePayload)(entity.workspaceId.toString(), entity.name, lastPayload);
539
+ }
540
+ }
541
+ if (ctx.gateway) {
542
+ // For schema validator: include specific valid/invalid payload fields for live updates
543
+ const extraPayloadFields = {};
544
+ if (handler.nodeType === 'schemaValidator' && lastPayload) {
545
+ const isInvalid = lastPayload?.error === 'Schema Validation Failed';
546
+ if (isInvalid) {
547
+ extraPayloadFields.lastInvalidPayload = lastPayload;
548
+ }
549
+ else {
550
+ extraPayloadFields.lastValidPayload = lastPayload;
551
+ }
552
+ }
553
+ ctx.gateway.emitPipelineStep(ctx.orgId, {
554
+ eventId: ctx.event?.id,
555
+ nodeType: handler.nodeType,
556
+ nodeId: id,
557
+ nodeName,
558
+ status: success ? 'success' : 'error',
559
+ statusCode: result.statusCode,
560
+ durationMs: result.latencyMs,
561
+ ...(success ? { lastPayload } : { error: result.responseBody }),
562
+ ...extraPayloadFields,
563
+ });
564
+ // Aviso aparte para la sección OUTPUT de la página de detalle. No sirve
565
+ // `pipeline:step` para esto: lo escucha quien sigue una corrida concreta,
566
+ // mientras que esto le interesa a cualquiera que tenga el nodo abierto,
567
+ // venga la ejecución de donde venga.
568
+ if (success && lastPayload != null) {
569
+ ctx.gateway.emitNodePayload(ctx.orgId, {
570
+ nodeType: handler.nodeType,
571
+ nodeId: id,
572
+ lastPayload,
573
+ });
574
+ }
575
+ }
576
+ // 7. DELIVERY (prod only)
577
+ if (ctx.mode === 'prod' && handler.buildDeliveryRecord && ctx.deliveryModel) {
578
+ const deliveryData = handler.buildDeliveryRecord(entity, result, success, ctx);
579
+ if (deliveryData) {
580
+ ctx.deliveryModel
581
+ .create(deliveryData)
582
+ .catch((err) => logger.warn(`Failed to create delivery record for ${nodeType} ${id}: ${err?.message ?? err}`));
583
+ }
584
+ }
585
+ // 8. TELEMETRY — handler override or unified format
586
+ if (handler.emitTelemetry) {
587
+ // Await the handler's emitter — when it returns a promise (e.g. it
588
+ // internally calls telemetry.emitAndWait for recovery safety), we
589
+ // need to actually wait for the Mongo write to land before
590
+ // declaring the node done. Sync handlers that return undefined
591
+ // resolve immediately and add no overhead.
592
+ await handler.emitTelemetry(entity, result, success, outputPayload, ctx, payload);
593
+ }
594
+ else {
595
+ const handlerMeta = handler.getTelemetryMetadata?.(entity, result) ?? {};
596
+ const entry = {
597
+ organizationId: ctx.orgId,
598
+ level: (success ? 'info' : 'error'),
599
+ source: handler.telemetrySource,
600
+ message: success
601
+ ? `${nodeType} "${nodeName}" executed (${result.latencyMs}ms)`
602
+ : `${nodeType} "${nodeName}" FAILED (${result.statusCode})`,
603
+ eventId: ctx.event.id,
604
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
605
+ webhookId: ctx.webhook.id,
606
+ webhookName: ctx.webhook.name,
607
+ nodeId: id,
608
+ nodeName,
609
+ statusCode: result.statusCode,
610
+ durationMs: result.latencyMs,
611
+ success,
612
+ /* `payload` es lo que ENTRÓ a este nodo, y hasta hoy no se guardaba en
613
+ producción: un nodo que fallaba dejaba un log que decía qué salió
614
+ mal pero no con qué datos. Ver `log-metadata.ts`. */
615
+ metadata: (0, log_metadata_1.construirMetadatosDeLog)({
616
+ nodeType,
617
+ nodeName,
618
+ payload,
619
+ responseBody: result.responseBody,
620
+ statusCode: result.statusCode,
621
+ success,
622
+ extra: handlerMeta,
623
+ }),
624
+ };
625
+ // Expensive/non-idempotent nodes (AI / LLM) opt in to await the
626
+ // telemetry write — guarantees `hasRecentSuccess` will see the
627
+ // success on recovery, preventing a re-run that would double-charge
628
+ // the provider.
629
+ if (handler.awaitTelemetry && success && ctx.telemetry?.emitAndWait) {
630
+ await ctx.telemetry.emitAndWait(entry);
631
+ }
632
+ else {
633
+ ctx.telemetry?.emit(entry);
634
+ }
635
+ }
636
+ // 8.5 SYNC MODE — resolve ingress waiter if handler provides a sync result
637
+ if (handler.getSyncResult && ctx.gateway?.resolveSyncWaiter) {
638
+ const syncResult = handler.getSyncResult(entity, result, outputPayload, ctx);
639
+ if (syncResult) {
640
+ ctx.gateway.resolveSyncWaiter(ctx.event.id, syncResult);
641
+ }
642
+ }
643
+ // 8.55 — checkpoint was moved to step 3.5 (pre-execute) so crashes during execute are recoverable
644
+ // 8.6 LOOP-BACK SIGNAL (success or failure)
645
+ // If this node is a loop-back target, signal the loop immediately to advance.
646
+ // On failure: skip downstream, advance to next item.
647
+ // On success: after downstream dispatch completes, advance to next item.
648
+ // Read loopBackTargetId via raw DB query since Mongoose strict mode may not include it.
649
+ logger.debug(`[LoopBack] Check: nodeType=${nodeType} id=${id} hasHandleLoop=${!!ctx.handleLoopIteration} hasRawEvent=${!!ctx.rawEvent} hasRawWebhook=${!!ctx.rawWebhook}`);
650
+ if (ctx.handleLoopIteration && ctx.rawEvent && ctx.rawWebhook) {
651
+ try {
652
+ const dispatchConfig = (0, node_dispatch_registry_1.getNodeDispatchConfig)(nodeType);
653
+ logger.debug(`[LoopBack] dispatchConfig=${dispatchConfig?.collection ?? 'null'} hasEventModel=${!!ctx.eventModel} hasDb=${!!ctx.eventModel?.db?.db}`);
654
+ if (dispatchConfig) {
655
+ const db = ctx.eventModel?.db?.db;
656
+ if (db) {
657
+ const rawDoc = await db
658
+ .collection(dispatchConfig.collection)
659
+ .findOne({ _id: new mongoose_1.Types.ObjectId(id) }, { projection: { loopBackTargetId: 1 } });
660
+ logger.debug(`[LoopBack] rawDoc loopBackTargetId=${rawDoc?.loopBackTargetId ?? 'null'} for ${nodeType}:${id}`);
661
+ if (rawDoc?.loopBackTargetId) {
662
+ const loopDoc = await db
663
+ .collection('loopnodes')
664
+ .findOne({ _id: rawDoc.loopBackTargetId, isActive: true });
665
+ if (loopDoc) {
666
+ if (!success) {
667
+ // Failed — send loop-back immediately (before downstream return)
668
+ logger.log(`[LoopBack] Node "${nodeName}" failed — sending immediate loop-back to loop "${loopDoc.name}"`);
669
+ await ctx.handleLoopIteration(ctx.rawEvent, ctx.rawWebhook, loopDoc, {}, ctx.rawCtx ?? { orgId: ctx.orgId }, ctx.depth);
670
+ }
671
+ else {
672
+ // Success — defer loop-back until AFTER downstream dispatch (step 9)
673
+ ctx._pendingLoopBack = {
674
+ loopDoc,
675
+ nodeName,
676
+ payload: outputPayload ?? payload,
677
+ };
678
+ }
679
+ }
680
+ }
681
+ }
682
+ }
683
+ }
684
+ catch (err) {
685
+ logger.warn(`[LoopBack] Failed to check loop-back: ${err instanceof Error ? err.message : err}`);
686
+ }
687
+ }
688
+ // 9. DOWNSTREAM DISPATCH
689
+ if (!success && handler.propagateDownstream !== false) {
690
+ await sendPendingLoopBack(ctx, logger);
691
+ return { executed: true, result };
692
+ }
693
+ if (outputPayload === null) {
694
+ await sendPendingLoopBack(ctx, logger);
695
+ return { executed: true, result };
696
+ }
697
+ if (handler.propagateDownstream === false) {
698
+ await sendPendingLoopBack(ctx, logger);
699
+ return { executed: true, result };
700
+ }
701
+ const downstreamPayload = handler.downstreamPayload === 'original'
702
+ ? payload
703
+ : (outputPayload ?? payload);
704
+ const allOutputs = handler.getOutputNodes
705
+ ? handler.getOutputNodes(entity, result)
706
+ : (0, node_dispatch_registry_1.getAllOutputs)(entity);
707
+ logger.debug(`[Downstream] ${nodeType} "${nodeName}" → ${allOutputs.length} targets: ${JSON.stringify(allOutputs)}`);
708
+ if (allOutputs.length === 0) {
709
+ await sendPendingLoopBack(ctx, logger);
710
+ return { executed: true, result };
711
+ }
712
+ await dispatchDownstream(allOutputs, downstreamPayload, ctx);
713
+ // Cross-workspace dispatch — fires AFTER local downstream so the
714
+ // call-graph stays predictable: in-canvas children first, then any
715
+ // FlowLinks pointing out of this node. callStack lives on rawCtx so
716
+ // sync-mode chains propagate it; absence means "starting fresh".
717
+ if (ctx.dispatchFlowLinksFor) {
718
+ try {
719
+ await ctx.dispatchFlowLinksFor(nodeType, id, downstreamPayload, ctx.rawCtx?.callStack ?? []);
720
+ }
721
+ catch (err) {
722
+ logger.warn(`[FlowLink] dispatchFlowLinksFor ${nodeType}:${id} failed: ${err instanceof Error ? err.message : err}`);
723
+ }
724
+ }
725
+ await sendPendingLoopBack(ctx, logger);
726
+ return { executed: true, result };
727
+ }
728
+ /** Execute deferred loop-back signal if pending */
729
+ async function sendPendingLoopBack(ctx, logger) {
730
+ const pendingLoopBack = ctx._pendingLoopBack;
731
+ if (!pendingLoopBack)
732
+ return;
733
+ try {
734
+ logger.log(`[LoopBack] Node "${pendingLoopBack.nodeName}" succeeded — sending loop-back to loop "${pendingLoopBack.loopDoc.name}"`);
735
+ await ctx.handleLoopIteration(ctx.rawEvent, ctx.rawWebhook, pendingLoopBack.loopDoc, pendingLoopBack.payload ?? {}, ctx.rawCtx ?? { orgId: ctx.orgId }, ctx.depth);
736
+ }
737
+ catch (err) {
738
+ logger.warn(`[LoopBack] Failed to send success loop-back: ${err instanceof Error ? err.message : err}`);
739
+ }
740
+ }
741
+ // ─── Downstream dispatch helper ──────────────────────────────────
742
+ async function dispatchDownstream(outputs, payload, ctx) {
743
+ const webhookOutputs = outputs.filter((o) => o.nodeType === 'webhook');
744
+ const transformOutputs = outputs.filter((o) => o.nodeType === 'transform');
745
+ const pipelineOutputs = outputs.filter((o) => o.nodeType !== 'webhook' && o.nodeType !== 'transform');
746
+ // Webhook outputs: create child events
747
+ if (ctx.eventModel && ctx.webhookModel && ctx.onEventCreated) {
748
+ for (const out of webhookOutputs) {
749
+ const outEp = await ctx.webhookModel.findById(out.nodeId);
750
+ if (!outEp || !outEp.isActive || !(0, output_webhook_scope_1.belongsToOrg)(outEp, ctx.orgId))
751
+ continue;
752
+ const childEvent = await ctx.eventModel.create({
753
+ webhookId: out.nodeId,
754
+ payload,
755
+ rawBody: JSON.stringify(payload),
756
+ headers: ctx.event.headers ?? {},
757
+ sourceIp: ctx.event.sourceIp ?? '0.0.0.0',
758
+ status: 'pending',
759
+ eventType: ctx.event.eventType || 'pipeline',
760
+ correlationId: ctx.event.correlationId ?? ctx.event.id,
761
+ });
762
+ await ctx.onEventCreated(childEvent, outEp, {
763
+ ...ctx.rawCtx,
764
+ propagationDepth: ctx.depth + 1,
765
+ });
766
+ }
767
+ }
768
+ // Transform outputs — dispatch through unified lifecycle (same as all other nodes)
769
+ for (const out of transformOutputs) {
770
+ await dispatchSingleNode('transform', out.nodeId, payload, {
771
+ ...ctx,
772
+ depth: ctx.depth + 1,
773
+ });
774
+ }
775
+ // Pipeline outputs (other node types)
776
+ if (pipelineOutputs.length > 0 && ctx.dispatchOutputNodes) {
777
+ await ctx.dispatchOutputNodes(pipelineOutputs.map((o) => ({
778
+ nodeType: o.nodeType,
779
+ nodeId: new mongoose_1.Types.ObjectId(o.nodeId),
780
+ })), payload, ctx.rawEvent, ctx.rawWebhook, ctx.rawCtx, ctx.depth + 1);
781
+ }
782
+ }