diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx index 6adea62f2b..5f5f3e0177 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx @@ -2,6 +2,7 @@ import { CheckCircleIcon, ExclamationTriangleIcon, NoSymbolIcon, + RectangleStackIcon, XCircleIcon, } from "@heroicons/react/20/solid"; import type { WorkerDeploymentStatus } from "@trigger.dev/database"; @@ -49,6 +50,9 @@ export function DeploymentStatusIcon({ }) { switch (status) { case "PENDING": + return ( + + ); case "BUILDING": case "DEPLOYING": return ; @@ -73,6 +77,7 @@ export function DeploymentStatusIcon({ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string { switch (status) { case "PENDING": + return "text-charcoal-500"; case "BUILDING": case "DEPLOYING": return "text-pending"; @@ -92,7 +97,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string { switch (status) { case "PENDING": - return "Pending…"; + return "Queued…"; case "BUILDING": return "Building…"; case "DEPLOYING": @@ -121,6 +126,7 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b // PENDING and CANCELED are not used so are ommited from the UI export const deploymentStatuses: WorkerDeploymentStatus[] = [ + "PENDING", "BUILDING", "DEPLOYING", "DEPLOYED", diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 2d8384ec19..8f9b5274c5 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -312,6 +312,10 @@ const EnvironmentSchema = z .number() .int() .default(60 * 1000 * 8), // 8 minutes + DEPLOY_QUEUE_TIMEOUT_MS: z.coerce + .number() + .int() + .default(60 * 1000 * 15), // 15 minutes OBJECT_STORE_BASE_URL: z.string().optional(), OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index 35e6e4184f..21f2b79a81 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -102,6 +102,7 @@ export class DeploymentPresenter { builtAt: true, deployedAt: true, createdAt: true, + startedAt: true, git: true, promotions: { select: { @@ -145,6 +146,7 @@ export class DeploymentPresenter { version: deployment.version, status: deployment.status, createdAt: deployment.createdAt, + startedAt: deployment.startedAt, builtAt: deployment.builtAt, deployedAt: deployment.deployedAt, tasks: deployment.worker?.tasks, 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 0575d7e758..069fa61a48 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 @@ -32,6 +32,7 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder"; import { capitalizeWord } from "~/utils/string"; +import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -187,7 +188,13 @@ export default function Page() { Started at - UTC + {deployment.startedAt ? ( + <> + UTC + + ) : ( + "–" + )} @@ -226,17 +233,16 @@ export default function Page() { Deployed by - {deployment.deployedBy ? ( -
- - - {deployment.deployedBy.name ?? deployment.deployedBy.displayName} - -
+ {deployment.git?.source === "trigger_github_app" ? ( + + ) : deployment.deployedBy ? ( + ) : ( "–" )} 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 610df3ed61..59b6c58ad7 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 @@ -358,7 +358,7 @@ export default function Page() { ); } -function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { +export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { return (
diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts new file mode 100644 index 0000000000..a82d452383 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts @@ -0,0 +1,71 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { StartDeploymentRequestBody } 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 request.json(); + const body = StartDeploymentRequestBody.safeParse(rawBody); + + if (!body.success) { + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); + } + + const deploymentService = new DeploymentService(); + + return await deploymentService + .startDeployment(authenticatedEnv, deploymentId, { + contentHash: body.data.contentHash, + git: body.data.gitMeta, + runtime: body.data.runtime, + }) + .match( + () => { + return json(null, { status: 204 }); + }, + (error) => { + switch (error.type) { + case "failed_to_extend_deployment_timeout": + return json(null, { status: 204 }); // ignore these errors for now + case "deployment_not_found": + return json({ error: "Deployment not found" }, { status: 404 }); + case "deployment_not_pending": + return json({ error: "Deployment is not pending" }, { 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.ts b/apps/webapp/app/routes/api.v1.deployments.ts index c80e180d89..8b3280cbb2 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -1,8 +1,8 @@ -import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { ApiDeploymentListSearchParams, InitializeDeploymentRequestBody, - InitializeDeploymentResponseBody, + type InitializeDeploymentResponseBody, } from "@trigger.dev/core/v3"; import { $replica } from "~/db.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts new file mode 100644 index 0000000000..9789cfc07e --- /dev/null +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -0,0 +1,85 @@ +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 { logger, type GitMeta } from "@trigger.dev/core/v3"; +import { TimeoutDeploymentService } from "./timeoutDeployment.server"; +import { env } from "~/env.server"; + +export class DeploymentService extends BaseService { + public startDeployment( + authenticatedEnv: AuthenticatedEnvironment, + friendlyId: string, + updates: Partial & { git: GitMeta }> + ) { + const getDeployment = () => + fromPromise( + this._prisma.workerDeployment.findFirst({ + where: { + friendlyId, + environmentId: authenticatedEnv.id, + }, + 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 (deployment.status !== "PENDING") { + logger.warn("Attempted starting deployment that is not in PENDING status", { + deployment, + }); + return errAsync({ type: "deployment_not_pending" as const }); + } + + return okAsync(deployment); + }; + + const updateDeployment = (deployment: Pick) => + fromPromise( + this._prisma.workerDeployment.updateMany({ + where: { id: deployment.id, status: "PENDING" }, // status could've changed in the meantime, we're not locking the row + data: { ...updates, status: "BUILDING", startedAt: new Date() }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).andThen((result) => { + if (result.count === 0) { + return errAsync({ type: "deployment_not_pending" as const }); + } + return okAsync({ id: deployment.id }); + }); + + const extendTimeout = (deployment: Pick) => + fromPromise( + TimeoutDeploymentService.enqueue( + deployment.id, + "BUILDING" satisfies WorkerDeploymentStatus, + "Building timed out", + new Date(Date.now() + env.DEPLOY_TIMEOUT_MS) + ), + (error) => ({ + type: "failed_to_extend_deployment_timeout" as const, + cause: error, + }) + ).map(() => undefined); + + return getDeployment() + .andThen(validateDeployment) + .andThen(updateDeployment) + .andThen(extendTimeout); + } +} diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 234cfa1270..fe11451bde 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -99,6 +99,10 @@ export class InitializeDeploymentService extends BaseService { const { imageRef, isEcr, repoCreated } = imageRefResult; + // we keep using `BUILDING` as the initial status if not explicitly set + // to avoid changing the behavior for deployments not created in the build server + const initialStatus = payload.initialStatus ?? "BUILDING"; + logger.debug("Creating deployment", { environmentId: environment.id, projectId: environment.projectId, @@ -108,6 +112,7 @@ export class InitializeDeploymentService extends BaseService { imageRef, isEcr, repoCreated, + initialStatus, }); const deployment = await this._prisma.workerDeployment.create({ @@ -116,7 +121,7 @@ export class InitializeDeploymentService extends BaseService { contentHash: payload.contentHash, shortCode: deploymentShortCode, version: nextVersion, - status: "BUILDING", + status: initialStatus, environmentId: environment.id, projectId: environment.projectId, externalBuildData, @@ -126,14 +131,18 @@ export class InitializeDeploymentService extends BaseService { imagePlatform: env.DEPLOY_IMAGE_PLATFORM, git: payload.gitMeta ?? undefined, runtime: payload.runtime ?? undefined, + startedAt: initialStatus === "BUILDING" ? new Date() : undefined, }, }); + const timeoutMs = + deployment.status === "PENDING" ? env.DEPLOY_QUEUE_TIMEOUT_MS : env.DEPLOY_TIMEOUT_MS; + await TimeoutDeploymentService.enqueue( deployment.id, - "BUILDING", + deployment.status, "Building timed out", - new Date(Date.now() + env.DEPLOY_TIMEOUT_MS) + new Date(Date.now() + timeoutMs) ); return { diff --git a/internal-packages/database/prisma/migrations/20250918122438_add_started_at_to_deployment_schema/migration.sql b/internal-packages/database/prisma/migrations/20250918122438_add_started_at_to_deployment_schema/migration.sql new file mode 100644 index 0000000000..83a251cd12 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250918122438_add_started_at_to_deployment_schema/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "startedAt" TIMESTAMP(3); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index d15b2692d0..041a7a0cc4 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1764,6 +1764,7 @@ model WorkerDeployment { triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) triggeredById String? + startedAt DateTime? builtAt DateTime? deployedAt DateTime? diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index e4c71bb341..7bfd4e23d3 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -377,6 +377,14 @@ export const FinalizeDeploymentRequestBody = z.object({ export type FinalizeDeploymentRequestBody = z.infer; +export const StartDeploymentRequestBody = z.object({ + contentHash: z.string().optional(), + gitMeta: GitMeta.optional(), + runtime: z.string().optional(), +}); + +export type StartDeploymentRequestBody = z.infer; + export const ExternalBuildData = z.object({ buildId: z.string(), buildToken: z.string(), @@ -419,6 +427,7 @@ export const InitializeDeploymentRequestBody = z.object({ gitMeta: GitMeta.optional(), type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(), runtime: z.string().optional(), + initialStatus: z.enum(["PENDING", "BUILDING"]).optional(), }); export type InitializeDeploymentRequestBody = z.infer;