diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx
index 1eae4c548a..a2a6d199ab 100644
--- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx
+++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx
@@ -137,6 +137,7 @@ export const deploymentStatuses: WorkerDeploymentStatus[] = [
"DEPLOYED",
"FAILED",
"TIMED_OUT",
+ "CANCELED",
];
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
diff --git a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx b/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx
deleted file mode 100644
index 50df478098..0000000000
--- a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { ArrowPathIcon } from "@heroicons/react/20/solid";
-import { DialogClose } from "@radix-ui/react-dialog";
-import { Form, useNavigation } from "@remix-run/react";
-import { Button } from "~/components/primitives/Buttons";
-import {
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
-} from "~/components/primitives/Dialog";
-import { SpinnerWhite } from "~/components/primitives/Spinner";
-
-type RollbackDeploymentDialogProps = {
- projectId: string;
- deploymentShortCode: string;
- redirectPath: string;
-};
-
-export function RollbackDeploymentDialog({
- projectId,
- deploymentShortCode,
- redirectPath,
-}: RollbackDeploymentDialogProps) {
- const navigation = useNavigation();
-
- const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
- const isLoading = navigation.formAction === formAction;
-
- return (
-
- Rollback to this deployment?
-
- This deployment will become the default for all future runs. Tasks triggered but not
- included in this deploy will remain queued until you roll back to or create a new deployment
- with these tasks included.
-
-
-
-
-
-
-
-
- );
-}
-
-export function PromoteDeploymentDialog({
- projectId,
- deploymentShortCode,
- redirectPath,
-}: RollbackDeploymentDialogProps) {
- const navigation = useNavigation();
-
- const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
- const isLoading = navigation.formAction === formAction;
-
- return (
-
- Promote this deployment?
-
- This deployment will become the default for all future runs not explicitly tied to a
- specific deployment.
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
index 21f2b79a81..8387269cb6 100644
--- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
@@ -103,6 +103,9 @@ export class DeploymentPresenter {
deployedAt: true,
createdAt: true,
startedAt: true,
+ installedAt: true,
+ canceledAt: true,
+ canceledReason: true,
git: true,
promotions: {
select: {
@@ -147,8 +150,11 @@ export class DeploymentPresenter {
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
+ installedAt: deployment.installedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
+ canceledAt: deployment.canceledAt,
+ canceledReason: deployment.canceledReason,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
environment: {
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
index 0805338ed7..63c0fc41a6 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
@@ -158,6 +158,22 @@ export default function Page() {
/>
+ {deployment.canceledAt && (
+
+ Canceled at
+
+ <>
+ UTC
+ >
+
+
+ )}
+ {deployment.canceledReason && (
+
+ Cancelation reason
+ {deployment.canceledReason}
+
+ )}
Tasks
{deployment.tasks ? deployment.tasks.length : "–"}
@@ -200,6 +216,18 @@ export default function Page() {
)}
+
+ Installed at
+
+ {deployment.installedAt ? (
+ <>
+ UTC
+ >
+ ) : (
+ "–"
+ )}
+
+
Built at
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
index 204ffbc58b..7f1f94dc31 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
@@ -1,5 +1,18 @@
-import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
-import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
+import {
+ ArrowPathIcon,
+ ArrowUturnLeftIcon,
+ BookOpenIcon,
+ NoSymbolIcon,
+} from "@heroicons/react/20/solid";
+import {
+ Form,
+ type MetaFunction,
+ Outlet,
+ useLocation,
+ useNavigate,
+ useNavigation,
+ useParams,
+} from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CogIcon, GitBranchIcon } from "lucide-react";
import { useEffect } from "react";
@@ -15,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
-import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
+import { SpinnerWhite } from "~/components/primitives/Spinner";
+import {
+ Dialog,
+ DialogDescription,
+ DialogContent,
+ DialogHeader,
+ DialogTrigger,
+ DialogFooter,
+} from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -39,10 +60,6 @@ import {
deploymentStatusDescription,
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
-import {
- PromoteDeploymentDialog,
- RollbackDeploymentDialog,
-} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -63,6 +80,7 @@ import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { env } from "~/env.server";
+import { DialogClose } from "@radix-ui/react-dialog";
export const meta: MetaFunction = () => {
return [
@@ -395,7 +413,10 @@ function DeploymentActionsCell({
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
- if (!canBeRolledBack && !canBePromoted) {
+ const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
+ const canBeCanceled = !finalStatuses.includes(deployment.status);
+
+ if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
return (
{""}
@@ -419,7 +440,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
- Rollback…
+ Rollback
- Promote…
+ Promote
)}
+ {canBeCanceled && (
+
+ )}
>
}
/>
);
}
+
+type RollbackDeploymentDialogProps = {
+ projectId: string;
+ deploymentShortCode: string;
+ redirectPath: string;
+};
+
+function RollbackDeploymentDialog({
+ projectId,
+ deploymentShortCode,
+ redirectPath,
+}: RollbackDeploymentDialogProps) {
+ const navigation = useNavigation();
+
+ const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
+ const isLoading = navigation.formAction === formAction;
+
+ return (
+
+ Rollback to this deployment?
+
+ This deployment will become the default for all future runs. Tasks triggered but not
+ included in this deploy will remain queued until you roll back to or create a new deployment
+ with these tasks included.
+
+
+
+
+
+
+
+
+ );
+}
+
+function PromoteDeploymentDialog({
+ projectId,
+ deploymentShortCode,
+ redirectPath,
+}: RollbackDeploymentDialogProps) {
+ const navigation = useNavigation();
+
+ const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
+ const isLoading = navigation.formAction === formAction;
+
+ return (
+
+ Promote this deployment?
+
+ This deployment will become the default for all future runs not explicitly tied to a
+ specific deployment.
+
+
+
+
+
+
+
+
+ );
+}
+
+function CancelDeploymentDialog({
+ projectId,
+ deploymentShortCode,
+ redirectPath,
+}: RollbackDeploymentDialogProps) {
+ const navigation = useNavigation();
+
+ const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
+ const isLoading = navigation.formAction === formAction;
+
+ return (
+
+ Cancel this deployment?
+ Canceling a deployment cannot be undone. Are you sure?
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts
new file mode 100644
index 0000000000..dd209d4494
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts
@@ -0,0 +1,72 @@
+import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
+import { z } from "zod";
+import { authenticateRequest } from "~/services/apiAuth.server";
+import { logger } from "~/services/logger.server";
+import { DeploymentService } from "~/v3/services/deployment.server";
+
+const ParamsSchema = z.object({
+ deploymentId: z.string(),
+});
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ if (request.method.toUpperCase() !== "POST") {
+ return json({ error: "Method Not Allowed" }, { status: 405 });
+ }
+
+ const parsedParams = ParamsSchema.safeParse(params);
+
+ if (!parsedParams.success) {
+ return json({ error: "Invalid params" }, { status: 400 });
+ }
+
+ const authenticationResult = await authenticateRequest(request, {
+ apiKey: true,
+ organizationAccessToken: false,
+ personalAccessToken: false,
+ });
+
+ if (!authenticationResult || !authenticationResult.result.ok) {
+ logger.info("Invalid or missing api key", { url: request.url });
+ return json({ error: "Invalid or Missing API key" }, { status: 401 });
+ }
+
+ const { environment: authenticatedEnv } = authenticationResult.result;
+ const { deploymentId } = parsedParams.data;
+
+ const [, rawBody] = await tryCatch(request.json());
+ const body = CancelDeploymentRequestBody.safeParse(rawBody ?? {});
+
+ if (!body.success) {
+ return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
+ }
+
+ const deploymentService = new DeploymentService();
+
+ return await deploymentService
+ .cancelDeployment(authenticatedEnv, deploymentId, {
+ canceledReason: body.data.reason,
+ })
+ .match(
+ () => {
+ return new Response(null, { status: 204 });
+ },
+ (error) => {
+ switch (error.type) {
+ case "deployment_not_found":
+ return json({ error: "Deployment not found" }, { status: 404 });
+ case "failed_to_delete_deployment_timeout":
+ return new Response(null, { status: 204 }); // not a critical error, ignore
+ case "deployment_cannot_be_cancelled":
+ return json(
+ { error: "Deployment is already in a final state and cannot be canceled" },
+ { status: 409 }
+ );
+ case "other":
+ default:
+ error.type satisfies "other";
+ return json({ error: "Internal server error" }, { status: 500 });
+ }
+ }
+ );
+}
diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts
index d07d9a2f3c..beb0fcd7c8 100644
--- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts
+++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts
@@ -1,5 +1,5 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
-import { ProgressDeploymentRequestBody } from "@trigger.dev/core/v3";
+import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -34,8 +34,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
- const rawBody = await request.json();
- const body = ProgressDeploymentRequestBody.safeParse(rawBody);
+ const [, rawBody] = await tryCatch(request.json());
+ const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts
new file mode 100644
index 0000000000..c802d115ad
--- /dev/null
+++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts
@@ -0,0 +1,133 @@
+import { parse } from "@conform-to/zod";
+import { type ActionFunction, json } from "@remix-run/node";
+import { errAsync, fromPromise, okAsync } from "neverthrow";
+import { z } from "zod";
+import { prisma } from "~/db.server";
+import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
+import { logger } from "~/services/logger.server";
+import { requireUserId } from "~/services/session.server";
+import { DeploymentService } from "~/v3/services/deployment.server";
+
+export const cancelSchema = z.object({
+ redirectUrl: z.string(),
+});
+
+const ParamSchema = z.object({
+ projectId: z.string(),
+ deploymentShortCode: z.string(),
+});
+
+export const action: ActionFunction = async ({ request, params }) => {
+ const userId = await requireUserId(request);
+ const { projectId, deploymentShortCode } = ParamSchema.parse(params);
+
+ const formData = await request.formData();
+ const submission = parse(formData, { schema: cancelSchema });
+
+ if (!submission.value) {
+ return json(submission);
+ }
+
+ const verifyProjectMembership = () =>
+ fromPromise(
+ prisma.project.findFirst({
+ where: {
+ id: projectId,
+ organization: {
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ },
+ select: {
+ id: true,
+ },
+ }),
+ (error) => ({ type: "other" as const, cause: error })
+ ).andThen((project) => {
+ if (!project) {
+ return errAsync({ type: "project_not_found" as const });
+ }
+ return okAsync(project);
+ });
+
+ const findDeploymentFriendlyId = ({ id }: { id: string }) =>
+ fromPromise(
+ prisma.workerDeployment.findUnique({
+ select: {
+ friendlyId: true,
+ projectId: true,
+ },
+ where: {
+ projectId_shortCode: {
+ projectId: id,
+ shortCode: deploymentShortCode,
+ },
+ },
+ }),
+ (error) => ({ type: "other" as const, cause: error })
+ ).andThen((deployment) => {
+ if (!deployment) {
+ return errAsync({ type: "deployment_not_found" as const });
+ }
+ return okAsync(deployment);
+ });
+
+ const deploymentService = new DeploymentService();
+ const result = await verifyProjectMembership()
+ .andThen(findDeploymentFriendlyId)
+ .andThen((deployment) =>
+ deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId)
+ );
+
+ if (result.isErr()) {
+ logger.error(
+ `Failed to cancel deployment: ${result.error.type}`,
+ result.error.type === "other"
+ ? {
+ cause: result.error.cause,
+ }
+ : undefined
+ );
+
+ switch (result.error.type) {
+ case "project_not_found":
+ return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
+ case "deployment_not_found":
+ return redirectWithErrorMessage(
+ submission.value.redirectUrl,
+ request,
+ "Deployment not found"
+ );
+ case "deployment_cannot_be_cancelled":
+ return redirectWithErrorMessage(
+ submission.value.redirectUrl,
+ request,
+ "Deployment is already in a final state and cannot be canceled"
+ );
+ case "failed_to_delete_deployment_timeout":
+ // not a critical error, ignore
+ return redirectWithSuccessMessage(
+ submission.value.redirectUrl,
+ request,
+ `Canceled deployment ${deploymentShortCode}.`
+ );
+ case "other":
+ default:
+ result.error.type satisfies "other";
+ return redirectWithErrorMessage(
+ submission.value.redirectUrl,
+ request,
+ "Internal server error"
+ );
+ }
+ }
+
+ return redirectWithSuccessMessage(
+ submission.value.redirectUrl,
+ request,
+ `Canceled deployment ${deploymentShortCode}.`
+ );
+};
diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts
index d3411d9f8f..e12256526c 100644
--- a/apps/webapp/app/v3/services/deployment.server.ts
+++ b/apps/webapp/app/v3/services/deployment.server.ts
@@ -1,11 +1,12 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService } from "./baseService.server";
import { errAsync, fromPromise, okAsync } from "neverthrow";
-import { type WorkerDeploymentStatus, type WorkerDeployment } from "@trigger.dev/database";
-import { type ExternalBuildData, logger, type GitMeta } from "@trigger.dev/core/v3";
+import { type WorkerDeployment } from "@trigger.dev/database";
+import { logger, type GitMeta } from "@trigger.dev/core/v3";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { env } from "~/env.server";
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
+import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
export class DeploymentService extends BaseService {
/**
@@ -143,4 +144,91 @@ export class DeploymentService extends BaseService {
.andThen(extendTimeout)
.map(() => undefined);
}
+
+ /**
+ * Cancels a deployment that is not yet in a final state.
+ *
+ * Only acts when the current status is not final. Not idempotent.
+ *
+ * @param authenticatedEnv The environment which the deployment belongs to.
+ * @param friendlyId The friendly deployment ID.
+ * @param data Cancelation reason.
+ */
+ public cancelDeployment(
+ authenticatedEnv: Pick,
+ friendlyId: string,
+ data?: Partial>
+ ) {
+ const getDeployment = () =>
+ fromPromise(
+ this._prisma.workerDeployment.findFirst({
+ where: {
+ friendlyId,
+ projectId: authenticatedEnv.projectId,
+ },
+ select: {
+ status: true,
+ id: true,
+ },
+ }),
+ (error) => ({
+ type: "other" as const,
+ cause: error,
+ })
+ ).andThen((deployment) => {
+ if (!deployment) {
+ return errAsync({ type: "deployment_not_found" as const });
+ }
+ return okAsync(deployment);
+ });
+
+ const validateDeployment = (deployment: Pick) => {
+ if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
+ logger.warn("Attempted cancelling deployment in a final state", {
+ deployment,
+ });
+ return errAsync({ type: "deployment_cannot_be_cancelled" as const });
+ }
+
+ return okAsync(deployment);
+ };
+
+ const cancelDeployment = (deployment: Pick) =>
+ fromPromise(
+ this._prisma.workerDeployment.updateMany({
+ where: {
+ id: deployment.id,
+ status: {
+ notIn: FINAL_DEPLOYMENT_STATUSES, // status could've changed in the meantime, we're not locking the row
+ },
+ },
+ data: {
+ status: "CANCELED",
+ canceledAt: new Date(),
+ canceledReason: data?.canceledReason,
+ },
+ }),
+ (error) => ({
+ type: "other" as const,
+ cause: error,
+ })
+ ).andThen((result) => {
+ if (result.count === 0) {
+ return errAsync({ type: "deployment_cannot_be_cancelled" as const });
+ }
+ return okAsync({ id: deployment.id });
+ });
+
+ const deleteTimeout = (deployment: Pick) =>
+ fromPromise(TimeoutDeploymentService.dequeue(deployment.id, this._prisma), (error) => ({
+ type: "failed_to_delete_deployment_timeout" as const,
+ cause: error,
+ }));
+
+ return getDeployment()
+ .andThen(validateDeployment)
+ .andThen(cancelDeployment)
+ .andThen(deleteTimeout)
+ .map(() => undefined);
+ }
}
diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts
index b486a0f67e..79234b8310 100644
--- a/apps/webapp/app/v3/services/failDeployment.server.ts
+++ b/apps/webapp/app/v3/services/failDeployment.server.ts
@@ -1,11 +1,11 @@
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
-import { WorkerDeploymentStatus } from "@trigger.dev/database";
-import { FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
-import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import { type WorkerDeploymentStatus } from "@trigger.dev/database";
+import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
+import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
-const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
+export const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
"CANCELED",
"DEPLOYED",
"FAILED",
diff --git a/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql b/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql
new file mode 100644
index 0000000000..c279f9dd32
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "canceledAt" TIMESTAMP(3),
+ADD COLUMN "canceledReason" TEXT;
\ No newline at end of file
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index c3c26ba507..e7e47b0707 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -1772,6 +1772,9 @@ model WorkerDeployment {
failedAt DateTime?
errorData Json?
+ canceledAt DateTime?
+ canceledReason String?
+
// This is GitMeta type
git Json?
diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts
index 1a6242b0c1..f8e12f62cc 100644
--- a/packages/core/src/v3/schemas/api.ts
+++ b/packages/core/src/v3/schemas/api.ts
@@ -385,6 +385,12 @@ export const ProgressDeploymentRequestBody = z.object({
export type ProgressDeploymentRequestBody = z.infer;
+export const CancelDeploymentRequestBody = z.object({
+ reason: z.string().max(200, "Reason must be less than 200 characters").optional(),
+});
+
+export type CancelDeploymentRequestBody = z.infer;
+
export const ExternalBuildData = z.object({
buildId: z.string(),
buildToken: z.string(),