Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/modules/temporal/activities/action-nodes.activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export interface ActionNodeActivities {
nodeData: any;
waitResult: 'success' | 'timeout' | 'cancelled';
}): Promise<string | undefined>;
resolveWaitHandle(input: {
nodeId: string;
contactId: string;
sessionId: string;
nodeData: any;
waitResult: 'success' | 'timeout' | 'cancelled';
}): Promise<string | undefined>;
}

// Node instances for modular execution - using lazy initialization to avoid Temporal context issues
Expand Down Expand Up @@ -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<string | undefined> {
// 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
Expand Down
97 changes: 97 additions & 0 deletions src/modules/temporal/activities/nodes/wait.node.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { WaitNode, WaitNodeInput } from './wait.node';

describe('WaitNode.resolveWaitHandle (EVO-1912)', () => {
const make = (
nodeData: Partial<WaitNodeInput['nodeData']> = {},
): 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');
});
});
});
47 changes: 43 additions & 4 deletions src/modules/temporal/activities/nodes/wait.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
42 changes: 34 additions & 8 deletions src/modules/temporal/workflows/journey-execution.workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading