Skip to content
Closed
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
7 changes: 7 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ export class RunNotFoundError extends Error {
}
}

export class StepAlreadyExecutedError extends Error {
constructor(runId: string, stepIndex: number) {
super(`Step ${stepIndex} in run "${runId}" has already been executed`);
this.name = 'StepAlreadyExecutedError';
}
}

export class PendingDataNotFoundError extends Error {
constructor(runId: string, stepIndex: number) {
super(`Step ${stepIndex} in run "${runId}" not found or has no pending data`);
Expand Down
14 changes: 13 additions & 1 deletion packages/workflow-executor/src/http/executor-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import http from 'http';
import Koa from 'koa';
import koaJwt from 'koa-jwt';

import { InvalidPendingDataError, PendingDataNotFoundError, RunNotFoundError } from '../errors';
import {
InvalidPendingDataError,
PendingDataNotFoundError,
RunNotFoundError,
StepAlreadyExecutedError,
} from '../errors';

export interface ExecutorHttpServerOptions {
port: number;
Expand Down Expand Up @@ -176,6 +181,13 @@ export default class ExecutorHttpServer {
try {
await this.options.runner.patchPendingData(runId, stepIndex, ctx.request.body);
} catch (err) {
if (err instanceof StepAlreadyExecutedError) {
ctx.status = 409;
ctx.body = { error: 'Step has already been executed' };

return;
}

if (err instanceof PendingDataNotFoundError) {
ctx.status = 404;
ctx.body = { error: 'Step execution not found or has no pending data' };
Expand Down
7 changes: 5 additions & 2 deletions packages/workflow-executor/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
InvalidPendingDataError,
PendingDataNotFoundError,
RunNotFoundError,
StepAlreadyExecutedError,
causeMessage,
} from './errors';
import StepExecutorFactory from './executors/step-executor-factory';
Expand Down Expand Up @@ -123,6 +124,10 @@ export default class Runner {
throw new PendingDataNotFoundError(runId, stepIndex);
}

if ('executionResult' in execution && execution.executionResult !== undefined) {
throw new StepAlreadyExecutedError(runId, stepIndex);
}

const parsed = schema.safeParse(body);

if (!parsed.success) {
Expand All @@ -135,8 +140,6 @@ export default class Runner {
);
}

// Cast is safe: the type guard above ensures `execution` is the correct union branch,
// and patchBodySchemas[execution.type] only accepts keys valid for that branch.
await this.config.runStore.saveStepExecution(runId, {
...execution,
pendingData: { ...(execution.pendingData as object), ...(parsed.data as object) },
Expand Down
17 changes: 17 additions & 0 deletions packages/workflow-executor/test/http/executor-http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
InvalidPendingDataError,
PendingDataNotFoundError,
RunNotFoundError,
StepAlreadyExecutedError,
} from '../../src/errors';
import ExecutorHttpServer from '../../src/http/executor-http-server';

Expand Down Expand Up @@ -380,6 +381,22 @@ describe('ExecutorHttpServer', () => {
expect(runner.patchPendingData).toHaveBeenCalledWith('run-1', 2, { userConfirmed: true });
});

it('returns 409 when patchPendingData throws StepAlreadyExecutedError', async () => {
const runner = createMockRunner({
patchPendingData: jest.fn().mockRejectedValue(new StepAlreadyExecutedError('run-1', 0)),
});
const server = createServer({ runner });
const token = signToken({ id: 'user-1' });

const response = await request(server.callback)
.patch('/runs/run-1/steps/0/pending-data')
.set('Authorization', `Bearer ${token}`)
.send({ userConfirmed: true });

expect(response.status).toBe(409);
expect(response.body).toEqual({ error: 'Step has already been executed' });
});

it('returns 404 when patchPendingData throws PendingDataNotFoundError', async () => {
const runner = createMockRunner({
patchPendingData: jest.fn().mockRejectedValue(new PendingDataNotFoundError('run-1', 0)),
Expand Down
55 changes: 55 additions & 0 deletions packages/workflow-executor/test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
InvalidPendingDataError,
PendingDataNotFoundError,
RunNotFoundError,
StepAlreadyExecutedError,
} from '../src/errors';
import BaseStepExecutor from '../src/executors/base-step-executor';
import ConditionStepExecutor from '../src/executors/condition-step-executor';
Expand Down Expand Up @@ -881,6 +882,60 @@ describe('getRunStepExecutions', () => {
// ---------------------------------------------------------------------------

describe('patchPendingData', () => {
it('throws StepAlreadyExecutedError when step has executionResult', async () => {
const runStore = createMockRunStore({
getStepExecutions: jest.fn().mockResolvedValue([
{
type: 'update-record',
stepIndex: 0,
pendingData: { fieldName: 'status', value: 'active' },
executionResult: { updatedValues: { status: 'active' } },
},
]),
});
runner = new Runner(createRunnerConfig({ runStore }));

await expect(runner.patchPendingData('run-1', 0, { userConfirmed: true })).rejects.toThrow(
StepAlreadyExecutedError,
);
});

it('throws StepAlreadyExecutedError when step was skipped', async () => {
const runStore = createMockRunStore({
getStepExecutions: jest.fn().mockResolvedValue([
{
type: 'trigger-action',
stepIndex: 0,
pendingData: { name: 'send_email', displayName: 'Send Email' },
executionResult: { skipped: true },
},
]),
});
runner = new Runner(createRunnerConfig({ runStore }));

await expect(runner.patchPendingData('run-1', 0, { userConfirmed: true })).rejects.toThrow(
StepAlreadyExecutedError,
);
});

it('does not throw StepAlreadyExecutedError when executionResult is undefined', async () => {
const runStore = createMockRunStore({
getStepExecutions: jest.fn().mockResolvedValue([
{
type: 'update-record',
stepIndex: 0,
pendingData: { fieldName: 'status', value: 'active' },
executionResult: undefined,
},
]),
});
runner = new Runner(createRunnerConfig({ runStore }));

await runner.patchPendingData('run-1', 0, { userConfirmed: true });

expect(runStore.saveStepExecution).toHaveBeenCalled();
});

it('throws PendingDataNotFoundError when step is not found', async () => {
const runStore = createMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]) });
runner = new Runner(createRunnerConfig({ runStore }));
Expand Down
Loading