diff --git a/.changeset/env-default-worker-group.md b/.changeset/env-default-worker-group.md new file mode 100644 index 00000000000..abcd46e99d5 --- /dev/null +++ b/.changeset/env-default-worker-group.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add an optional `defaultWorkerGroupId` to the authenticated environment shape, enabling per-environment default region selection (falls back to the project, then global, default). diff --git a/.server-changes/per-environment-default-region.md b/.server-changes/per-environment-default-region.md new file mode 100644 index 00000000000..b30568688e4 --- /dev/null +++ b/.server-changes/per-environment-default-region.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Default region selection is now per-environment instead of per-project, falling back to the project default and then the global default. diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 64b1da3be49..f8c363b596c 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -61,6 +61,7 @@ export function toAuthenticated( builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides, createdAt: env.createdAt, updatedAt: env.updatedAt, + defaultWorkerGroupId: env.defaultWorkerGroupId, project: { id: env.project.id, slug: env.project.slug, diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 2dd5a448cb4..40b2b17acbe 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,9 +1,8 @@ import { type WorkloadType } from "@trigger.dev/database"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; -import { FEATURE_FLAG } from "~/v3/featureFlags"; -import { makeFlag } from "~/v3/featureFlags.server"; import { defaultVisibilityFilter, resolveComputeAccess } from "~/v3/regionAccess.server"; +import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.server"; import { BasePresenter } from "./basePresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; @@ -24,17 +23,18 @@ export class RegionsPresenter extends BasePresenter { public async call({ userId, projectSlug, + environmentId, isAdmin = false, }: { userId: User["id"]; projectSlug: Project["slug"]; + environmentId?: string; isAdmin?: boolean; }) { const project = await this._replica.project.findFirst({ select: { id: true, organizationId: true, - defaultWorkerGroupId: true, allowedWorkerQueues: true, organization: { select: { featureFlags: true }, @@ -56,14 +56,21 @@ export class RegionsPresenter extends BasePresenter { throw new Error("Project not found"); } - const getFlag = makeFlag(this._replica); - const defaultWorkerInstanceGroupId = await getFlag({ - key: FEATURE_FLAG.defaultWorkerInstanceGroupId, - }); + const environment = environmentId + ? await this._replica.runtimeEnvironment.findFirst({ + select: { defaultWorkerGroupId: true }, + where: { id: environmentId, projectId: project.id, archivedAt: null }, + }) + : null; - if (!defaultWorkerInstanceGroupId) { - throw new Error("Default worker instance group not found"); - } + // Resolve via the same path the trigger uses (env -> project -> global, each + // existence-checked) so the UI default always matches where runs route and can + // never point at a deleted region. + const defaultWorkerGroup = await new WorkerGroupService().getDefaultWorkerGroupForProject({ + projectId: project.id, + environmentDefaultWorkerGroupId: environment?.defaultWorkerGroupId, + }); + const effectiveDefaultId = defaultWorkerGroup?.id; const hasComputeAccess = await resolveComputeAccess( this._replica, @@ -103,47 +110,26 @@ export class RegionsPresenter extends BasePresenter { cloudProvider: region.cloudProvider ?? undefined, location: region.location ?? undefined, staticIPs: region.staticIPs ?? undefined, - isDefault: region.id === defaultWorkerInstanceGroupId, + isDefault: region.id === effectiveDefaultId, isHidden: region.hidden, workloadType: region.workloadType, })); - if (project.defaultWorkerGroupId) { - const defaultWorkerGroup = await this._replica.workerInstanceGroup.findFirst({ - select: { - id: true, - name: true, - masterQueue: true, - description: true, - cloudProvider: true, - location: true, - staticIPs: true, - hidden: true, - workloadType: true, - }, - where: { id: project.defaultWorkerGroupId }, + // The default may not be in the visible list (e.g. a hidden region set as the + // env/project default) — include the already-resolved group so it still shows. + if (defaultWorkerGroup && !regions.some((region) => region.id === defaultWorkerGroup.id)) { + regions.push({ + id: defaultWorkerGroup.id, + name: defaultWorkerGroup.name, + masterQueue: defaultWorkerGroup.masterQueue, + description: defaultWorkerGroup.description ?? undefined, + cloudProvider: defaultWorkerGroup.cloudProvider ?? undefined, + location: defaultWorkerGroup.location ?? undefined, + staticIPs: defaultWorkerGroup.staticIPs ?? undefined, + isDefault: true, + isHidden: defaultWorkerGroup.hidden, + workloadType: defaultWorkerGroup.workloadType, }); - - if (defaultWorkerGroup) { - // Unset the default region - const defaultRegion = regions.find((region) => region.isDefault); - if (defaultRegion) { - defaultRegion.isDefault = false; - } - - regions.push({ - id: defaultWorkerGroup.id, - name: defaultWorkerGroup.name, - masterQueue: defaultWorkerGroup.masterQueue, - description: defaultWorkerGroup.description ?? undefined, - cloudProvider: defaultWorkerGroup.cloudProvider ?? undefined, - location: defaultWorkerGroup.location ?? undefined, - staticIPs: defaultWorkerGroup.staticIPs ?? undefined, - isDefault: true, - isHidden: defaultWorkerGroup.hidden, - workloadType: defaultWorkerGroup.workloadType, - }); - } } // Default first diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx index 08856f65aca..102a874f9db 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx @@ -57,6 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { new RegionsPresenter().call({ userId: user.id, projectSlug: projectParam, + environmentId: environment.id, isAdmin: user.admin || user.isImpersonating, }), ]); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 2d754309a3d..e8668214ffb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -52,12 +52,12 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { requireUser } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, - ProjectParamSchema, regionsPath, v3BillingPath, } from "~/utils/pathBuilder"; @@ -65,13 +65,24 @@ import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); - const { projectParam } = ProjectParamSchema.parse(params); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response(undefined, { status: 404, statusText: "Project not found" }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response(undefined, { status: 404, statusText: "Environment not found" }); + } const presenter = new RegionsPresenter(); const [error, result] = await tryCatch( presenter.call({ userId: user.id, projectSlug: projectParam, + environmentId: environment.id, isAdmin: user.admin || user.isImpersonating, }) ); @@ -90,6 +101,20 @@ const FormSchema = z.object({ regionId: z.string(), }); +// Lets the parent layout route revalidate its cached regions after a new +// default is set — the action redirects to the same URL, so a pathname check +// alone wouldn't refresh the default shown by useRegions(). +export function isSetDefaultRegionFormSubmission( + formMethod: string | undefined, + formData: FormData | undefined +) { + if (!formMethod || !formData) { + return false; + } + + return formMethod.toLowerCase() === "post" && formData.has("regionId"); +} + export const action = async ({ request, params }: ActionFunctionArgs) => { const user = await requireUser(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); @@ -106,6 +131,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { throw redirectWithErrorMessage(redirectPath, request, "Project not found"); } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw redirectWithErrorMessage(redirectPath, request, "Environment not found"); + } + const formData = await request.formData(); const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); @@ -116,7 +146,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const service = new SetDefaultRegionService(); const [error, result] = await tryCatch( service.call({ - projectId: project.id, + environmentId: environment.id, regionId: parsedFormData.data.regionId, isAdmin: user.admin || user.isImpersonating, }) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index a611ffbcf89..ce190528e68 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -119,6 +119,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { new RegionsPresenter().call({ userId: user.id, projectSlug: projectParam, + environmentId: environment.id, isAdmin: user.admin || user.isImpersonating, }), ]); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 1ad36854dcf..b049862c9f6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -14,6 +14,7 @@ import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; import { organizationPath } from "~/utils/pathBuilder"; import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route"; +import { isSetDefaultRegionFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route"; const ParamsSchema = z.object({ organizationSlug: z.string(), @@ -53,6 +54,11 @@ export const shouldRevalidate: ShouldRevalidateFunction = (params) => { return true; } + // Invalidate so useRegions() reflects a newly set default region + if (isSetDefaultRegionFormSubmission(params.formMethod, params.formData)) { + return true; + } + // This prevents revalidation when there are search params changes // IMPORTANT: If the loader function depends on search params, this should be updated return params.currentUrl.pathname !== params.nextUrl.pathname; @@ -106,7 +112,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }), shouldLoadRegions ? new RegionsPresenter() - .call({ userId: user.id, projectSlug: projectParam! }) + .call({ userId: user.id, projectSlug: projectParam!, environmentId: environment?.id }) .then(({ regions }) => regions) .catch(() => [] as Region[]) : Promise.resolve([] as Region[]), diff --git a/apps/webapp/app/routes/api.v1.workers.ts b/apps/webapp/app/routes/api.v1.workers.ts index 4008d64f1a9..d41b031ebfc 100644 --- a/apps/webapp/app/routes/api.v1.workers.ts +++ b/apps/webapp/app/routes/api.v1.workers.ts @@ -27,12 +27,19 @@ export const loader = createLoaderApiRoute( projectId: authentication.environment.projectId, }); + // Reuse the trigger path's resolution so isDefault matches where runs + // actually route: env default -> project default -> global default. + const defaultWorkerGroup = await service.getDefaultWorkerGroupForProject({ + projectId: authentication.environment.projectId, + environmentDefaultWorkerGroupId: authentication.environment.defaultWorkerGroupId, + }); + return json( workers.map((w) => ({ type: w.type, name: w.name, description: w.description, - isDefault: w.id === authentication.environment.project.defaultWorkerGroupId, + isDefault: w.id === defaultWorkerGroup?.id, updatedAt: w.updatedAt, })) ); diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 631bd5ece52..354b2aa0c57 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -201,6 +201,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { new RegionsPresenter().call({ userId, projectSlug: run.project.slug, + environmentId: environment.id, isAdmin: user.admin || user.isImpersonating, }), ]); diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index 2fc35fc8435..112f49f6ccc 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -389,6 +389,7 @@ export class DefaultQueueManager implements QueueManager { const [error, workerGroup] = await tryCatch( workerGroupService.getDefaultWorkerGroupForProject({ projectId: environment.projectId, + environmentDefaultWorkerGroupId: environment.defaultWorkerGroupId, regionOverride, }) ); diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index e13f5d244c8..d56b3159776 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -119,6 +119,8 @@ export class UpsertBranchService { pkApiKey, shortcode, maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, + // Inherit the region from the parent preview environment. + defaultWorkerGroupId: parentEnvironment.defaultWorkerGroupId, organization: { connect: { id: parentEnvironment.organization.id, diff --git a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts index c972952b471..4990af47842 100644 --- a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts +++ b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts @@ -9,6 +9,7 @@ import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { ServiceValidationError } from "./baseService.server"; import { FailDeploymentService } from "./failDeployment.server"; import { resolveComputeAccess } from "../regionAccess.server"; +import { WorkerGroupService } from "./worker/workerGroupService.server"; type TemplateCreationMode = "required" | "shadow" | "skip"; @@ -56,7 +57,7 @@ export class ComputeTemplateCreationService { prisma: PrismaClientOrTransaction; writer?: WritableStreamDefaultWriter; }): Promise { - const mode = await this.resolveMode(options.projectId, options.prisma); + const mode = await this.resolveMode(options.authenticatedEnv, options.prisma); if (mode === "skip") { return; @@ -134,7 +135,7 @@ export class ComputeTemplateCreationService { } async resolveMode( - projectId: string, + authenticatedEnv: AuthenticatedEnvironment, prisma: PrismaClientOrTransaction ): Promise { if (!this.client) { @@ -142,11 +143,8 @@ export class ComputeTemplateCreationService { } const project = await prisma.project.findFirst({ - where: { id: projectId }, + where: { id: authenticatedEnv.projectId }, select: { - defaultWorkerGroup: { - select: { workloadType: true }, - }, organization: { select: { featureFlags: true }, }, @@ -157,7 +155,14 @@ export class ComputeTemplateCreationService { return "skip"; } - if (project.defaultWorkerGroup?.workloadType === "MICROVM") { + // Reuse the trigger path's resolution so the template decision matches the + // region runs actually deploy to: env default -> project default -> global default. + const defaultWorkerGroup = await new WorkerGroupService().getDefaultWorkerGroupForProject({ + projectId: authenticatedEnv.projectId, + environmentDefaultWorkerGroupId: authenticatedEnv.defaultWorkerGroupId, + }); + + if (defaultWorkerGroup?.workloadType === "MICROVM") { return "required"; } diff --git a/apps/webapp/app/v3/services/setDefaultRegion.server.ts b/apps/webapp/app/v3/services/setDefaultRegion.server.ts index e484b9c4346..2c98c3399a1 100644 --- a/apps/webapp/app/v3/services/setDefaultRegion.server.ts +++ b/apps/webapp/app/v3/services/setDefaultRegion.server.ts @@ -3,11 +3,11 @@ import { BaseService, ServiceValidationError } from "./baseService.server"; export class SetDefaultRegionService extends BaseService { public async call({ - projectId, + environmentId, regionId, isAdmin = false, }: { - projectId: string; + environmentId: string; regionId: string; isAdmin?: boolean; }) { @@ -21,23 +21,25 @@ export class SetDefaultRegionService extends BaseService { throw new ServiceValidationError("Region not found"); } - const project = await this._prisma.project.findFirst({ + const environment = await this._prisma.runtimeEnvironment.findFirst({ where: { - id: projectId, + id: environmentId, }, - include: { + select: { + id: true, + project: { select: { allowedWorkerQueues: true } }, organization: { select: { featureFlags: true } }, }, }); - if (!project) { - throw new ServiceValidationError("Project not found"); + if (!environment) { + throw new ServiceValidationError("Environment not found"); } - // If their project is restricted, only allow them to set default regions that are allowed + // The allowlist stays project-scoped; only the default moves to the environment. if (!isAdmin) { - if (project.allowedWorkerQueues.length > 0) { - if (!project.allowedWorkerQueues.includes(workerGroup.masterQueue)) { + if (environment.project.allowedWorkerQueues.length > 0) { + if (!environment.project.allowedWorkerQueues.includes(workerGroup.masterQueue)) { throw new ServiceValidationError("You're not allowed to set this region as default"); } } else { @@ -48,7 +50,7 @@ export class SetDefaultRegionService extends BaseService { if (workerGroup.workloadType === "MICROVM") { const hasComputeAccess = await resolveComputeAccess( this._prisma, - project.organization.featureFlags + environment.organization.featureFlags ); if (!isComputeRegionAccessible(workerGroup, hasComputeAccess)) { @@ -58,9 +60,9 @@ export class SetDefaultRegionService extends BaseService { } } - await this._prisma.project.update({ + await this._prisma.runtimeEnvironment.update({ where: { - id: projectId, + id: environmentId, }, data: { defaultWorkerGroupId: regionId, diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index fc280e81652..44222d2565f 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -220,9 +220,11 @@ export class WorkerGroupService extends WithRunEngine { async getDefaultWorkerGroupForProject({ projectId, + environmentDefaultWorkerGroupId, regionOverride, }: { projectId: string; + environmentDefaultWorkerGroupId?: string | null; regionOverride?: string; }): Promise { const project = await this._prisma.project.findFirst({ @@ -282,6 +284,19 @@ export class WorkerGroupService extends WithRunEngine { return workerGroup; } + // Canonical default-region resolution (reused by the regions UI, workers API + // and compute templates): environment default -> project default -> global + // default, each existence-checked so a deleted region falls through. + if (environmentDefaultWorkerGroupId) { + const envWorkerGroup = await this._prisma.workerInstanceGroup.findFirst({ + where: { id: environmentDefaultWorkerGroupId }, + }); + + if (envWorkerGroup) { + return envWorkerGroup; + } + } + if (project.defaultWorkerGroup) { return project.defaultWorkerGroup; } diff --git a/internal-packages/database/prisma/migrations/20260609142054_add_environment_default_worker_group/migration.sql b/internal-packages/database/prisma/migrations/20260609142054_add_environment_default_worker_group/migration.sql new file mode 100644 index 00000000000..0dc7a875788 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260609142054_add_environment_default_worker_group/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."RuntimeEnvironment" ADD COLUMN IF NOT EXISTS "defaultWorkerGroupId" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 337a6059ebd..6da087f7fa0 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -354,6 +354,10 @@ model RuntimeEnvironment { orgMember OrgMember? @relation(fields: [orgMemberId], references: [id], onDelete: SetNull, onUpdate: Cascade) orgMemberId String? + /// The default region (WorkerInstanceGroup) for this environment. Deliberately not a foreign key: + /// a deleted region is tolerated — resolution falls back to the project default, then the global default. + defaultWorkerGroupId String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/core/src/v3/auth/environment.ts b/packages/core/src/v3/auth/environment.ts index 8918f191300..35ac217a68c 100644 --- a/packages/core/src/v3/auth/environment.ts +++ b/packages/core/src/v3/auth/environment.ts @@ -57,6 +57,11 @@ export type AuthenticatedEnvironment = { createdAt: Date; updatedAt: Date; + // Per-environment default region. Optional so plugins that don't set + // it fall back to the project/global default. Read in the trigger hot + // path (getWorkerQueue) to route runs. + defaultWorkerGroupId?: string | null; + project: { id: string; slug: string;