From fa2a9f7e88c395466e37f1ea1fb682a80691985c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:21:45 +0200 Subject: [PATCH 01/14] feat(database): add defaultWorkerGroupId to RuntimeEnvironment Adds a nullable FK for per-environment default region selection. Resolution will fall back to the project default, then the global default. --- .../migration.sql | 5 +++++ internal-packages/database/prisma/schema.prisma | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 internal-packages/database/prisma/migrations/20260609142054_add_environment_default_worker_group/migration.sql 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..8b5b0589aff --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260609142054_add_environment_default_worker_group/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "public"."RuntimeEnvironment" ADD COLUMN "defaultWorkerGroupId" TEXT; + +-- AddForeignKey +ALTER TABLE "public"."RuntimeEnvironment" ADD CONSTRAINT "RuntimeEnvironment_defaultWorkerGroupId_fkey" FOREIGN KEY ("defaultWorkerGroupId") REFERENCES "public"."WorkerInstanceGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 337a6059ebd..186ca4ab30b 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 for this environment. Falls back to the project default, then the global default. + defaultWorkerGroup WorkerInstanceGroup? @relation("EnvironmentDefaultWorkerGroup", fields: [defaultWorkerGroupId], references: [id]) + defaultWorkerGroupId String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1517,7 +1521,8 @@ model WorkerInstanceGroup { workers WorkerInstance[] backgroundWorkers BackgroundWorker[] - defaultForProjects Project[] @relation("ProjectDefaultWorkerGroup") + defaultForProjects Project[] @relation("ProjectDefaultWorkerGroup") + defaultForEnvironments RuntimeEnvironment[] @relation("EnvironmentDefaultWorkerGroup") organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String? From 5ab883ff6b56aba6694a937bbcff0485e0d2a1c9 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:22:23 +0200 Subject: [PATCH 02/14] feat(core): add defaultWorkerGroupId to AuthenticatedEnvironment Optional per-environment default region, mapped from the Prisma row in toAuthenticated(). Read in the trigger path to route runs. --- .changeset/env-default-worker-group.md | 5 +++++ apps/webapp/app/models/runtimeEnvironment.server.ts | 1 + packages/core/src/v3/auth/environment.ts | 5 +++++ 3 files changed, 11 insertions(+) create mode 100644 .changeset/env-default-worker-group.md 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/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/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; From f834ace69492e73f569420635a5ae2285787f68e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:23:15 +0200 Subject: [PATCH 03/14] feat(webapp): resolve env-level default region in trigger path getDefaultWorkerGroupForProject now checks the environment default before the project default. Adds resolveEffectiveDefaultWorkerGroupId as the shared fallback chain (env -> project -> global). --- .../app/runEngine/concerns/queues.server.ts | 1 + apps/webapp/app/v3/regionAccess.server.ts | 23 +++++++++++++++++++ .../worker/workerGroupService.server.ts | 14 +++++++++++ 3 files changed, 38 insertions(+) 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/v3/regionAccess.server.ts b/apps/webapp/app/v3/regionAccess.server.ts index c3e338cb945..72a7425c79a 100644 --- a/apps/webapp/app/v3/regionAccess.server.ts +++ b/apps/webapp/app/v3/regionAccess.server.ts @@ -33,6 +33,29 @@ export function defaultVisibilityFilter( return { hidden: false, workloadType: { not: "MICROVM" } }; } +/** + * Canonical fallback chain for a run's default region: + * environment default -> project default -> global default. + * Both the trigger path and the regions UI must use this order so the + * displayed default matches what actually runs. + */ +export function resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId, + projectDefaultWorkerGroupId, + globalDefaultWorkerGroupId, +}: { + environmentDefaultWorkerGroupId?: string | null; + projectDefaultWorkerGroupId?: string | null; + globalDefaultWorkerGroupId?: string | null; +}): string | undefined { + return ( + environmentDefaultWorkerGroupId ?? + projectDefaultWorkerGroupId ?? + globalDefaultWorkerGroupId ?? + undefined + ); +} + /** * Whether a region is accessible given compute access. * MICROVM regions require compute access; all other types pass through. diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index fc280e81652..bc0fb94185c 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,18 @@ export class WorkerGroupService extends WithRunEngine { return workerGroup; } + // Resolution order must match resolveEffectiveDefaultWorkerGroupId: + // environment default -> project default -> global default. + if (environmentDefaultWorkerGroupId) { + const envWorkerGroup = await this._prisma.workerInstanceGroup.findFirst({ + where: { id: environmentDefaultWorkerGroupId }, + }); + + if (envWorkerGroup) { + return envWorkerGroup; + } + } + if (project.defaultWorkerGroup) { return project.defaultWorkerGroup; } From 20e97fcf558b85361f4b0e3536022812ce5b1017 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:24:32 +0200 Subject: [PATCH 04/14] feat(webapp): write default region to the environment SetDefaultRegionService now sets RuntimeEnvironment.defaultWorkerGroupId (allowlist checks stay project-scoped). Regions route resolves the env in its loader and action. --- .../route.tsx | 22 +++++++++++++-- .../v3/services/setDefaultRegion.server.ts | 28 ++++++++++--------- 2 files changed, 34 insertions(+), 16 deletions(-) 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..5e9d64a1207 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, }) ); @@ -106,6 +117,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 +132,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/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, From b5176506dd5f2ca631fe1dd7701407c09a3c5865 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:25:52 +0200 Subject: [PATCH 05/14] feat(webapp): show effective default region per environment RegionsPresenter marks the effective default (env -> project -> global) and all callers pass the current environment id. --- .../presenters/v3/RegionsPresenter.server.ts | 36 +++++++++++++------ .../route.tsx | 1 + .../route.tsx | 1 + .../_app.orgs.$organizationSlug/route.tsx | 2 +- .../resources.taskruns.$runParam.replay.ts | 1 + 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 2dd5a448cb4..cde14301b29 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -3,7 +3,11 @@ 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 { + defaultVisibilityFilter, + resolveComputeAccess, + resolveEffectiveDefaultWorkerGroupId, +} from "~/v3/regionAccess.server"; import { BasePresenter } from "./basePresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; @@ -24,10 +28,12 @@ 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({ @@ -65,6 +71,20 @@ export class RegionsPresenter extends BasePresenter { throw new Error("Default worker instance group not found"); } + const environment = environmentId + ? await this._replica.runtimeEnvironment.findFirst({ + select: { defaultWorkerGroupId: true }, + where: { id: environmentId }, + }) + : null; + + // env default -> project default -> global default + const effectiveDefaultId = resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: environment?.defaultWorkerGroupId, + projectDefaultWorkerGroupId: project.defaultWorkerGroupId, + globalDefaultWorkerGroupId: defaultWorkerInstanceGroupId, + }); + const hasComputeAccess = await resolveComputeAccess( this._replica, project.organization.featureFlags @@ -103,12 +123,14 @@ 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) { + // The effective default may not be in the visible list (e.g. a hidden + // region set as the env/project default) — fetch and include it. + if (effectiveDefaultId && !regions.some((region) => region.id === effectiveDefaultId)) { const defaultWorkerGroup = await this._replica.workerInstanceGroup.findFirst({ select: { id: true, @@ -121,16 +143,10 @@ export class RegionsPresenter extends BasePresenter { hidden: true, workloadType: true, }, - where: { id: project.defaultWorkerGroupId }, + where: { id: effectiveDefaultId }, }); 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, 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.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..a2e1d13b929 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -106,7 +106,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/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, }), ]); From f25808f10d45ed41cfb34eaf8d4e8ee360df6141 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:27:30 +0200 Subject: [PATCH 06/14] feat(webapp): inherit region on preview branches + test resolver Preview branches copy the parent env's defaultWorkerGroupId. Adds a unit test for the env -> project -> global fallback order. --- .../app/services/upsertBranch.server.ts | 2 + apps/webapp/test/regionAccess.test.ts | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 apps/webapp/test/regionAccess.test.ts 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/test/regionAccess.test.ts b/apps/webapp/test/regionAccess.test.ts new file mode 100644 index 00000000000..aa7d4da86d3 --- /dev/null +++ b/apps/webapp/test/regionAccess.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveEffectiveDefaultWorkerGroupId } from "~/v3/regionAccess.server"; + +describe("resolveEffectiveDefaultWorkerGroupId", () => { + it("prefers the environment default", () => { + expect( + resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: "env", + projectDefaultWorkerGroupId: "project", + globalDefaultWorkerGroupId: "global", + }) + ).toBe("env"); + }); + + it("falls back to the project default when the environment has none", () => { + expect( + resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: null, + projectDefaultWorkerGroupId: "project", + globalDefaultWorkerGroupId: "global", + }) + ).toBe("project"); + }); + + it("falls back to the global default when env and project have none", () => { + expect( + resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: null, + projectDefaultWorkerGroupId: null, + globalDefaultWorkerGroupId: "global", + }) + ).toBe("global"); + }); + + it("returns undefined when nothing is set", () => { + expect( + resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: null, + projectDefaultWorkerGroupId: null, + globalDefaultWorkerGroupId: null, + }) + ).toBeUndefined(); + }); +}); From fe51f38978b074d108fe2775cb36986f1d0f7b04 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 16:38:41 +0200 Subject: [PATCH 07/14] fix(webapp): use relation connect for inherited branch region Prisma's checked create input rejects a raw FK scalar alongside relation connects; use defaultWorkerGroup.connect instead. --- apps/webapp/app/services/upsertBranch.server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index d56b3159776..13aa4cbb92e 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -120,7 +120,9 @@ export class UpsertBranchService { shortcode, maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, // Inherit the region from the parent preview environment. - defaultWorkerGroupId: parentEnvironment.defaultWorkerGroupId, + defaultWorkerGroup: parentEnvironment.defaultWorkerGroupId + ? { connect: { id: parentEnvironment.defaultWorkerGroupId } } + : undefined, organization: { connect: { id: parentEnvironment.organization.id, From 2e3f8176ca6db94a2d28b649f76e35422da5adb9 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 17:11:54 +0200 Subject: [PATCH 08/14] chore(database): make env default worker group migration idempotent --- .../migration.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 8b5b0589aff..302203fa562 100644 --- 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 @@ -1,5 +1,6 @@ -- AlterTable -ALTER TABLE "public"."RuntimeEnvironment" ADD COLUMN "defaultWorkerGroupId" TEXT; +ALTER TABLE "public"."RuntimeEnvironment" ADD COLUMN IF NOT EXISTS "defaultWorkerGroupId" TEXT; -- AddForeignKey +ALTER TABLE "public"."RuntimeEnvironment" DROP CONSTRAINT IF EXISTS "RuntimeEnvironment_defaultWorkerGroupId_fkey"; ALTER TABLE "public"."RuntimeEnvironment" ADD CONSTRAINT "RuntimeEnvironment_defaultWorkerGroupId_fkey" FOREIGN KEY ("defaultWorkerGroupId") REFERENCES "public"."WorkerInstanceGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; From a77df3f9d93c1ff1fa1da06949bcfc91a24f4b93 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 17:26:00 +0200 Subject: [PATCH 09/14] fix(webapp): address review feedback on env default region - Scope RegionsPresenter env lookup to the resolved project (+ archivedAt: null) so a mismatched env id can't surface a default from another project. - Index RuntimeEnvironment.defaultWorkerGroupId via a separate CONCURRENTLY migration to keep FK checks off a seq scan. --- apps/webapp/app/presenters/v3/RegionsPresenter.server.ts | 2 +- .../migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index cde14301b29..1fb632a5795 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -74,7 +74,7 @@ export class RegionsPresenter extends BasePresenter { const environment = environmentId ? await this._replica.runtimeEnvironment.findFirst({ select: { defaultWorkerGroupId: true }, - where: { id: environmentId }, + where: { id: environmentId, projectId: project.id, archivedAt: null }, }) : null; diff --git a/internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql b/internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql new file mode 100644 index 00000000000..daa29d1be01 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql @@ -0,0 +1,3 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_defaultWorkerGroupId_idx" + ON "public"."RuntimeEnvironment"("defaultWorkerGroupId"); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 186ca4ab30b..4e1d54404fc 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -401,6 +401,7 @@ model RuntimeEnvironment { @@index([parentEnvironmentId]) @@index([projectId]) @@index([organizationId]) + @@index([defaultWorkerGroupId]) } /// Records of previously-valid API keys that are still accepted for authentication From 84350b8f0e760fdf3fcc5c43ffbc9095e8917c4a Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 17:40:38 +0200 Subject: [PATCH 10/14] fix(webapp): revalidate cached regions after setting a default The org layout caches regions for useRegions(); the set-default action redirects to the same URL, so add a shouldRevalidate hook (mirroring the pause/resume pattern) to refresh the default shown in Test/Replay. --- .../route.tsx | 14 ++++++++++++++ .../routes/_app.orgs.$organizationSlug/route.tsx | 6 ++++++ 2 files changed, 20 insertions(+) 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 5e9d64a1207..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 @@ -101,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); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index a2e1d13b929..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; From 1d8807c97011ce4f3359e1836f4fe6a44baca8e3 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 17:55:53 +0200 Subject: [PATCH 11/14] chore(webapp): add server-changes note for per-environment default region --- .server-changes/per-environment-default-region.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/per-environment-default-region.md 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. From 43bcd0801520ceacc2cff60d396fe2097b8ba2d0 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 18:11:16 +0200 Subject: [PATCH 12/14] fix(webapp): resolve effective default region in workers API + compute templates Two callers still read the project-level default directly, which the UI no longer updates. Use resolveEffectiveDefaultWorkerGroupId (env -> project -> global) in: - api.v1.workers isDefault flag - computeTemplateCreation.resolveMode (MICROVM template decision) --- apps/webapp/app/routes/api.v1.workers.ts | 17 +++++++++- .../computeTemplateCreation.server.ts | 33 ++++++++++++++----- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.workers.ts b/apps/webapp/app/routes/api.v1.workers.ts index 4008d64f1a9..399cbde95fb 100644 --- a/apps/webapp/app/routes/api.v1.workers.ts +++ b/apps/webapp/app/routes/api.v1.workers.ts @@ -4,10 +4,14 @@ import { WorkersCreateResponseBody, WorkersListResponseBody, } from "@trigger.dev/core/v3"; +import { $replica } from "~/db.server"; import { createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; +import { resolveEffectiveDefaultWorkerGroupId } from "~/v3/regionAccess.server"; import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.server"; export const loader = createLoaderApiRoute( @@ -27,12 +31,23 @@ export const loader = createLoaderApiRoute( projectId: authentication.environment.projectId, }); + const globalDefaultWorkerGroupId = await makeFlag($replica)({ + key: FEATURE_FLAG.defaultWorkerInstanceGroupId, + }); + + // env default -> project default -> global default + const effectiveDefaultWorkerGroupId = resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: authentication.environment.defaultWorkerGroupId, + projectDefaultWorkerGroupId: authentication.environment.project.defaultWorkerGroupId, + globalDefaultWorkerGroupId, + }); + return json( workers.map((w) => ({ type: w.type, name: w.name, description: w.description, - isDefault: w.id === authentication.environment.project.defaultWorkerGroupId, + isDefault: w.id === effectiveDefaultWorkerGroupId, updatedAt: w.updatedAt, })) ); diff --git a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts index c972952b471..116287a8e95 100644 --- a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts +++ b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts @@ -8,7 +8,9 @@ import type { PrismaClientOrTransaction } from "~/db.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { ServiceValidationError } from "./baseService.server"; import { FailDeploymentService } from "./failDeployment.server"; -import { resolveComputeAccess } from "../regionAccess.server"; +import { resolveComputeAccess, resolveEffectiveDefaultWorkerGroupId } from "../regionAccess.server"; +import { FEATURE_FLAG } from "../featureFlags"; +import { makeFlag } from "../featureFlags.server"; type TemplateCreationMode = "required" | "shadow" | "skip"; @@ -56,7 +58,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 +136,7 @@ export class ComputeTemplateCreationService { } async resolveMode( - projectId: string, + authenticatedEnv: AuthenticatedEnvironment, prisma: PrismaClientOrTransaction ): Promise { if (!this.client) { @@ -142,11 +144,9 @@ export class ComputeTemplateCreationService { } const project = await prisma.project.findFirst({ - where: { id: projectId }, + where: { id: authenticatedEnv.projectId }, select: { - defaultWorkerGroup: { - select: { workloadType: true }, - }, + defaultWorkerGroupId: true, organization: { select: { featureFlags: true }, }, @@ -157,7 +157,24 @@ export class ComputeTemplateCreationService { return "skip"; } - if (project.defaultWorkerGroup?.workloadType === "MICROVM") { + // Resolve the region this env actually deploys to: env default -> project default -> global default. + const globalDefaultWorkerGroupId = await makeFlag(prisma)({ + key: FEATURE_FLAG.defaultWorkerInstanceGroupId, + }); + const effectiveDefaultWorkerGroupId = resolveEffectiveDefaultWorkerGroupId({ + environmentDefaultWorkerGroupId: authenticatedEnv.defaultWorkerGroupId, + projectDefaultWorkerGroupId: project.defaultWorkerGroupId, + globalDefaultWorkerGroupId, + }); + + const defaultWorkerGroup = effectiveDefaultWorkerGroupId + ? await prisma.workerInstanceGroup.findFirst({ + where: { id: effectiveDefaultWorkerGroupId }, + select: { workloadType: true }, + }) + : null; + + if (defaultWorkerGroup?.workloadType === "MICROVM") { return "required"; } From 4b7f66d5b7dda8ea57286b88909f490b54a3f6e7 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 18:26:39 +0200 Subject: [PATCH 13/14] refactor(webapp): drop FK on env default region; reuse canonical resolver - RuntimeEnvironment.defaultWorkerGroupId is now a plain nullable column (no FK, no relation, no index): a deleted region is tolerated and resolution falls back to project -> global. Avoids Prisma drift and FK-check overhead on a cold table. - api.v1.workers and computeTemplateCreation.resolveMode now reuse getDefaultWorkerGroupForProject instead of re-reading the global flag, so their isDefault / MICROVM decisions match exactly where runs route (and resolve the global default the same way as the trigger path). --- apps/webapp/app/routes/api.v1.workers.ts | 18 ++++---------- .../app/services/upsertBranch.server.ts | 4 +--- .../computeTemplateCreation.server.ts | 24 +++++-------------- .../migration.sql | 4 ---- .../migration.sql | 3 --- .../database/prisma/schema.prisma | 8 +++---- 6 files changed, 15 insertions(+), 46 deletions(-) delete mode 100644 internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql diff --git a/apps/webapp/app/routes/api.v1.workers.ts b/apps/webapp/app/routes/api.v1.workers.ts index 399cbde95fb..d41b031ebfc 100644 --- a/apps/webapp/app/routes/api.v1.workers.ts +++ b/apps/webapp/app/routes/api.v1.workers.ts @@ -4,14 +4,10 @@ import { WorkersCreateResponseBody, WorkersListResponseBody, } from "@trigger.dev/core/v3"; -import { $replica } from "~/db.server"; import { createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; -import { FEATURE_FLAG } from "~/v3/featureFlags"; -import { makeFlag } from "~/v3/featureFlags.server"; -import { resolveEffectiveDefaultWorkerGroupId } from "~/v3/regionAccess.server"; import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.server"; export const loader = createLoaderApiRoute( @@ -31,15 +27,11 @@ export const loader = createLoaderApiRoute( projectId: authentication.environment.projectId, }); - const globalDefaultWorkerGroupId = await makeFlag($replica)({ - key: FEATURE_FLAG.defaultWorkerInstanceGroupId, - }); - - // env default -> project default -> global default - const effectiveDefaultWorkerGroupId = resolveEffectiveDefaultWorkerGroupId({ + // 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, - projectDefaultWorkerGroupId: authentication.environment.project.defaultWorkerGroupId, - globalDefaultWorkerGroupId, }); return json( @@ -47,7 +39,7 @@ export const loader = createLoaderApiRoute( type: w.type, name: w.name, description: w.description, - isDefault: w.id === effectiveDefaultWorkerGroupId, + isDefault: w.id === defaultWorkerGroup?.id, updatedAt: w.updatedAt, })) ); diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 13aa4cbb92e..d56b3159776 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -120,9 +120,7 @@ export class UpsertBranchService { shortcode, maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, // Inherit the region from the parent preview environment. - defaultWorkerGroup: parentEnvironment.defaultWorkerGroupId - ? { connect: { id: parentEnvironment.defaultWorkerGroupId } } - : undefined, + 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 116287a8e95..4990af47842 100644 --- a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts +++ b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts @@ -8,9 +8,8 @@ import type { PrismaClientOrTransaction } from "~/db.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { ServiceValidationError } from "./baseService.server"; import { FailDeploymentService } from "./failDeployment.server"; -import { resolveComputeAccess, resolveEffectiveDefaultWorkerGroupId } from "../regionAccess.server"; -import { FEATURE_FLAG } from "../featureFlags"; -import { makeFlag } from "../featureFlags.server"; +import { resolveComputeAccess } from "../regionAccess.server"; +import { WorkerGroupService } from "./worker/workerGroupService.server"; type TemplateCreationMode = "required" | "shadow" | "skip"; @@ -146,7 +145,6 @@ export class ComputeTemplateCreationService { const project = await prisma.project.findFirst({ where: { id: authenticatedEnv.projectId }, select: { - defaultWorkerGroupId: true, organization: { select: { featureFlags: true }, }, @@ -157,23 +155,13 @@ export class ComputeTemplateCreationService { return "skip"; } - // Resolve the region this env actually deploys to: env default -> project default -> global default. - const globalDefaultWorkerGroupId = await makeFlag(prisma)({ - key: FEATURE_FLAG.defaultWorkerInstanceGroupId, - }); - const effectiveDefaultWorkerGroupId = resolveEffectiveDefaultWorkerGroupId({ + // 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, - projectDefaultWorkerGroupId: project.defaultWorkerGroupId, - globalDefaultWorkerGroupId, }); - const defaultWorkerGroup = effectiveDefaultWorkerGroupId - ? await prisma.workerInstanceGroup.findFirst({ - where: { id: effectiveDefaultWorkerGroupId }, - select: { workloadType: true }, - }) - : null; - if (defaultWorkerGroup?.workloadType === "MICROVM") { return "required"; } 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 index 302203fa562..0dc7a875788 100644 --- 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 @@ -1,6 +1,2 @@ -- AlterTable ALTER TABLE "public"."RuntimeEnvironment" ADD COLUMN IF NOT EXISTS "defaultWorkerGroupId" TEXT; - --- AddForeignKey -ALTER TABLE "public"."RuntimeEnvironment" DROP CONSTRAINT IF EXISTS "RuntimeEnvironment_defaultWorkerGroupId_fkey"; -ALTER TABLE "public"."RuntimeEnvironment" ADD CONSTRAINT "RuntimeEnvironment_defaultWorkerGroupId_fkey" FOREIGN KEY ("defaultWorkerGroupId") REFERENCES "public"."WorkerInstanceGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql b/internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql deleted file mode 100644 index daa29d1be01..00000000000 --- a/internal-packages/database/prisma/migrations/20260609142055_add_environment_default_worker_group_index/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- CreateIndex -CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_defaultWorkerGroupId_idx" - ON "public"."RuntimeEnvironment"("defaultWorkerGroupId"); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 4e1d54404fc..6da087f7fa0 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -354,8 +354,8 @@ model RuntimeEnvironment { orgMember OrgMember? @relation(fields: [orgMemberId], references: [id], onDelete: SetNull, onUpdate: Cascade) orgMemberId String? - /// The default region for this environment. Falls back to the project default, then the global default. - defaultWorkerGroup WorkerInstanceGroup? @relation("EnvironmentDefaultWorkerGroup", fields: [defaultWorkerGroupId], references: [id]) + /// 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()) @@ -401,7 +401,6 @@ model RuntimeEnvironment { @@index([parentEnvironmentId]) @@index([projectId]) @@index([organizationId]) - @@index([defaultWorkerGroupId]) } /// Records of previously-valid API keys that are still accepted for authentication @@ -1522,8 +1521,7 @@ model WorkerInstanceGroup { workers WorkerInstance[] backgroundWorkers BackgroundWorker[] - defaultForProjects Project[] @relation("ProjectDefaultWorkerGroup") - defaultForEnvironments RuntimeEnvironment[] @relation("EnvironmentDefaultWorkerGroup") + defaultForProjects Project[] @relation("ProjectDefaultWorkerGroup") organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String? From 1267e485f156c8a40b4ff3905b0f206fa2ac7c42 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 9 Jun 2026 18:44:26 +0200 Subject: [PATCH 14/14] refactor(webapp): resolve regions UI default via canonical resolver RegionsPresenter now resolves the effective default through getDefaultWorkerGroupForProject (existence-checked env -> project -> global), so the UI default always matches where runs route and never points at a deleted region. Removes the id-only resolveEffectiveDefaultWorkerGroupId helper (and its test) now that all four sites share one resolver. --- .../presenters/v3/RegionsPresenter.server.ts | 74 ++++++------------- apps/webapp/app/v3/regionAccess.server.ts | 23 ------ .../worker/workerGroupService.server.ts | 5 +- apps/webapp/test/regionAccess.test.ts | 44 ----------- 4 files changed, 25 insertions(+), 121 deletions(-) delete mode 100644 apps/webapp/test/regionAccess.test.ts diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 1fb632a5795..40b2b17acbe 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,13 +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, - resolveEffectiveDefaultWorkerGroupId, -} from "~/v3/regionAccess.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"; @@ -40,7 +35,6 @@ export class RegionsPresenter extends BasePresenter { select: { id: true, organizationId: true, - defaultWorkerGroupId: true, allowedWorkerQueues: true, organization: { select: { featureFlags: true }, @@ -62,15 +56,6 @@ export class RegionsPresenter extends BasePresenter { throw new Error("Project not found"); } - const getFlag = makeFlag(this._replica); - const defaultWorkerInstanceGroupId = await getFlag({ - key: FEATURE_FLAG.defaultWorkerInstanceGroupId, - }); - - if (!defaultWorkerInstanceGroupId) { - throw new Error("Default worker instance group not found"); - } - const environment = environmentId ? await this._replica.runtimeEnvironment.findFirst({ select: { defaultWorkerGroupId: true }, @@ -78,12 +63,14 @@ export class RegionsPresenter extends BasePresenter { }) : null; - // env default -> project default -> global default - const effectiveDefaultId = resolveEffectiveDefaultWorkerGroupId({ + // 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, - projectDefaultWorkerGroupId: project.defaultWorkerGroupId, - globalDefaultWorkerGroupId: defaultWorkerInstanceGroupId, }); + const effectiveDefaultId = defaultWorkerGroup?.id; const hasComputeAccess = await resolveComputeAccess( this._replica, @@ -128,38 +115,21 @@ export class RegionsPresenter extends BasePresenter { workloadType: region.workloadType, })); - // The effective default may not be in the visible list (e.g. a hidden - // region set as the env/project default) — fetch and include it. - if (effectiveDefaultId && !regions.some((region) => region.id === effectiveDefaultId)) { - 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: effectiveDefaultId }, + // 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) { - 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/v3/regionAccess.server.ts b/apps/webapp/app/v3/regionAccess.server.ts index 72a7425c79a..c3e338cb945 100644 --- a/apps/webapp/app/v3/regionAccess.server.ts +++ b/apps/webapp/app/v3/regionAccess.server.ts @@ -33,29 +33,6 @@ export function defaultVisibilityFilter( return { hidden: false, workloadType: { not: "MICROVM" } }; } -/** - * Canonical fallback chain for a run's default region: - * environment default -> project default -> global default. - * Both the trigger path and the regions UI must use this order so the - * displayed default matches what actually runs. - */ -export function resolveEffectiveDefaultWorkerGroupId({ - environmentDefaultWorkerGroupId, - projectDefaultWorkerGroupId, - globalDefaultWorkerGroupId, -}: { - environmentDefaultWorkerGroupId?: string | null; - projectDefaultWorkerGroupId?: string | null; - globalDefaultWorkerGroupId?: string | null; -}): string | undefined { - return ( - environmentDefaultWorkerGroupId ?? - projectDefaultWorkerGroupId ?? - globalDefaultWorkerGroupId ?? - undefined - ); -} - /** * Whether a region is accessible given compute access. * MICROVM regions require compute access; all other types pass through. diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index bc0fb94185c..44222d2565f 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -284,8 +284,9 @@ export class WorkerGroupService extends WithRunEngine { return workerGroup; } - // Resolution order must match resolveEffectiveDefaultWorkerGroupId: - // environment default -> project default -> global default. + // 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 }, diff --git a/apps/webapp/test/regionAccess.test.ts b/apps/webapp/test/regionAccess.test.ts deleted file mode 100644 index aa7d4da86d3..00000000000 --- a/apps/webapp/test/regionAccess.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveEffectiveDefaultWorkerGroupId } from "~/v3/regionAccess.server"; - -describe("resolveEffectiveDefaultWorkerGroupId", () => { - it("prefers the environment default", () => { - expect( - resolveEffectiveDefaultWorkerGroupId({ - environmentDefaultWorkerGroupId: "env", - projectDefaultWorkerGroupId: "project", - globalDefaultWorkerGroupId: "global", - }) - ).toBe("env"); - }); - - it("falls back to the project default when the environment has none", () => { - expect( - resolveEffectiveDefaultWorkerGroupId({ - environmentDefaultWorkerGroupId: null, - projectDefaultWorkerGroupId: "project", - globalDefaultWorkerGroupId: "global", - }) - ).toBe("project"); - }); - - it("falls back to the global default when env and project have none", () => { - expect( - resolveEffectiveDefaultWorkerGroupId({ - environmentDefaultWorkerGroupId: null, - projectDefaultWorkerGroupId: null, - globalDefaultWorkerGroupId: "global", - }) - ).toBe("global"); - }); - - it("returns undefined when nothing is set", () => { - expect( - resolveEffectiveDefaultWorkerGroupId({ - environmentDefaultWorkerGroupId: null, - projectDefaultWorkerGroupId: null, - globalDefaultWorkerGroupId: null, - }) - ).toBeUndefined(); - }); -});