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
71 changes: 71 additions & 0 deletions packages/workflow-executor/src/http/executor-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default class ExecutorHttpServer {
this.handleGetRun.bind(this),
);
router.post('/runs/:runId/trigger', this.handleTrigger.bind(this));
router.post('/workflow/complete-step', this.handleCompleteStep.bind(this));

this.app.use(router.routes());
this.app.use(router.allowedMethods());
Expand Down Expand Up @@ -163,6 +164,76 @@ export default class ExecutorHttpServer {
ctx.body = { steps };
}

private async handleCompleteStep(ctx: Koa.Context): Promise<void> {
const body = ctx.request.body as {
runId?: string;
stepIndex?: number;
userInput?: { selectedOption?: string };
};

const { runId, userInput } = body;

if (!runId || typeof runId !== 'string') {
ctx.status = 400;
ctx.body = { error: 'Missing or invalid runId' };

return;
}

const rawId = (ctx.state.user as { id?: unknown })?.id;
const bearerUserId = typeof rawId === 'number' ? rawId : Number(rawId);

if (!Number.isFinite(bearerUserId)) {
ctx.status = 400;
ctx.body = { error: 'Missing or invalid user id in token' };

return;
}

const pendingData: Record<string, unknown> = { userConfirmed: true };

if (userInput?.selectedOption !== undefined) {
pendingData.selectedOption = userInput.selectedOption;
}

try {
await this.options.runner.triggerPoll(runId, { pendingData, bearerUserId });
} catch (err) {
if (err instanceof RunNotFoundError) {
ctx.status = 404;
ctx.body = { error: 'Run not found or unavailable' };

return;
}

if (err instanceof UserMismatchError) {
ctx.status = 403;
ctx.body = { error: 'Forbidden' };

return;
}

if (err instanceof PendingDataNotFoundError) {
ctx.status = 404;
ctx.body = { error: 'Step execution not found or has no pending data' };

return;
}

if (err instanceof InvalidPendingDataError) {
ctx.status = 400;
ctx.body = { error: 'Invalid request body', details: err.issues };

return;
}

throw err;
}

ctx.status = 200;
ctx.body = { completed: true };
}

private async handleTrigger(ctx: Koa.Context): Promise<void> {
const { runId } = ctx.params;
const rawId = (ctx.state.user as { id?: unknown })?.id;
Expand Down
127 changes: 127 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 @@ -469,4 +469,131 @@ describe('ExecutorHttpServer', () => {
await expect(server.stop()).resolves.toBeUndefined();
});
});

describe('POST /workflow/complete-step', () => {
const token = signToken({ id: 1 });

it('returns 401 without auth token', async () => {
const server = createServer();

const res = await request(server.callback).post('/workflow/complete-step').send({
runId: 'run-1',
stepIndex: 0,
});

expect(res.status).toBe(401);
});

it('returns 400 when runId is missing', async () => {
const server = createServer();

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({ stepIndex: 0 });

expect(res.status).toBe(400);
expect(res.body.error).toBe('Missing or invalid runId');
});

it('calls triggerPoll with userConfirmed: true', async () => {
const runner = createMockRunner();
const server = createServer({ runner });

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({ runId: 'run-1', stepIndex: 0 });

expect(res.status).toBe(200);
expect(res.body).toEqual({ completed: true });
expect(runner.triggerPoll).toHaveBeenCalledWith('run-1', {
pendingData: { userConfirmed: true },
bearerUserId: 1,
});
});

it('passes selectedOption in pendingData when provided', async () => {
const runner = createMockRunner();
const server = createServer({ runner });

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({
runId: 'run-1',
stepIndex: 0,
userInput: { selectedOption: 'optionA' },
});

expect(res.status).toBe(200);
expect(runner.triggerPoll).toHaveBeenCalledWith('run-1', {
pendingData: { userConfirmed: true, selectedOption: 'optionA' },
bearerUserId: 1,
});
});

it('returns 404 when run not found', async () => {
const runner = createMockRunner({
triggerPoll: jest.fn().mockRejectedValue(new RunNotFoundError('run-1')),
});
const server = createServer({ runner });

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({ runId: 'run-1', stepIndex: 0 });

expect(res.status).toBe(404);
});

it('returns 403 when user does not match', async () => {
const runner = createMockRunner({
triggerPoll: jest.fn().mockRejectedValue(new UserMismatchError('run-1')),
});
const server = createServer({ runner });

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({ runId: 'run-1', stepIndex: 0 });

expect(res.status).toBe(403);
});

it('returns 404 when step has no pending data', async () => {
const runner = createMockRunner({
triggerPoll: jest.fn().mockRejectedValue(new PendingDataNotFoundError('run-1', 0)),
});
const server = createServer({ runner });

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({ runId: 'run-1', stepIndex: 0 });

expect(res.status).toBe(404);
});

it('returns 400 when pending data validation fails', async () => {
const runner = createMockRunner({
triggerPoll: jest
.fn()
.mockRejectedValue(
new InvalidPendingDataError([
{ path: ['value'], message: 'Required', code: 'invalid' },
]),
),
});
const server = createServer({ runner });

const res = await request(server.callback)
.post('/workflow/complete-step')
.set('Authorization', `Bearer ${token}`)
.send({ runId: 'run-1', stepIndex: 0 });

expect(res.status).toBe(400);
expect(res.body.details).toHaveLength(1);
});
});
});
Loading