diff --git a/.changeset/orange-falcons-smile.md b/.changeset/orange-falcons-smile.md new file mode 100644 index 0000000000..7651f714f3 --- /dev/null +++ b/.changeset/orange-falcons-smile.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +allow cancelling jobs from trigger-client diff --git a/apps/webapp/app/presenters/ApiRunPresenter.server.ts b/apps/webapp/app/presenters/ApiRunPresenter.server.ts new file mode 100644 index 0000000000..6e8ccd3d2b --- /dev/null +++ b/apps/webapp/app/presenters/ApiRunPresenter.server.ts @@ -0,0 +1,72 @@ +import { Job } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; + +type ApiRunOptions = { + runId: Job["id"]; + maxTasks?: number; + taskDetails?: boolean; + subTasks?: boolean; + cursor?: string; +}; + +export class ApiRunPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + runId, + maxTasks = 20, + taskDetails = false, + subTasks = false, + cursor, + }: ApiRunOptions) { + const take = Math.min(maxTasks, 50); + + return await prisma.jobRun.findUnique({ + where: { + id: runId, + }, + select: { + id: true, + status: true, + startedAt: true, + updatedAt: true, + completedAt: true, + environmentId: true, + output: true, + tasks: { + select: { + id: true, + parentId: true, + displayKey: true, + status: true, + name: true, + icon: true, + startedAt: true, + completedAt: true, + params: taskDetails, + output: taskDetails, + }, + where: { + parentId: subTasks ? undefined : null, + }, + orderBy: { + id: "asc", + }, + take: take + 1, + cursor: cursor + ? { + id: cursor, + } + : undefined, + }, + statuses: { + select: { key: true, label: true, state: true, data: true, history: true }, + }, + }, + }); + } +} diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.cancel.ts b/apps/webapp/app/routes/api.v1.runs.$runId.cancel.ts new file mode 100644 index 0000000000..876d22d846 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.runs.$runId.cancel.ts @@ -0,0 +1,71 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { PrismaErrorSchema } from "~/db.server"; +import { z } from "zod"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { CancelRunService } from "~/services/runs/cancelRun.server"; +import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server"; + +const ParamsSchema = z.object({ + runId: z.string(), +}); + +export async function action({ request, params }: ActionArgs) { + // Ensure this is a POST request + if (request.method.toUpperCase() !== "POST") { + return { status: 405, body: "Method Not Allowed" }; + } + + // Authenticate the request + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API Key" }, { status: 401 }); + } + + const parsed = ParamsSchema.safeParse(params); + + if (!parsed.success) { + return json({ error: "Invalid or Missing runId" }, { status: 400 }); + } + + const { runId } = parsed.data; + + const service = new CancelRunService(); + try { + await service.call({ runId }); + } catch (error) { + const prismaError = PrismaErrorSchema.safeParse(error); + // Record not found in the database + if (prismaError.success && prismaError.data.code === "P2005") { + return json({ error: "Run not found" }, { status: 404 }); + } else { + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + + const presenter = new ApiRunPresenter(); + const jobRun = await presenter.call({ + runId: runId, + }); + + if (!jobRun) { + return json({ message: "Run not found" }, { status: 404 }); + } + + return json({ + id: jobRun.id, + status: jobRun.status, + startedAt: jobRun.startedAt, + updatedAt: jobRun.updatedAt, + completedAt: jobRun.completedAt, + output: jobRun.output, + tasks: jobRun.tasks, + statuses: jobRun.statuses.map((s) => ({ + ...s, + state: s.state ?? undefined, + data: s.data ?? undefined, + history: s.history ?? undefined, + })), + }); +} diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.ts b/apps/webapp/app/routes/api.v1.runs.$runId.ts index e1273654f8..12fbaffc9a 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.ts @@ -1,7 +1,7 @@ import type { LoaderArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; import { apiCors } from "~/utils/apiCors"; import { taskListToTree } from "~/utils/taskListToTree"; @@ -51,51 +51,15 @@ export async function loader({ request, params }: LoaderArgs) { const query = parsedQuery.data; const showTaskDetails = query.taskdetails && authenticationResult.type === "PRIVATE"; - const take = Math.min(query.take, 50); - const jobRun = await prisma.jobRun.findUnique({ - where: { - id: runId, - }, - select: { - id: true, - status: true, - startedAt: true, - updatedAt: true, - completedAt: true, - environmentId: true, - output: true, - tasks: { - select: { - id: true, - parentId: true, - displayKey: true, - status: true, - name: true, - icon: true, - startedAt: true, - completedAt: true, - params: showTaskDetails, - output: showTaskDetails, - }, - where: { - parentId: query.subtasks ? undefined : null, - }, - orderBy: { - id: "asc", - }, - take: take + 1, - cursor: query.cursor - ? { - id: query.cursor, - } - : undefined, - }, - statuses: { - select: { key: true, label: true, state: true, data: true, history: true }, - }, - }, + const presenter = new ApiRunPresenter(); + const jobRun = await presenter.call({ + runId: runId, + maxTasks: take, + taskDetails: showTaskDetails, + subTasks: query.subtasks, + cursor: query.cursor, }); if (!jobRun) { diff --git a/packages/trigger-sdk/src/apiClient.ts b/packages/trigger-sdk/src/apiClient.ts index 42a2976a52..ad003b10ac 100644 --- a/packages/trigger-sdk/src/apiClient.ts +++ b/packages/trigger-sdk/src/apiClient.ts @@ -399,6 +399,22 @@ export class ApiClient { ); } + async cancelRun(runId: string) { + const apiKey = await this.#apiKey(); + + this.#logger.debug("Cancelling Run", { + runId, + }); + + return await zodfetch(GetRunSchema, `${this.#apiUrl}/api/v1/runs/${runId}/cancel`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + } + async getRunStatuses(runId: string) { const apiKey = await this.#apiKey(); diff --git a/packages/trigger-sdk/src/triggerClient.ts b/packages/trigger-sdk/src/triggerClient.ts index 55412d89b4..b1766f1642 100644 --- a/packages/trigger-sdk/src/triggerClient.ts +++ b/packages/trigger-sdk/src/triggerClient.ts @@ -642,6 +642,10 @@ export class TriggerClient { return this.#client.getRun(runId, options); } + async cancelRun(runId: string) { + return this.#client.cancelRun(runId); + } + async getRuns(jobSlug: string, options?: GetRunsOptions) { return this.#client.getRuns(jobSlug, options); }