Skip to content
Draft
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
3 changes: 2 additions & 1 deletion apps/api/src/controllers/Workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ export class Workflows {
public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body;
const {fromStepId, toStepId, condition, waitOutcome, priority} = req.body;

if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
Expand All @@ -325,6 +325,7 @@ export class Workflows {
fromStepId,
toStepId,
condition,
waitOutcome,
priority,
});

Expand Down
140 changes: 65 additions & 75 deletions apps/api/src/services/WorkflowExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
WorkflowStep,
WorkflowStepExecution,
} from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowWaitOutcome} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
import dns from 'node:dns/promises';
Expand All @@ -30,6 +30,7 @@ type WorkflowStepWithTransitions = WorkflowStep & {
outgoingTransitions?: Array<{
id: string;
condition: Prisma.JsonValue;
waitOutcome: WorkflowWaitOutcome | null;
priority: number;
toStep: WorkflowStep;
}>;
Expand Down Expand Up @@ -328,71 +329,28 @@ export class WorkflowExecutionService {
return;
}

const result = {
waitOutcome: WorkflowWaitOutcome.TIMEOUT,
timedOut: true,
eventName:
stepExecution.step.config &&
typeof stepExecution.step.config === 'object' &&
'eventName' in stepExecution.step.config
? stepExecution.step.config.eventName
: undefined,
};

// Mark step as completed with timeout
await prisma.workflowStepExecution.update({
where: {id: stepExecution.id},
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: {
timedOut: true,
eventName:
stepExecution.step.config &&
typeof stepExecution.step.config === 'object' &&
'eventName' in stepExecution.step.config
? stepExecution.step.config.eventName
: undefined,
},
output: toPrismaJson(result),
},
});

// Continue workflow - find transitions with timeout/fallback logic
const transitions = stepExecution.step.outgoingTransitions || [];
const fallbackTransition = transitions.find(
t =>
(t.condition &&
typeof t.condition === 'object' &&
'branch' in t.condition &&
t.condition.branch === 'timeout') ||
(t.condition && typeof t.condition === 'object' && 'fallback' in t.condition && t.condition.fallback === true),
);

if (fallbackTransition) {
// Follow timeout branch
await prisma.workflowExecution.update({
where: {id: stepExecution.executionId},
data: {
status: WorkflowExecutionStatus.RUNNING,
currentStepId: fallbackTransition.toStep.id,
},
});

await this.processStepExecution(stepExecution.executionId, fallbackTransition.toStep.id);
} else if (transitions.length > 0) {
// No timeout branch, follow first transition
const firstTransition = transitions[0];
if (firstTransition?.toStep) {
const nextStep = firstTransition.toStep;
await prisma.workflowExecution.update({
where: {id: stepExecution.executionId},
data: {
status: WorkflowExecutionStatus.RUNNING,
currentStepId: nextStep.id,
},
});

await this.processStepExecution(stepExecution.executionId, nextStep.id);
}
} else {
// No transitions, complete workflow
await prisma.workflowExecution.update({
where: {id: stepExecution.executionId},
data: {
status: WorkflowExecutionStatus.COMPLETED,
completedAt: new Date(),
},
});
}
await this.processNextSteps(stepExecution.execution, stepExecution.step, result);
}

/**
Expand Down Expand Up @@ -438,25 +396,43 @@ export class WorkflowExecutionService {
const config = stepExecution.step.config;

if (config && typeof config === 'object' && 'eventName' in config && config.eventName === eventName) {
// Event matches, resume execution
await prisma.workflowStepExecution.update({
where: {id: stepExecution.id},
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: toPrismaJson({
eventName,
eventData: data ? toPrismaJson(data) : undefined,
receivedAt: new Date().toISOString(),
}),
},
});
const currentContext =
stepExecution.execution.context &&
typeof stepExecution.execution.context === 'object' &&
!Array.isArray(stepExecution.execution.context)
? (stepExecution.execution.context as Record<string, unknown>)
: {};
const resumedContext = {...currentContext, ...(data ?? {})};
const result = {
waitOutcome: WorkflowWaitOutcome.EVENT,
eventReceived: true,
eventName,
...(data ? {eventData: data} : {}),
receivedAt: new Date().toISOString(),
};

// Persist the resume payload with the completed wait. A worker restart
// after this commit reads the same context for downstream steps.
await prisma.$transaction([
prisma.workflowStepExecution.update({
where: {id: stepExecution.id},
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: toPrismaJson(result),
},
}),
prisma.workflowExecution.update({
where: {id: stepExecution.executionId},
data: {context: toPrismaJson(resumedContext)},
}),
]);

// Cancel any pending timeout job
await QueueService.cancelWorkflowTimeout(stepExecution.id);

// Continue workflow
await this.processNextSteps(stepExecution.execution, stepExecution.step, {eventReceived: true});
await this.processNextSteps(stepExecution.execution, stepExecution.step, result);
}
}
}
Expand Down Expand Up @@ -1192,7 +1168,7 @@ export class WorkflowExecutionService {
* Process next steps based on transitions
*/
private static async processNextSteps(
execution: WorkflowExecutionWithRelations,
execution: Pick<WorkflowExecution, 'id'>,
currentStep: WorkflowStepWithTransitions,
stepResult: StepResult,
): Promise<void> {
Expand All @@ -1211,10 +1187,24 @@ export class WorkflowExecutionService {
return;
}

// Find the appropriate transition based on conditions
// WAIT_FOR_EVENT owns a distinct routing dimension. It does not borrow the
// condition JSON used by CONDITION steps.
let nextStep = null;

for (const transition of transitions) {
if (currentStep.type === 'WAIT_FOR_EVENT') {
const waitOutcome = stepResult.waitOutcome;
const outcomeTransition = transitions.find(t => t.waitOutcome === waitOutcome);

if (outcomeTransition) {
nextStep = outcomeTransition.toStep;
} else if (transitions.every(t => t.waitOutcome === null)) {
// Compatibility for rows created before the wait-outcome migration and
// direct test fixtures: their single transition handled either result.
nextStep = transitions[0]?.toStep ?? null;
}
}

for (const transition of currentStep.type === 'WAIT_FOR_EVENT' ? [] : transitions) {
const condition = transition.condition;

// If no condition, always follow
Expand Down Expand Up @@ -1372,7 +1362,7 @@ export class WorkflowExecutionService {
private static evaluateTransitionCondition(
_condition: Prisma.JsonValue,
_stepResult: StepResult,
_execution: WorkflowExecutionWithRelations,
_execution: Pick<WorkflowExecution, 'id'>,
): boolean {
// Implement custom transition condition logic here
// For now, return false as default
Expand Down
Loading