@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.
- package/dist/code-runner.d.ts +20 -0
- package/dist/code-runner.js +138 -0
- package/dist/contratos.d.ts +121 -0
- package/dist/contratos.js +24 -0
- package/dist/dto/output-node.dto.d.ts +19 -0
- package/dist/dto/output-node.dto.js +96 -0
- package/dist/ensure-meta.d.ts +22 -0
- package/dist/ensure-meta.js +35 -0
- package/dist/execute-with-iteration.d.ts +18 -0
- package/dist/execute-with-iteration.js +66 -0
- package/dist/filter-utils.d.ts +22 -0
- package/dist/filter-utils.js +178 -0
- package/dist/handler-helpers.d.ts +21 -0
- package/dist/handler-helpers.js +53 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +73 -0
- package/dist/log-metadata.d.ts +191 -0
- package/dist/log-metadata.js +375 -0
- package/dist/node-dispatch.registry.d.ts +32 -0
- package/dist/node-dispatch.registry.js +45 -0
- package/dist/node-executors.d.ts +299 -0
- package/dist/node-executors.js +555 -0
- package/dist/node-lifecycle.d.ts +399 -0
- package/dist/node-lifecycle.js +782 -0
- package/dist/normalize-nodes.d.ts +18 -0
- package/dist/normalize-nodes.js +22 -0
- package/dist/output-node-ref.schema.d.ts +82 -0
- package/dist/output-node-ref.schema.js +90 -0
- package/dist/output-webhook-scope.d.ts +36 -0
- package/dist/output-webhook-scope.js +42 -0
- package/dist/payload-preview.d.ts +10 -0
- package/dist/payload-preview.js +39 -0
- package/dist/pipeline.constants.d.ts +29 -0
- package/dist/pipeline.constants.js +51 -0
- package/dist/pre-request-pool.d.ts +58 -0
- package/dist/pre-request-pool.js +308 -0
- package/dist/pre-request-runner-source.d.ts +28 -0
- package/dist/pre-request-runner-source.js +411 -0
- package/dist/regex-de-inquilino.d.ts +15 -0
- package/dist/regex-de-inquilino.js +98 -0
- package/dist/request-context.d.ts +18 -0
- package/dist/request-context.js +34 -0
- package/dist/retry-transient.d.ts +54 -0
- package/dist/retry-transient.js +67 -0
- package/dist/retry-utils.d.ts +17 -0
- package/dist/retry-utils.js +23 -0
- package/dist/schema-validator-utils.d.ts +9 -0
- package/dist/schema-validator-utils.js +140 -0
- package/dist/ssrf-guard.d.ts +202 -0
- package/dist/ssrf-guard.js +917 -0
- package/dist/swallow.d.ts +52 -0
- package/dist/swallow.js +55 -0
- package/dist/template-render.d.ts +33 -0
- package/dist/template-render.js +43 -0
- package/dist/try-parse.d.ts +41 -0
- package/dist/try-parse.js +69 -0
- package/dist/workspace-payloads.d.ts +66 -0
- package/dist/workspace-payloads.js +496 -0
- 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
|
+
}
|