From abdd1569c4e7def68c8e89622302f51a72558bc8 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 19 Jun 2026 19:58:28 +0100 Subject: [PATCH 1/2] chore(db): backfill isBranchableEnvironment for existing dev environments Dev environments are now branchable. Backfill all existing non-archived DEVELOPMENT runtime environments so isBranchableEnvironment is true. TRI-8726 --- .../migration.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260618090554_backfill_branchable_dev_env/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260618090554_backfill_branchable_dev_env/migration.sql b/internal-packages/database/prisma/migrations/20260618090554_backfill_branchable_dev_env/migration.sql new file mode 100644 index 0000000000..e76565fdcd --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260618090554_backfill_branchable_dev_env/migration.sql @@ -0,0 +1,6 @@ +-- Dev environments are now branchable, backfill all existing +UPDATE "RuntimeEnvironment" +SET "isBranchableEnvironment" = true +WHERE "type" = 'DEVELOPMENT' + AND "isBranchableEnvironment" = false + AND "archivedAt" IS NULL; From 5b6142dd7d191d0272a38202dae3724f1f95c12c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 19 Jun 2026 19:58:42 +0100 Subject: [PATCH 2/2] feat(webapp): make development environments branchable (API + auth) Extend branch support to DEVELOPMENT environments alongside PREVIEW. - UpsertBranchRequestBody / branches API accept env "development" as well as "preview"; the upsert service resolves the parent env by slug ("preview" or "dev") and scopes dev branches per org member. - checkBranchLimit applies a separate "branchesDev" limit and filters dev branches by the owning org member. - API-key and JWT auth resolve branch child environments for both PREVIEW and DEVELOPMENT parents; findEnvironmentByApiKey returns the dev branch child when a non-default branch is requested. - archiveBranch refuses to archive the default dev branch and reports the branch type so callers can route appropriately. - Presenters and presence are env/branch aware. Backwards compatible with the existing CLI: requests that send env "preview" (or no dev branch) behave exactly as before. TRI-8726 --- .changeset/dev-branch-default-sentinel.md | 6 ++ apps/webapp/app/models/member.server.ts | 2 +- apps/webapp/app/models/project.server.ts | 2 +- .../app/models/runtimeEnvironment.server.ts | 31 ++++-- .../OrganizationsPresenter.server.ts | 22 ++-- .../SelectBestEnvironmentPresenter.server.ts | 7 +- .../presenters/v3/BranchesPresenter.server.ts | 102 +++++++++++------- .../app/presenters/v3/DevPresence.server.ts | 42 +++++++- .../v3/EditSchedulePresenter.server.ts | 8 +- ...environmentVariablesEnvironments.server.ts | 3 + .../api.v1.projects.$projectRef.$env.jwt.ts | 3 +- ...jects.$projectRef.$env.workers.$tagName.ts | 10 +- ...1.projects.$projectRef.branches.archive.ts | 16 ++- .../api.v1.projects.$projectRef.branches.ts | 34 +++--- .../app/routes/engine.v1.dev.presence.ts | 11 +- .../resources.taskruns.$runParam.replay.ts | 3 +- apps/webapp/app/services/apiAuth.server.ts | 89 ++++++++------- .../app/services/archiveBranch.server.ts | 10 +- .../app/services/upsertBranch.server.ts | 72 +++++++++---- .../environmentVariablesRepository.server.ts | 13 +++ internal-packages/rbac/src/fallback.ts | 19 +++- .../core/src/v3/apiClientManager/index.ts | 11 +- packages/core/src/v3/schemas/api.ts | 2 +- packages/core/src/v3/utils/gitBranch.ts | 20 ++++ 24 files changed, 373 insertions(+), 165 deletions(-) create mode 100644 .changeset/dev-branch-default-sentinel.md diff --git a/.changeset/dev-branch-default-sentinel.md b/.changeset/dev-branch-default-sentinel.md new file mode 100644 index 0000000000..6e120555f3 --- /dev/null +++ b/.changeset/dev-branch-default-sentinel.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Centralize the `"default"` dev-branch sentinel behind a shared `DEFAULT_DEV_BRANCH` constant and `isDefaultDevBranch()` helper in `@trigger.dev/core/v3/utils/gitBranch`, replacing the hardcoded string literals duplicated across the CLI and server. No behavior change — `trigger dev` still targets the root development environment when no branch is specified. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index e88f5a5ccf..3a2b25771d 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -215,7 +215,7 @@ export async function acceptInvite({ organization: invite.organization, project, type: "DEVELOPMENT", - isBranchableEnvironment: false, + isBranchableEnvironment: true, member, prismaClient: tx, }); diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index d084bec8ad..3836b451d7 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -126,7 +126,7 @@ export async function createProject( organization, project, type: "DEVELOPMENT", - isBranchableEnvironment: false, + isBranchableEnvironment: true, member, }); } diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 9135872417..53968aae5e 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; -import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; export type { RuntimeEnvironment }; @@ -100,11 +100,11 @@ export async function findEnvironmentByApiKey( ...authIncludeBase, childEnvironments: branchName ? { - where: { - branchName: sanitizeBranchName(branchName), - archivedAt: null, - }, - } + where: { + branchName: sanitizeBranchName(branchName), + archivedAt: null, + }, + } : undefined, } satisfies Prisma.RuntimeEnvironmentInclude; @@ -163,6 +163,25 @@ export async function findEnvironmentByApiKey( return null; } + // If there is a named DEV branch (other than default), return it + if (environment.type === "DEVELOPMENT" && branchName !== undefined && !isDefaultDevBranch(branchName)) { + const childEnvironment = environment.childEnvironments.at(0); + + if (childEnvironment) { + return toAuthenticated({ + ...childEnvironment, + apiKey: environment.apiKey, + orgMember: environment.orgMember, + organization: environment.organization, + project: environment.project, + }); + } + + //A branch was specified but no child environment was found + return null; + + } + return toAuthenticated(environment); } diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 99ced5e3ef..2f2b36d545 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -13,6 +13,8 @@ import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar"; import { env } from "~/env.server"; import { flags } from "~/v3/featureFlags.server"; import { validatePartialFeatureFlags } from "~/v3/featureFlags"; +import { devPresence } from "./v3/DevPresence.server"; +import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server"; export class OrganizationsPresenter { #prismaClient: PrismaClient; @@ -102,6 +104,13 @@ export class OrganizationsPresenter { throw redirect(newProjectPath(organization)); } + const recentDevBranchIds = await devPresence.getRecentBranchIds(user.id, fullProject.id); + + const environments = fullProject. + environments.filter((env) => env.type !== "DEVELOPMENT" || env.orgMember?.userId === user.id); + + const environmentsWithActivity = await hydrateEnvsWithActivity(user.id, fullProject.id, environments); + const environment = this.#getEnvironment({ user, projectId: fullProject.id, @@ -115,13 +124,7 @@ export class OrganizationsPresenter { project: { ...fullProject, createdAt: fullProject.createdAt, - environments: sortEnvironments( - fullProject.environments.filter((env) => { - if (env.type !== "DEVELOPMENT") return true; - if (env.orgMember?.userId === user.id) return true; - return false; - }) - ), + environments: sortEnvironments(environmentsWithActivity), }, environment, }; @@ -244,7 +247,10 @@ export class OrganizationsPresenter { //otherwise show their dev environment const yourDevEnvironment = environments.find( - (env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id + (env) => + env.type === "DEVELOPMENT" && + env.parentEnvironmentId === null && + env.orgMember?.userId === user.id ); if (yourDevEnvironment) { return yourDevEnvironment; diff --git a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts index 67abdc808e..2745359094 100644 --- a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts +++ b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts @@ -1,7 +1,7 @@ import { type RuntimeEnvironment, type PrismaClient, - RuntimeEnvironmentType, + type RuntimeEnvironmentType, } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; @@ -140,7 +140,7 @@ export class SelectBestEnvironmentPresenter { } async selectBestEnvironment< - T extends { id: string; type: RuntimeEnvironmentType; orgMember: { userId: string } | null } + T extends { id: string; type: RuntimeEnvironmentType; slug: string; orgMember: { userId: string } | null } >(projectId: string, user: UserFromSession, environments: T[]): Promise { //try get current environment from prefs const currentEnvironmentId: string | undefined = @@ -153,7 +153,8 @@ export class SelectBestEnvironmentPresenter { //otherwise show their dev environment const yourDevEnvironment = environments.find( - (env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id + // Return the default dev environment, not a branch + (env) => env.type === "DEVELOPMENT" && env.slug === "dev" && env.orgMember?.userId === user.id ); if (yourDevEnvironment) { return yourDevEnvironment; diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index fb094a9809..41c54a19ef 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -1,4 +1,6 @@ -import { GitMeta } from "@trigger.dev/core/v3"; +import { GitMeta, } from "@trigger.dev/core/v3"; +import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch"; +import { type RuntimeEnvironmentType } from "@trigger.dev/database"; import { type z } from "zod"; import { type Prisma, type PrismaClient, prisma } from "~/db.server"; import { type Project } from "~/models/project.server"; @@ -6,6 +8,8 @@ import { type User } from "~/models/user.server"; import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; import { checkBranchLimit } from "~/services/upsertBranch.server"; +import { devPresence } from "./DevPresence.server"; +import { sortEnvironments } from "~/utils/environmentSort"; type Result = Awaited>; export type Branch = Result["branches"][number]; @@ -58,12 +62,14 @@ export class BranchesPresenter { public async call({ userId, projectSlug, + env, showArchived = false, search, page = 1, }: { userId: User["id"]; projectSlug: Project["slug"]; + env: "preview" | "development"; } & Options) { const project = await this.#prismaClient.project.findFirst({ select: { @@ -86,12 +92,16 @@ export class BranchesPresenter { throw new Error("Project not found"); } + // TODO audit mishmash of preview/developement preview/dev stg/dev PREVIEW/DEVELOPMENT + const envType = env === "preview" ? "PREVIEW" : "DEVELOPMENT"; + const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ select: { id: true, }, where: { projectId: project.id, + type: envType, isBranchableEnvironment: true, }, }); @@ -119,23 +129,30 @@ export class BranchesPresenter { }; } + // The default DEV branch has no branchName (it's the root dev env, stored + // with branchName: null), so searching for it by name wouldn't display it. + // Hacky way around that: always include the null-branchName root env. + const branchNameWhere = envType === "DEVELOPMENT" ? + search + ? { OR: [{ contains: search, mode: "insensitive" as const }, { is: null }] } + : {} : + search + ? { contains: search, mode: "insensitive" as const } + : { not: null }; + const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {}; + + const visibleCount = await this.#prismaClient.runtimeEnvironment.count({ where: { projectId: project.id, - branchName: search - ? { - contains: search, - mode: "insensitive", - } - : { - not: null, - }, + type: envType, + branchName: branchNameWhere, + ...orgMemberWhere, ...(showArchived ? {} : { archivedAt: null }), }, }); - // Limits - const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id); + const limits = await checkBranchLimit({ prisma: this.#prismaClient, organizationId: project.organizationId, projectId: project.id, userId, env }); const [currentPlan, plans] = await Promise.all([ getCurrentPlan(project.organizationId), @@ -161,14 +178,9 @@ export class BranchesPresenter { }, where: { projectId: project.id, - branchName: search - ? { - contains: search, - mode: "insensitive", - } - : { - not: null, - }, + type: envType, + branchName: branchNameWhere, + ...orgMemberWhere, ...(showArchived ? {} : { archivedAt: null }), }, orderBy: { @@ -178,35 +190,34 @@ export class BranchesPresenter { take: BRANCHES_PER_PAGE, }); + const totalBranchesWhere = envType === "DEVELOPMENT" ? {} : { not: null }; const totalBranches = await this.#prismaClient.runtimeEnvironment.count({ where: { projectId: project.id, - branchName: { - not: null, - }, + type: envType, + branchName: totalBranchesWhere, + ...orgMemberWhere, }, }); + + const branchesFiltered = branches + .filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null) + .map((branch) => ({ + ...branch, + git: processGitMetadata(branch.git), + branchName: branch.branchName ?? DEFAULT_DEV_BRANCH, + })); + + const branchesWithActivity = await hydrateEnvsWithActivity(userId, project.id, branchesFiltered); + const branchesSorted = sortEnvironments(branchesWithActivity); + return { branchableEnvironment, currentPage: page, totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE), hasBranches: totalBranches > 0, - branches: branches.flatMap((branch) => { - if (branch.branchName === null) { - return []; - } - - const git = processGitMetadata(branch.git); - - return [ - { - ...branch, - branchName: branch.branchName, - git, - } as const, - ]; - }), + branches: branchesSorted, hasFilters, limits, canPurchaseBranches, @@ -218,6 +229,23 @@ export class BranchesPresenter { } } +export async function hydrateEnvsWithActivity + (userId: string, projectId: string, environments: T[]): Promise> { + const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId); + + return Promise.all(environments.map(async (env) => { + if (env.type !== "DEVELOPMENT") { + return { ...env, lastActivity: undefined, isConnected: undefined }; + } + + const devHit = recentDevBranchIds.get(env.id); + const lastActivity = devHit === undefined ? undefined : devHit; + // TODO change dev-presence to a different data structure to avoid N calls? + const isConnected = devHit === undefined ? undefined : await devPresence.isConnected(env.id); + return { ...env, lastActivity, isConnected }; + })); +} + export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null { if (!data) return null; diff --git a/apps/webapp/app/presenters/v3/DevPresence.server.ts b/apps/webapp/app/presenters/v3/DevPresence.server.ts index d751b6d711..f974324711 100644 --- a/apps/webapp/app/presenters/v3/DevPresence.server.ts +++ b/apps/webapp/app/presenters/v3/DevPresence.server.ts @@ -1,8 +1,11 @@ import Redis, { type RedisOptions } from "ioredis"; import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; +import { subDays } from "date-fns"; -const PRESENCE_KEY_PREFIX = "dev-presence:connection:"; +const DEV_RECENT_DEBOUNCE_SEC = 60; +const DEV_RECENT_TTL = 7 * 24 * 60 * 60; // 7 days +const RECENCY_DAYS = 3; export class DevPresence { private redis: Redis; @@ -17,13 +20,46 @@ export class DevPresence { return !!presenceValue; } - async setConnected(environmentId: string, ttl: number) { + async setConnected({ userId, projectId, environmentId, ttl }: { userId: string; projectId: string; environmentId: string; ttl: number; }) { const presenceKey = this.getPresenceKey(environmentId); await this.redis.setex(presenceKey, ttl, new Date().toISOString()); + + const touchKey = this.getTouchKey(environmentId); + const acquired = await this.redis.set(touchKey, "1", "EX", DEV_RECENT_DEBOUNCE_SEC, "NX"); + + if (acquired !== null) { + const recentKey = this.getRecentKey(userId, projectId); + const now = new Date(); + const threeDaysAgo = subDays(now, RECENCY_DAYS); + await this.redis.zadd(recentKey, now.getTime(), environmentId); + await this.redis.zremrangebyscore(recentKey, 0, threeDaysAgo.getTime()); + await this.redis.zremrangebyrank(recentKey, 0, -51); + await this.redis.expire(recentKey, DEV_RECENT_TTL); + } + } + + async getRecentBranchIds(userId: string, projectId: string) { + const recentKey = this.getRecentKey(userId, projectId); + const threeDaysAgo = subDays(Date.now(), RECENCY_DAYS); + const raw = await this.redis.zrevrangebyscore(recentKey, "+inf", threeDaysAgo.getTime(), "WITHSCORES"); + + const branches = new Map(); + for (let i = 0; i < raw.length; i += 2) { + branches.set(raw[i], new Date(Number(raw[i + 1]))); + } + return branches; } private getPresenceKey(environmentId: string) { - return `${PRESENCE_KEY_PREFIX}${environmentId}`; + return `dev-presence:connection:${environmentId}`; + } + + private getRecentKey(userId: string, projectId: string) { + return `dev-recent:${userId}:${projectId}`; + } + + private getTouchKey(environmentId: string) { + return `dev-recent-touch:${environmentId}`; } } diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts index 5b0881b6d1..6ec63a2efb 100644 --- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts @@ -51,6 +51,7 @@ export class EditSchedulePresenter { }, }, branchName: true, + parentEnvironmentId: true, }, }, }, @@ -87,15 +88,14 @@ export class EditSchedulePresenter { : []; const possibleEnvironments = filterOrphanedEnvironments(project.environments) + // Exclude the branchable PREVIEW parent (it has no parent of its own); + // only actual preview branches are schedulable. + .filter((environment) => !(environment.type === "PREVIEW" && environment.parentEnvironmentId === null)) .map((environment) => { return { ...displayableEnvironment(environment, userId), branchName: environment.branchName ?? undefined, }; - }) - .filter((env) => { - if (env.type === "PREVIEW" && !env.branchName) return false; - return true; }); return { diff --git a/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts b/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts index 9473127df0..9cb61afa7b 100644 --- a/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts +++ b/apps/webapp/app/presenters/v3/environmentVariablesEnvironments.server.ts @@ -7,6 +7,7 @@ export type EnvironmentVariablesEnvironment = { type: RuntimeEnvironmentType; isBranchableEnvironment: boolean; branchName: string | null; + parentEnvironmentId: string | null; }; export type EnvironmentVariablesEnvironmentsResult = { @@ -47,6 +48,7 @@ export async function loadEnvironmentVariablesEnvironments( type: true, isBranchableEnvironment: true, branchName: true, + parentEnvironmentId: true, orgMember: { select: { userId: true, @@ -69,6 +71,7 @@ export async function loadEnvironmentVariablesEnvironments( type: environment.type, isBranchableEnvironment: environment.isBranchableEnvironment, branchName: environment.branchName, + parentEnvironmentId: environment.parentEnvironmentId, })), hasStaging: environments.some((environment) => environment.type === "STAGING"), }; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts index 45dca11fba..7f52d9a523 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { authenticatedEnvironmentForAuthentication, authenticateRequest, + branchNameFromRequest, type AuthenticationResult, } from "~/services/apiAuth.server"; import { env as appEnv } from "~/env.server"; @@ -69,7 +70,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } const { projectRef, env } = parsedParams.data; - const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined; + const triggerBranch = branchNameFromRequest(request); const runtimeEnv = await authenticatedEnvironmentForAuthentication( authenticationResult, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts index 07774339dc..4a4ab1bb38 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts @@ -8,6 +8,7 @@ import { v3RunsPath } from "~/utils/pathBuilder"; import { authenticatedEnvironmentForAuthentication, authenticateRequest, + branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; @@ -17,10 +18,6 @@ const ParamsSchema = z.object({ env: z.enum(["dev", "staging", "prod", "preview"]), }); -const HeadersSchema = z.object({ - "x-trigger-branch": z.string().optional(), -}); - type ParamsSchema = z.infer; export async function loader({ request, params }: LoaderFunctionArgs) { @@ -42,10 +39,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } const { projectRef, env } = parsedParams.data; - const parsedHeaders = HeadersSchema.safeParse(Object.fromEntries(request.headers)); - const triggerBranch = parsedHeaders.success - ? parsedHeaders.data["x-trigger-branch"] - : undefined; + const triggerBranch = branchNameFromRequest(request); const runtimeEnv = await authenticatedEnvironmentForAuthentication( authenticationResult, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts index 64119b5a41..9c92a7b88c 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts @@ -11,6 +11,8 @@ const ParamsSchema = z.object({ }); const BodySchema = z.object({ + // Defaults to "preview" so existing CLIs that don't send `env` keep working. + env: z.enum(["preview", "development"]).default("preview"), branch: z.string(), }); @@ -49,6 +51,9 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: parsed.error.message }, { status: 400 }); } + const { env, branch } = parsed.data; + + const environmentType = env === "preview" ? "PREVIEW" : "DEVELOPMENT"; const environments = await prisma.runtimeEnvironment.findMany({ select: { id: true, @@ -59,16 +64,17 @@ export async function action({ request, params }: ActionFunctionArgs) { authenticationResult.type === "organizationAccessToken" ? { id: authenticationResult.result.organizationId } : { - members: { - some: { - userId: authenticationResult.result.userId, - }, + members: { + some: { + userId: authenticationResult.result.userId, }, }, + }, project: { externalRef: projectRef, }, - branchName: parsed.data.branch, + type: environmentType, + branchName: branch, }, }); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts index 8678ef1f9d..5e2ed3dee7 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts @@ -1,5 +1,7 @@ import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3"; +import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch"; +import invariant from "tiny-invariant"; import { z } from "zod"; import { prisma } from "~/db.server"; import { authenticateRequest } from "~/services/apiAuth.server"; @@ -47,12 +49,12 @@ export async function action({ request, params }: ActionFunctionArgs) { authenticationResult.type === "organizationAccessToken" ? { id: authenticationResult.result.organizationId } : { - members: { - some: { - userId: authenticationResult.result.userId, - }, + members: { + some: { + userId: authenticationResult.result.userId, }, }, + }, }, }); if (!project) { @@ -69,24 +71,21 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: parsed.error.message }, { status: 400 }); } - const previewEnvironment = await prisma.runtimeEnvironment.findFirst({ - select: { - id: true, - }, - where: { - projectId: project.id, - slug: "preview", - }, - }); + const { branch, env, git } = parsed.data; - if (!previewEnvironment) { + if (env === "development" && authenticationResult.type === "organizationAccessToken") { return json( - { error: "You don't have preview branches setup. Go to the dashboard to enable them." }, + { error: "Cannot create dev branches with organization access tokens." }, { status: 400 } ); } - const { branch, env, git } = parsed.data; + if (env === "development" && isDefaultDevBranch(branch)) { + return json( + { error: `Cannot create dev branch with name '${DEFAULT_DEV_BRANCH}'.` }, + { status: 400 } + ); + } const service = new UpsertBranchService(); const result = await service.call( @@ -94,8 +93,9 @@ export async function action({ request, params }: ActionFunctionArgs) { ? { type: "orgId", organizationId: authenticationResult.result.organizationId } : { type: "userMembership", userId: authenticationResult.result.userId }, { + env, branchName: branch, - parentEnvironmentId: previewEnvironment.id, + projectId: project.id, git, } ); diff --git a/apps/webapp/app/routes/engine.v1.dev.presence.ts b/apps/webapp/app/routes/engine.v1.dev.presence.ts index 58a5e8c7a4..7a64c24fdc 100644 --- a/apps/webapp/app/routes/engine.v1.dev.presence.ts +++ b/apps/webapp/app/routes/engine.v1.dev.presence.ts @@ -1,4 +1,5 @@ import { json } from "@remix-run/server-runtime"; +import invariant from "tiny-invariant"; import { env } from "~/env.server"; import { devPresence } from "~/presenters/v3/DevPresence.server"; import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server"; @@ -12,11 +13,17 @@ export const loader = createSSELoader({ handler: async ({ id, controller, debug, request }) => { const authentication = await authenticateApiRequestWithFailure(request); + if (!authentication.ok) { throw json({ error: "Invalid or Missing API key" }, { status: 401 }); } const environmentId = authentication.environment.id; + const projectId = authentication.environment.projectId; + const userId = authentication.environment.orgMember?.userId; + + invariant(userId, "No userId on dev environment"); + const ttl = env.DEV_PRESENCE_TTL_MS / 1000; return { @@ -27,11 +34,11 @@ export const loader = createSSELoader({ }, initStream: async ({ send }) => { // Set initial presence with more context - await devPresence.setConnected(environmentId, ttl); + await devPresence.setConnected({ userId, projectId, environmentId, ttl }); send({ event: "start", data: `Started ${id}` }); }, iterator: async ({ send, date }) => { - await devPresence.setConnected(environmentId, ttl); + await devPresence.setConnected({ userId, projectId, environmentId, ttl }); send({ event: "time", data: new Date().toISOString() }); }, cleanup: async () => {}, diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 9d106fca8d..0e6623a219 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -71,6 +71,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { type: true, slug: true, branchName: true, + parentEnvironmentId: true, orgMember: { select: { user: true, @@ -248,7 +249,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, environments: sortEnvironments( run.project.environments - .filter((env) => env.type !== "PREVIEW" || env.branchName) + .filter((env) => env.type !== "PREVIEW" || env.parentEnvironmentId !== null) .map((env) => ({ ...displayableEnvironment(env, userId), branchName: env.branchName ?? undefined, diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index c19c5d4c51..ccbc9def4f 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -25,7 +25,7 @@ import { isOrganizationAccessToken, } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; -import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -282,8 +282,16 @@ function isSecretApiKey(key: string) { return key.startsWith("tr_"); } +/** + * Reads the branch off the `x-trigger-branch` header and sanitizes it. This is + * the single door for the branch header — every server-side reader should go + * through here so sanitization is applied uniformly. The dev `"default"` + * sentinel is intentionally NOT resolved here: that translation is environment + * type-dependent and only knowable once we've looked up the environment (see + * `findEnvironmentByApiKey` and `authenticatedEnvironmentForAuthentication`). + */ export function branchNameFromRequest(request: Request): string | undefined { - return request.headers.get("x-trigger-branch") ?? undefined; + return sanitizeBranchName(request.headers.get("x-trigger-branch")) ?? undefined; } function getApiKeyFromRequest(request: Request): { @@ -312,26 +320,26 @@ function getApiKeyResult(apiKey: string): { const type = isPublicApiKey(apiKey) ? "PUBLIC" : isSecretApiKey(apiKey) - ? "PRIVATE" - : isPublicJWT(apiKey) - ? "PUBLIC_JWT" - : "PRIVATE"; // Fallback to private key + ? "PRIVATE" + : isPublicJWT(apiKey) + ? "PUBLIC_JWT" + : "PRIVATE"; // Fallback to private key return { apiKey, type }; } export type AuthenticationResult = | { - type: "personalAccessToken"; - result: PersonalAccessTokenAuthenticationResult; - } + type: "personalAccessToken"; + result: PersonalAccessTokenAuthenticationResult; + } | { - type: "organizationAccessToken"; - result: OrganizationAccessTokenAuthenticationResult; - } + type: "organizationAccessToken"; + result: OrganizationAccessTokenAuthenticationResult; + } | { - type: "apiKey"; - result: ApiAuthenticationResult; - }; + type: "apiKey"; + result: ApiAuthenticationResult; + }; type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | "apiKey"; @@ -348,11 +356,11 @@ type FilteredAuthenticationResult< T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods > = | (T["personalAccessToken"] extends true - ? Extract - : never) + ? Extract + : never) | (T["organizationAccessToken"] extends true - ? Extract - : never) + ? Extract + : never) | (T["apiKey"] extends true ? Extract : never); /** @@ -454,6 +462,12 @@ export async function authenticatedEnvironmentForAuthentication( slug = "stg"; } + // Normalize the requested branch once: sanitize it, then collapse the dev + // `"default"` sentinel to "no branch" so it resolves to the root dev env + // rather than a (non-existent) branch literally named "default". + const sanitizedBranch = sanitizeBranchName(branch); + const resolvedBranch = isDefaultDevBranch(sanitizedBranch) ? null : sanitizedBranch; + switch (auth.type) { case "apiKey": { if (!auth.result.ok) { @@ -470,7 +484,10 @@ export async function authenticatedEnvironmentForAuthentication( ); } - if (auth.result.environment.slug !== slug && auth.result.environment.branchName !== branch) { + if ( + auth.result.environment.slug !== slug && + auth.result.environment.branchName !== resolvedBranch + ) { throw json( { error: @@ -499,19 +516,17 @@ export async function authenticatedEnvironmentForAuthentication( throw json({ error: "Project not found" }, { status: 404 }); } - const sanitizedBranch = sanitizeBranchName(branch); - - if (!sanitizedBranch) { + if (!resolvedBranch) { const environment = await $replica.runtimeEnvironment.findFirst({ where: { projectId: project.id, slug: slug, ...(slug === "dev" ? { - orgMember: { - userId: user.id, - }, - } + orgMember: { + userId: user.id, + }, + } : {}), }, include: authIncludeBase, @@ -527,8 +542,10 @@ export async function authenticatedEnvironmentForAuthentication( const environment = await $replica.runtimeEnvironment.findFirst({ where: { projectId: project.id, - type: "PREVIEW", - branchName: sanitizedBranch, + type: { + in: ["PREVIEW", "DEVELOPMENT"], + }, + branchName: resolvedBranch, archivedAt: null, }, include: authIncludeWithParent, @@ -539,10 +556,10 @@ export async function authenticatedEnvironmentForAuthentication( } if (!environment.parentEnvironment) { - throw json({ error: "Branch not associated with a preview environment" }, { status: 400 }); + throw json({ error: "Branch not associated with a parent environment" }, { status: 400 }); } - // PREVIEW envs reuse the parent's apiKey for downstream auth flows + // PREVIEW envs (and DEVELOPMENT branches) reuse the parent's apiKey for downstream auth flows // (signed JWTs, internal-fetch helpers). Override before mapping so // the slim shape carries the parent's key. return toAuthenticated({ @@ -572,9 +589,7 @@ export async function authenticatedEnvironmentForAuthentication( throw json({ error: "Project not found" }, { status: 404 }); } - const sanitizedBranch = sanitizeBranchName(branch); - - if (!sanitizedBranch) { + if (!resolvedBranch) { const environment = await $replica.runtimeEnvironment.findFirst({ where: { projectId: project.id, @@ -593,8 +608,10 @@ export async function authenticatedEnvironmentForAuthentication( const environment = await $replica.runtimeEnvironment.findFirst({ where: { projectId: project.id, - type: "PREVIEW", - branchName: sanitizedBranch, + type: { + in: ["PREVIEW", "DEVELOPMENT"], + }, + branchName: resolvedBranch, archivedAt: null, }, include: authIncludeWithParent, diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts index 9d7897f32c..1f4c0cadba 100644 --- a/apps/webapp/app/services/archiveBranch.server.ts +++ b/apps/webapp/app/services/archiveBranch.server.ts @@ -56,10 +56,16 @@ export class ArchiveBranchService { }, }); - if (!environment.parentEnvironmentId) { + // A branch is defined by having a parent; the branchable root (dev or + // preview) has none and can't be archived. For dev, that root is the + // default branch, so give the clearer message. + if (!environment.parentEnvironmentId || environment.isBranchableEnvironment) { return { success: false as const, - error: "This isn't a branch, and cannot be archived.", + error: + environment.type === "DEVELOPMENT" + ? "The default development branch cannot be archived." + : "This isn't a branch, and cannot be archived.", }; } diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index e13f5d244c..3259a877cd 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -2,10 +2,15 @@ import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/ import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; -import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { logger } from "./logger.server"; import { getCurrentPlan, getLimit } from "./platform.v3.server"; +import { type z } from "zod"; +import invariant from "tiny-invariant"; +import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; + + +type CreateBranchOptions = z.infer; export class UpsertBranchService { #prismaClient: PrismaClient; @@ -22,8 +27,12 @@ export class UpsertBranchService { orgFilter: | { type: "userMembership"; userId: string } | { type: "orgId"; organizationId: string }, - { parentEnvironmentId, branchName, git }: CreateBranchOptions + { projectId, env, branchName, git }: CreateBranchOptions ) { + + + const parentEnvSlug = env === "preview" ? "preview" : "dev"; + const sanitizedBranchName = sanitizeBranchName(branchName); if (!sanitizedBranchName) { return { @@ -42,16 +51,17 @@ export class UpsertBranchService { try { const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ where: { - id: parentEnvironmentId, + projectId, + slug: parentEnvSlug, organization: orgFilter.type === "userMembership" ? { - members: { - some: { - userId: orgFilter.userId, - }, + members: { + some: { + userId: orgFilter.userId, }, - } + }, + } : { id: orgFilter.organizationId }, }, include: { @@ -71,7 +81,11 @@ export class UpsertBranchService { }, }); + // Dev environments are scoped per org member, so a dev branch must inherit + // its parent's orgMemberId. Preview parents have no orgMember (orgMemberId is null). + if (!parentEnvironment) { + invariant(env === "preview", "No default dev runtime environment setup"); return { success: false as const, error: "You don't have preview branches setup. Go to the dashboard to enable them.", @@ -81,16 +95,14 @@ export class UpsertBranchService { if (!parentEnvironment.isBranchableEnvironment) { return { success: false as const, - error: "Your preview environment is not branchable", + error: `Your ${env} environment is not branchable`, }; } + + const limits = await checkBranchLimit( - this.#prismaClient, - parentEnvironment.organization.id, - parentEnvironment.project.id, - sanitizedBranchName - ); + { prisma: this.#prismaClient, organizationId: parentEnvironment.organization.id, projectId: parentEnvironment.project.id, env, newBranchName: sanitizedBranchName }); if (limits.isAtLimit) { return { @@ -132,6 +144,9 @@ export class UpsertBranchService { parentEnvironment: { connect: { id: parentEnvironment.id }, }, + orgMember: parentEnvironment.orgMemberId + ? { connect: { id: parentEnvironment.orgMemberId } } + : undefined, git: git ?? undefined, }, update: { @@ -159,17 +174,26 @@ export class UpsertBranchService { } export async function checkBranchLimit( - prisma: PrismaClientOrTransaction, - organizationId: string, - projectId: string, - newBranchName?: string -) { + { prisma, organizationId, projectId, userId, env, newBranchName }: + { prisma: PrismaClientOrTransaction; organizationId: string; projectId: string; userId?: string; env: "preview" | "development"; newBranchName?: string; }) { + + // TODO audit mishmash of preview/developement preview/dev stg/dev PREVIEW/DEVELOPMENT + const envType = env === "preview" ? "PREVIEW" : "DEVELOPMENT"; + + let orgMemberWhere = {}; + if (envType === "DEVELOPMENT") { + invariant(userId, "Cannot use org access for dev server"); + orgMemberWhere = { orgMember: { userId } }; + } + const usedEnvs = await prisma.runtimeEnvironment.findMany({ where: { projectId, - branchName: { - not: null, - }, + type: envType, + // For PREVIEW, count only branches (exclude the branchable parent). For + // DEVELOPMENT, the root env counts toward the limit alongside its branches. + ...(envType === "PREVIEW" ? { parentEnvironmentId: { not: null } } : {}), + ...orgMemberWhere, archivedAt: null, }, }); @@ -177,7 +201,9 @@ export async function checkBranchLimit( const count = newBranchName ? usedEnvs.filter((env) => env.branchName !== newBranchName).length : usedEnvs.length; - const baseLimit = await getLimit(organizationId, "branches", 100_000_000); + + const limitName = env === "preview" ? "branches" : "branchesDev"; + const baseLimit = await getLimit(organizationId, limitName, 100_000_000); const currentPlan = await getCurrentPlan(organizationId); const purchasedBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0; const limit = baseLimit + purchasedBranches; diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 68f9aa0f3b..7d915bf32f 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -1114,6 +1114,19 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment ]); } + // Dev branches set branchName too, so carry it to the task via the same + // TRIGGER_PREVIEW_BRANCH var the prod path uses — the SDK reads it for the + // x-trigger-branch header (the header is branch-type agnostic). Skipped for + // the default dev env (branchName null), so non-branch dev is unchanged. + if (runtimeEnvironment.branchName) { + result = result.concat([ + { + key: "TRIGGER_PREVIEW_BRANCH", + value: runtimeEnvironment.branchName, + }, + ]); + } + const commonVariables = await resolveCommonBuiltInVariables(runtimeEnvironment); return [...result, ...commonVariables]; diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 2a135a475d..15ee7a14d3 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -146,7 +146,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { }; } - // PREVIEW envs are parents — operating "on a branch" means routing + // PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing // to a child env keyed by branchName. The customer authenticates // with the parent's apiKey + an `x-trigger-branch` header. Mirror // findEnvironmentByApiKey: include the matching child env so the @@ -196,14 +196,25 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { // downstream code operates on the branch (its own id, but the // parent's apiKey/orgMember/organization/project — exactly what // findEnvironmentByApiKey does for the legacy auth path). - if (env.type === "PREVIEW") { - if (!branchName) { + if (env.type === "PREVIEW" && !branchName) { + return { + ok: false, + status: 401, + error: "x-trigger-branch header required for preview env", + }; + } + // Pivot to the child env so downstream code operates on the branch + // (its own id, but the parent's apiKey/orgMember/organization/project — + // exactly what findEnvironmentByApiKey does for the legacy auth path). + if (branchName !== null && branchName !== "default") { + if (env.type !== "PREVIEW" && env.type !== "DEVELOPMENT") { return { ok: false, status: 401, - error: "x-trigger-branch header required for preview env", + error: "x-trigger-branch header can only be used with preview and dev envs", }; } + const child = env.childEnvironments?.[0]; if (!child) { return { ok: false, status: 401, error: "No matching branch env" }; diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index 6120c3aae0..c647b1ae7b 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -16,7 +16,7 @@ export class ApiClientMissingError extends Error { export class APIClientManagerAPI { private static _instance?: APIClientManagerAPI; - private constructor() {} + private constructor() { } public static getInstance(): APIClientManagerAPI { if (!this._instance) { @@ -56,6 +56,9 @@ export class APIClientManagerAPI { get branchName(): string | undefined { const scoped = sdkScope.getStore(); if (scoped) { + // previewBranch carries the branch for any branchable env (preview or dev) — + // they share the x-trigger-branch header. resolveApiClientConfig folds in the + // dev-side TRIGGER_DEV_BRANCH carrier when building the scoped config. const value = scoped.apiClientConfig.previewBranch; return value ? value : undefined; } @@ -64,6 +67,9 @@ export class APIClientManagerAPI { config?.previewBranch ?? getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? getEnvVar("VERCEL_GIT_COMMIT_REF") ?? + // Dev branches share the x-trigger-branch header; TRIGGER_DEV_BRANCH is the + // dev-side carrier. Read the raw env var only — never the "default" sentinel. + getEnvVar("TRIGGER_DEV_BRANCH") ?? undefined; return value ? value : undefined; } @@ -80,7 +86,8 @@ export class APIClientManagerAPI { previewBranch: partial.previewBranch ?? getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? - getEnvVar("VERCEL_GIT_COMMIT_REF"), + getEnvVar("VERCEL_GIT_COMMIT_REF") ?? + getEnvVar("TRIGGER_DEV_BRANCH"), requestOptions: partial.requestOptions, future: partial.future, }; diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 155bb77b7a..610fa98711 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -605,7 +605,7 @@ export type DeploymentTriggeredVia = z.infer; export const UpsertBranchRequestBody = z.object({ git: GitMeta.optional(), - env: z.enum(["preview"]), + env: z.enum(["preview", "development"]), branch: z.string(), }); diff --git a/packages/core/src/v3/utils/gitBranch.ts b/packages/core/src/v3/utils/gitBranch.ts index b1f2f2df27..341d4cca6a 100644 --- a/packages/core/src/v3/utils/gitBranch.ts +++ b/packages/core/src/v3/utils/gitBranch.ts @@ -1,3 +1,23 @@ +/** + * The sentinel branch name the CLI/SDK sends for a `trigger dev` session that + * isn't targeting a named dev branch. On the server the "root" development + * environment is stored with `branchName: null`, so this value never matches a + * real row — call sites translate it to "no branch" via {@link isDefaultDevBranch}. + * + * It's a wire value: any client (the CLI, a custom frontend) can send it in the + * `x-trigger-branch` header, so the server must always interpret it, never + * assume the CLI stripped it. + */ +export const DEFAULT_DEV_BRANCH = "default"; + +/** + * Whether a branch name is the {@link DEFAULT_DEV_BRANCH} sentinel, i.e. it + * refers to the root development environment rather than a named dev branch. + */ +export function isDefaultDevBranch(branchName: string | null | undefined): boolean { + return branchName === DEFAULT_DEV_BRANCH; +} + export function isValidGitBranchName(branch: string): boolean { if (!branch) return false;