From 3f9ba98e507ff87aa88e601f060b1a0fc3137caa Mon Sep 17 00:00:00 2001 From: Danilo Leone Date: Wed, 24 Jun 2026 23:21:37 -0300 Subject: [PATCH] fix(journey): roteia wait multi-saida por sourceHandle wait-success/wait-otherwise (EVO-1912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O no Wait com multiplas saidas (enableFallback / time_or_condition) roteava por nodeData.successNodeId/otherwiseNodeId/nextNodeId — campos que o FE nunca grava. Com eles nulos, o workflow caia no fallback outgoingEdges[0].target sem olhar o handle, tornando o roteamento dependente da ordem de insercao das arestas (nao-deterministico). Agora o backend resolve o handle de saida a partir do resultado do wait (success -> wait-success, timeout/cancelled -> wait-otherwise) e o workflow casa edge.sourceHandle === nextNodeHandle, igual conditional/split. Waits de saida unica retornam null e seguem a unica aresta. Roteamento legacy por id em node-data preservado para journeys que o persistam. - wait.node.ts: hasMultipleOutputs() + resolveWaitHandle() + constantes de handle - action-nodes.activities.ts: activity resolveWaitHandle - journey-execution.workflow.ts: resolve handle no resume do wait e seta nodeResult.nextNodeHandle (sobrevive a reassign generica de nextNodeId) - wait.node.spec.ts: 11 testes (handles, single-output, legacy id-based) --- .../activities/action-nodes.activities.ts | 30 ++++++ .../activities/nodes/wait.node.spec.ts | 97 +++++++++++++++++++ .../temporal/activities/nodes/wait.node.ts | 47 ++++++++- .../workflows/journey-execution.workflow.ts | 42 ++++++-- 4 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src/modules/temporal/activities/nodes/wait.node.spec.ts diff --git a/src/modules/temporal/activities/action-nodes.activities.ts b/src/modules/temporal/activities/action-nodes.activities.ts index 6750448..a14576c 100644 --- a/src/modules/temporal/activities/action-nodes.activities.ts +++ b/src/modules/temporal/activities/action-nodes.activities.ts @@ -156,6 +156,13 @@ export interface ActionNodeActivities { nodeData: any; waitResult: 'success' | 'timeout' | 'cancelled'; }): Promise; + resolveWaitHandle(input: { + nodeId: string; + contactId: string; + sessionId: string; + nodeData: any; + waitResult: 'success' | 'timeout' | 'cancelled'; + }): Promise; } // Node instances for modular execution - using lazy initialization to avoid Temporal context issues @@ -571,6 +578,29 @@ export const actionNodeActivities: ActionNodeActivities = { // Convert null to undefined return Promise.resolve(result || undefined); }, + + resolveWaitHandle(input: { + nodeId: string; + contactId: string; + sessionId: string; + nodeData: any; + waitResult: 'success' | 'timeout' | 'cancelled'; + }): Promise { + // Map the wait result to the FE outgoing-edge handle + // (wait-success / wait-otherwise) so the workflow can route the next node + // by edge.sourceHandle, just like conditional/split nodes. + const handle = WaitNode.resolveWaitHandle( + { + nodeId: input.nodeId, + contactId: input.contactId, + sessionId: input.sessionId, + nodeData: input.nodeData, + }, + input.waitResult, + ); + // Convert null to undefined (single-output waits → no handle) + return Promise.resolve(handle || undefined); + }, }; // time conversion and wait time helpers moved to ../utils/wait-time.util diff --git a/src/modules/temporal/activities/nodes/wait.node.spec.ts b/src/modules/temporal/activities/nodes/wait.node.spec.ts new file mode 100644 index 0000000..114449d --- /dev/null +++ b/src/modules/temporal/activities/nodes/wait.node.spec.ts @@ -0,0 +1,97 @@ +import { WaitNode, WaitNodeInput } from './wait.node'; + +describe('WaitNode.resolveWaitHandle (EVO-1912)', () => { + const make = ( + nodeData: Partial = {}, + ): WaitNodeInput => ({ + nodeId: 'wait-1', + contactId: 'contact-1', + sessionId: 'session-1', + nodeData: { waitType: 'event', ...nodeData } as WaitNodeInput['nodeData'], + }); + + describe('hasMultipleOutputs', () => { + it('is true when enableFallback is set', () => { + expect( + WaitNode.hasMultipleOutputs(make({ enableFallback: true }).nodeData), + ).toBe(true); + }); + + it('is true for time_or_condition waits', () => { + expect( + WaitNode.hasMultipleOutputs(make({ waitType: 'time_or_condition' }).nodeData), + ).toBe(true); + }); + + it('is false for a plain single-output wait', () => { + expect(WaitNode.hasMultipleOutputs(make({ waitType: 'time' }).nodeData)).toBe( + false, + ); + }); + }); + + describe('multi-output routing by FE handle', () => { + it('routes success to the wait-success handle (no node-data ids needed)', () => { + const handle = WaitNode.resolveWaitHandle( + make({ enableFallback: true }), + 'success', + ); + expect(handle).toBe('wait-success'); + }); + + it('routes timeout to the wait-otherwise handle', () => { + const handle = WaitNode.resolveWaitHandle( + make({ enableFallback: true }), + 'timeout', + ); + expect(handle).toBe('wait-otherwise'); + }); + + it('routes cancelled to the wait-otherwise (fallback) handle', () => { + const handle = WaitNode.resolveWaitHandle( + make({ waitType: 'time_or_condition' }), + 'cancelled', + ); + expect(handle).toBe('wait-otherwise'); + }); + + it('uses the FE handle constants', () => { + expect(WaitNode.SUCCESS_HANDLE).toBe('wait-success'); + expect(WaitNode.OTHERWISE_HANDLE).toBe('wait-otherwise'); + }); + }); + + describe('single-output waits', () => { + it('returns null so the workflow follows the single outgoing edge', () => { + expect( + WaitNode.resolveWaitHandle(make({ waitType: 'time' }), 'success'), + ).toBeNull(); + expect( + WaitNode.resolveWaitHandle(make({ waitType: 'time' }), 'timeout'), + ).toBeNull(); + }); + }); + + describe('legacy id-based processWaitCompletion is preserved', () => { + it('honours successNodeId / otherwiseNodeId when present', () => { + const input = make({ + enableFallback: true, + successNodeId: 'node-ok', + otherwiseNodeId: 'node-timeout', + }); + expect(WaitNode.processWaitCompletion(input, 'success')).toBe('node-ok'); + expect(WaitNode.processWaitCompletion(input, 'timeout')).toBe('node-timeout'); + }); + + it('returns null for multi-output when ids are absent (FE case → handle routing)', () => { + const input = make({ enableFallback: true }); + expect(WaitNode.processWaitCompletion(input, 'success')).toBeNull(); + expect(WaitNode.processWaitCompletion(input, 'timeout')).toBeNull(); + }); + + it('returns nextNodeId for single-output waits', () => { + const input = make({ waitType: 'time', nextNodeId: 'node-next' }); + expect(WaitNode.processWaitCompletion(input, 'success')).toBe('node-next'); + }); + }); +}); diff --git a/src/modules/temporal/activities/nodes/wait.node.ts b/src/modules/temporal/activities/nodes/wait.node.ts index 5bd00d7..dfcc03c 100644 --- a/src/modules/temporal/activities/nodes/wait.node.ts +++ b/src/modules/temporal/activities/nodes/wait.node.ts @@ -114,18 +114,57 @@ export class WaitNode extends BaseNode { }); } + // Source handles drawn by the FE Wait node (WaitNode.tsx) for multi-output waits. + static readonly SUCCESS_HANDLE = 'wait-success'; + static readonly OTHERWISE_HANDLE = 'wait-otherwise'; + + /** + * Whether the Wait node exposes the two branch outputs (success vs + * timeout/fallback) in the editor. Mirrors `needsMultipleOutputs` in the FE. + */ + static hasMultipleOutputs(nodeData: WaitNodeInput['nodeData']): boolean { + return Boolean( + nodeData?.enableFallback || nodeData?.waitType === 'time_or_condition', + ); + } + + /** + * Resolve which outgoing edge handle the workflow should follow once the wait + * completes. For multi-output waits this maps the result to the FE handles + * (`wait-success` / `wait-otherwise`) so the workflow can match the edge by + * `sourceHandle` — the same contract used by conditional/split nodes. + * Single-output waits return `null` (workflow takes the only outgoing edge). + */ + static resolveWaitHandle( + input: WaitNodeInput, + result: 'success' | 'timeout' | 'cancelled', + ): string | null { + if (!WaitNode.hasMultipleOutputs(input.nodeData)) { + // Single output - workflow follows the single outgoing edge. + return null; + } + + return result === 'success' + ? WaitNode.SUCCESS_HANDLE + : WaitNode.OTHERWISE_HANDLE; + } + /** - * Process wait completion (called by signal handler) + * Process wait completion (called by signal handler). + * + * Legacy id-based routing kept for journeys that explicitly persist + * `successNodeId` / `otherwiseNodeId` / `nextNodeId` in node-data. The FE + * never writes these fields, so handle-based routing via `resolveWaitHandle` + * is the primary mechanism; this returns `null` in that case and the workflow + * falls back to matching the outgoing edge by `sourceHandle`. */ static processWaitCompletion( input: WaitNodeInput, result: 'success' | 'timeout' | 'cancelled', ): string | null { const { nodeData } = input; - const hasMultipleOutputs = - nodeData.enableFallback || nodeData.waitType === 'time_or_condition'; - if (!hasMultipleOutputs) { + if (!WaitNode.hasMultipleOutputs(nodeData)) { // Single output - always go to default next node return nodeData.nextNodeId || null; } diff --git a/src/modules/temporal/workflows/journey-execution.workflow.ts b/src/modules/temporal/workflows/journey-execution.workflow.ts index 126877f..75ad495 100644 --- a/src/modules/temporal/workflows/journey-execution.workflow.ts +++ b/src/modules/temporal/workflows/journey-execution.workflow.ts @@ -588,14 +588,40 @@ export async function JourneyExecutionWorkflow( } isWaiting = false; - // Determine next node based on wait result using activity - nextNodeId = await actionNodeActivities.processWaitCompletion({ - nodeId: currentNode.id, - contactId: input.contactId, - sessionId: input.sessionId, - nodeData: currentNode.data, - waitResult: waitResult.result, - }); + // Determine next node based on wait result. + // + // Primary routing is by outgoing-edge handle: the FE Wait node + // draws `wait-success` / `wait-otherwise` source handles for + // multi-output waits, so we resolve the handle and let the shared + // edge-matching logic below (sourceHandle === nextNodeHandle) + // pick the deterministic target — same contract as + // conditional/split nodes. (EVO-1912) + // + // Legacy id-based routing (successNodeId/otherwiseNodeId/ + // nextNodeId in node-data) is preserved for journeys that + // explicitly persist those ids; the FE never writes them, so it + // returns undefined and we fall through to handle routing. + const [resolvedNextNodeId, resolvedHandle] = await Promise.all([ + actionNodeActivities.processWaitCompletion({ + nodeId: currentNode.id, + contactId: input.contactId, + sessionId: input.sessionId, + nodeData: currentNode.data, + waitResult: waitResult.result, + }), + actionNodeActivities.resolveWaitHandle({ + nodeId: currentNode.id, + contactId: input.contactId, + sessionId: input.sessionId, + nodeData: currentNode.data, + waitResult: waitResult.result, + }), + ]); + + // Persist on nodeResult so it survives the generic + // `nextNodeId = nodeResult.nextNodeId` reassignment below. + nodeResult.nextNodeId = resolvedNextNodeId; + nodeResult.nextNodeHandle = resolvedHandle; // Update variables with wait completion info nodeResult.variables = {