From ba9f6e7f31c8dc45aae07a6333b775ed18dbea27 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 7 Jul 2026 23:57:47 +0000 Subject: [PATCH 001/103] feat(webapp): migrate from the classic Remix compiler to Vite Workspace packages resolve to TS source via the @triggerdotdev/source condition; server.ts keeps the Express/cluster/ws wiring with vite middleware in dev and the ESM server bundle in prod. Fixes surfaced by the stricter ESM pipeline: destructured route exports, server-only imports in routes/components, CJS interop (redlock, cuid, regression), browser node-global shims, font asset handling, and a websockets TDZ. --- .changeset/vite-ignore-optional-imports.md | 5 + .../app/components/primitives/Avatar.tsx | 3 +- .../presenters/v3/UsagePresenter.server.ts | 5 +- apps/webapp/app/root.tsx | 8 +- .../routes/api.v1.errors.$errorId.ignore.ts | 5 +- .../routes/api.v1.errors.$errorId.resolve.ts | 5 +- .../api.v1.errors.$errorId.unresolve.ts | 5 +- .../api.v1.idempotencyKeys.$key.reset.ts | 4 +- .../routes/api.v1.orgs.$orgParam.projects.ts | 3 +- ...queues.$queueParam.concurrency.override.ts | 4 +- ...v1.queues.$queueParam.concurrency.reset.ts | 4 +- .../routes/api.v1.queues.$queueParam.pause.ts | 4 +- .../app/routes/api.v1.runs.$runId.metadata.ts | 2 +- ...api.v1.sessions.$sessionId.snapshot-url.ts | 4 +- .../app/routes/auth.github.callback.tsx | 4 +- apps/webapp/app/routes/auth.github.ts | 13 +- .../app/routes/auth.google.callback.tsx | 4 +- apps/webapp/app/routes/auth.google.ts | 13 +- .../app/services/redirectCookies.server.ts | 19 +++ apps/webapp/app/tailwind.css | 3 - apps/webapp/app/v3/querySchemas.ts | 6 +- apps/webapp/package.json | 12 +- apps/webapp/remix.config.js | 48 ------ apps/webapp/remix.env.d.ts | 1 + apps/webapp/server.ts | 67 ++++++--- apps/webapp/vite.config.ts | 66 ++++++++ apps/webapp/vite/node-globals-shim.js | 10 ++ docker/Dockerfile | 6 + docker/docker-compose.yml | 8 +- internal-packages/rbac/src/index.ts | 3 +- .../run-engine/src/engine/locking.ts | 24 +-- internal-packages/sso/src/index.ts | 3 +- .../trigger-sdk/src/v3/aiAutoTelemetry.ts | 6 +- pnpm-lock.yaml | 142 +++++++++--------- 34 files changed, 310 insertions(+), 209 deletions(-) create mode 100644 .changeset/vite-ignore-optional-imports.md create mode 100644 apps/webapp/app/services/redirectCookies.server.ts delete mode 100644 apps/webapp/remix.config.js create mode 100644 apps/webapp/vite.config.ts create mode 100644 apps/webapp/vite/node-globals-shim.js diff --git a/.changeset/vite-ignore-optional-imports.md b/.changeset/vite-ignore-optional-imports.md new file mode 100644 index 00000000000..1720d87044e --- /dev/null +++ b/.changeset/vite-ignore-optional-imports.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Annotate the optional `@ai-sdk/otel` dynamic import with `@vite-ignore` so Vite-based bundlers don't warn about an unanalyzable import. diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 0d000a9a372..9fc6832fcc4 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -10,7 +10,6 @@ import { } from "@heroicons/react/20/solid"; import type { Prisma } from "@trigger.dev/database"; import { z } from "zod"; -import { logger } from "~/services/logger.server"; import { cn } from "~/utils/cn"; export const AvatarType = z.enum(["icon", "letters", "image"]); @@ -45,7 +44,7 @@ export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avat const parsed = AvatarData.safeParse(json); if (!parsed.success) { - logger.error("Invalid org avatar", { json, error: parsed.error }); + console.error("Invalid org avatar", { json, error: parsed.error }); return defaultAvatar; } diff --git a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts index fe5ab1ec3ce..a3b96cf6ac3 100644 --- a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts @@ -1,5 +1,8 @@ import type { DataPoint } from "regression"; -import { linear } from "regression"; +// Default-import: regression is CJS and its named exports aren't statically +// analyzable under ESM interop. +import regression from "regression"; +const { linear } = regression; import type { PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index c84da200ad4..66df9ddff53 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -1,11 +1,14 @@ import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; -import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { ExternalScripts } from "remix-utils/external-scripts"; import type { ToastMessage } from "~/models/message.server"; import { commitSession, getSession } from "~/models/message.server"; -import tailwindStylesheetUrl from "~/tailwind.css"; +// Fonts imported here so Vite rebases the urls and emits the woff2 assets +import "non.geist"; +import "non.geist/mono"; +import tailwindStylesheetUrl from "~/tailwind.css?url"; import { RouteErrorDisplay } from "./components/ErrorDisplay"; import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout"; import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider"; @@ -137,7 +140,6 @@ export default function App() { - diff --git a/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts b/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts index 4b0ad2ab671..9aba43042c9 100644 --- a/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts +++ b/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts @@ -9,7 +9,7 @@ const ParamsSchema = z.object({ errorId: z.string(), }); -export const { action, loader } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, body: IgnoreErrorRequestBody, @@ -56,3 +56,6 @@ export const { action, loader } = createActionApiRoute( return json(updated); } ); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts b/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts index e0818c89f49..68d4a094ab8 100644 --- a/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts +++ b/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts @@ -9,7 +9,7 @@ const ParamsSchema = z.object({ errorId: z.string(), }); -export const { action, loader } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, body: ResolveErrorRequestBody, @@ -48,3 +48,6 @@ export const { action, loader } = createActionApiRoute( return json(updated); } ); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts b/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts index 9362b7c4c4b..ba16118c816 100644 --- a/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts +++ b/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts @@ -8,7 +8,7 @@ const ParamsSchema = z.object({ errorId: z.string(), }); -export const { action, loader } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, method: "POST", @@ -40,3 +40,6 @@ export const { action, loader } = createActionApiRoute( return json(updated); } ); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts index feec6dd6554..d0304035f7b 100644 --- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts +++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts @@ -13,7 +13,7 @@ const BodySchema = z.object({ taskIdentifier: z.string().min(1, "Task identifier is required"), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, body: BodySchema, @@ -50,3 +50,5 @@ export const { action } = createActionApiRoute( } } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 33b68ad244a..6b91205e464 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -7,7 +7,8 @@ import { prisma } from "~/db.server"; import { createProject } from "~/models/project.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { isCuid } from "cuid"; +import cuid from "cuid"; +const { isCuid } = cuid; const ParamsSchema = z.object({ orgParam: z.string(), diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 3223a2a6062..1d3aa69b152 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -10,7 +10,7 @@ const BodySchema = z.object({ concurrencyLimit: z.number().int().min(0).max(100000), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { body: BodySchema, params: z.object({ @@ -73,3 +73,5 @@ export const { action } = createActionApiRoute( ); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts index dbeea591ade..78c2729808e 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts @@ -9,7 +9,7 @@ const BodySchema = z.object({ type: RetrieveQueueType.default("id"), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { body: BodySchema, params: z.object({ @@ -73,3 +73,5 @@ export const { action } = createActionApiRoute( ); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts index 452bd81746b..98ecd290c4b 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -9,7 +9,7 @@ const BodySchema = z.object({ action: z.enum(["pause", "resume"]), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { body: BodySchema, params: z.object({ @@ -44,3 +44,5 @@ export const { action } = createActionApiRoute( return json(q); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts index 58cf572f44d..41af5f388fd 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts @@ -79,7 +79,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // wraps it in auth + body parsing + error-handler middleware), but the // fan-out helper carries the load-bearing logic — including the ops- // visibility branch this change adds. -export async function routeOperationsToRun( +async function routeOperationsToRun( targetRunId: string | undefined, operations: RunMetadataChangeOperation[] | undefined, env: AuthenticatedEnvironment diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index ba70f08daae..bfe609b7a85 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -40,7 +40,7 @@ function sessionResource( return anyResource([...ids].map((id) => ({ type: "sessions" as const, id }))); } -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { ...routeConfig, method: "PUT", @@ -94,3 +94,5 @@ export const loader = createLoaderApiRoute( return json({ presignedUrl: signed.url }); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 32c23ab665b..28d5deb706c 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -9,12 +9,12 @@ import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; import type { AuthUser } from "~/services/authUser"; -import { redirectCookie } from "./auth.github"; +import { githubRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = async ({ request }) => { const cookie = request.headers.get("Cookie"); - const redirectValue = await redirectCookie.parse(cookie); + const redirectValue = await githubRedirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); // The SSO auto-discovery gate runs inside the strategy's verify diff --git a/apps/webapp/app/routes/auth.github.ts b/apps/webapp/app/routes/auth.github.ts index 8c464e0e598..04bd5fe1284 100644 --- a/apps/webapp/app/routes/auth.github.ts +++ b/apps/webapp/app/routes/auth.github.ts @@ -1,6 +1,6 @@ -import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunction, redirect } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; -import { env } from "~/env.server"; +import { githubRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = () => redirect("/login"); @@ -23,15 +23,8 @@ export let action: ActionFunction = async ({ request }) => { if (error instanceof Response) { // we need to append a Set-Cookie header with a cookie storing the // returnTo value (store the sanitized path) - error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect)); + error.headers.append("Set-Cookie", await githubRedirectCookie.serialize(safeRedirect)); } throw error; } }; - -export const redirectCookie = createCookie("redirect-to", { - maxAge: 60 * 60, // 1 hour - httpOnly: true, - sameSite: "lax", - secure: env.NODE_ENV === "production", -}); diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 593b9d40fb8..e32dd43384f 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -9,12 +9,12 @@ import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; import type { AuthUser } from "~/services/authUser"; -import { redirectCookie } from "./auth.google"; +import { googleRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = async ({ request }) => { const cookie = request.headers.get("Cookie"); - const redirectValue = await redirectCookie.parse(cookie); + const redirectValue = await googleRedirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); // The SSO auto-discovery gate runs inside the strategy's verify diff --git a/apps/webapp/app/routes/auth.google.ts b/apps/webapp/app/routes/auth.google.ts index 95fb4ff7b58..09ab022bd9f 100644 --- a/apps/webapp/app/routes/auth.google.ts +++ b/apps/webapp/app/routes/auth.google.ts @@ -1,6 +1,6 @@ -import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunction, redirect } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; -import { env } from "~/env.server"; +import { googleRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = () => redirect("/login"); @@ -23,15 +23,8 @@ export let action: ActionFunction = async ({ request }) => { if (error instanceof Response) { // we need to append a Set-Cookie header with a cookie storing the // returnTo value (store the sanitized path) - error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect)); + error.headers.append("Set-Cookie", await googleRedirectCookie.serialize(safeRedirect)); } throw error; } }; - -export const redirectCookie = createCookie("google-redirect-to", { - maxAge: 60 * 60, // 1 hour - httpOnly: true, - sameSite: "lax", - secure: env.NODE_ENV === "production", -}); diff --git a/apps/webapp/app/services/redirectCookies.server.ts b/apps/webapp/app/services/redirectCookies.server.ts new file mode 100644 index 00000000000..58cecbc3398 --- /dev/null +++ b/apps/webapp/app/services/redirectCookies.server.ts @@ -0,0 +1,19 @@ +import { createCookie } from "@remix-run/node"; +import { env } from "~/env.server"; + +// Post-auth redirect cookies. Kept in a .server module: Vite can't strip +// non-standard route exports that pull in server-only code. + +export const githubRedirectCookie = createCookie("redirect-to", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", +}); + +export const googleRedirectCookie = createCookie("google-redirect-to", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", +}); diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index f7b05430c89..5a6429fce80 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -1,6 +1,3 @@ -@import url("non.geist"); -@import url("non.geist/mono"); - @import "react-grid-layout/css/styles.css" layer(base); @import "react-resizable/css/styles.css" layer(base); diff --git a/apps/webapp/app/v3/querySchemas.ts b/apps/webapp/app/v3/querySchemas.ts index 4784ad75629..a860d411a37 100644 --- a/apps/webapp/app/v3/querySchemas.ts +++ b/apps/webapp/app/v3/querySchemas.ts @@ -2,7 +2,6 @@ import { column, type BucketThreshold, type TableSchema } from "@internal/tsql"; import { z } from "zod"; import { autoFormatSQL } from "~/components/code/TSQLEditor"; import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus"; -import { logger } from "~/services/logger.server"; export const QueryScopeSchema = z.enum(["organization", "project", "environment"]); export type QueryScope = z.infer; @@ -443,10 +442,7 @@ export const runsSchema: TableSchema = { ...column("Array(String)", { description: "Any bulk actions that operated on this run.", example: '["bulk_12345678", "bulk_34567890"]', - whereTransform: (value: string) => { - logger.log(`WHERE TRANSFORM: ${value}`); - return value.replace(/^bulk_/, ""); - }, + whereTransform: (value: string) => value.replace(/^bulk_/, ""), }), }, }, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index ea7352cb3c1..c98376d754f 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -5,10 +5,10 @@ "sideEffects": false, "scripts": { "build": "run-s build:** && pnpm run upload:sourcemaps", - "build:remix": "remix build --sourcemap", + "build:remix": "remix vite:build", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap", "build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap", - "dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"", + "dev": "cross-env NODE_ENV=development PORT=3030 tsx ./server.ts", "dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js", "format": "oxfmt .", "lint": "oxlint -c ../../.oxlintrc.json", @@ -141,6 +141,7 @@ "@whatwg-node/fetch": "^0.9.14", "@window-splitter/react": "1.1.3", "ai": "^6.0.116", + "assert": "^2.1.0", "assert-never": "^1.2.1", "aws4fetch": "^1.0.18", "class-variance-authority": "^0.5.2", @@ -184,6 +185,7 @@ "p-map": "^6.0.0", "p-retry": "^4.6.1", "parse-duration": "^2.1.0", + "pg": "8.15.6", "posthog-js": "^1.93.3", "posthog-node": "5.35.6", "prism-react-renderer": "^2.3.1", @@ -227,10 +229,11 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.6.0", "tailwind-scrollbar-hide": "^4.0.0", - "tw-animate-css": "^1.4.0", "tiny-invariant": "^1.2.0", + "tw-animate-css": "^1.4.0", "ulid": "^2.3.0", "ulidx": "^2.2.1", + "util": "^0.12.5", "uuid": "^14.0.0", "ws": "^8.11.0", "zod": "3.25.76", @@ -291,7 +294,8 @@ "tailwindcss": "^4.3.1", "tsconfig-paths": "^3.14.1", "tsx": "^4.20.6", - "vite-tsconfig-paths": "^4.0.5" + "vite-tsconfig-paths": "^5.1.4", + "vite": "^6.4.2" }, "engines": { "node": ">=18.19.0 || >=20.6.0" diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js deleted file mode 100644 index f6bad31aa77..00000000000 --- a/apps/webapp/remix.config.js +++ /dev/null @@ -1,48 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - dev: { - port: 8002, - }, - // Tailwind v4 runs through PostCSS (@tailwindcss/postcss), not the built-in Remix integration - tailwind: false, - postcss: true, - cacheDirectory: "./node_modules/.cache/remix", - ignoredRouteFiles: ["**/.*"], - serverModuleFormat: "cjs", - serverDependenciesToBundle: [ - /^remix-utils.*/, - /^@internal\//, // Bundle all internal packages - /^@trigger\.dev\//, // Bundle all trigger packages - "marked", - "agentcrumbs", - "axios", - "p-limit", - "p-map", - "yocto-queue", - "@unkey/cache", - "@unkey/cache/stores", - "emails", - "highlight.run", - "random-words", - "superjson", - "copy-anything", - "is-what", - "prismjs/components/prism-json", - "prismjs/components/prism-typescript", - "redlock", - "parse-duration", - "uncrypto", - "std-env", - "uuid", - ], - browserNodeBuiltinsPolyfill: { - modules: { - path: true, - os: true, - crypto: true, - http2: true, - assert: true, - util: true, - }, - }, -}; diff --git a/apps/webapp/remix.env.d.ts b/apps/webapp/remix.env.d.ts index 72e2affe311..3a7ebcc0835 100644 --- a/apps/webapp/remix.env.d.ts +++ b/apps/webapp/remix.env.d.ts @@ -1,2 +1,3 @@ /// +/// /// diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index c3c1a45f633..2b96d24ead3 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -1,13 +1,13 @@ import "./sentry.server"; import { createRequestHandler } from "@remix-run/express"; -import { broadcastDevReady, logDevReady } from "@remix-run/server-runtime"; import compression from "compression"; import type { Server as EngineServer } from "engine.io"; import express, { type RequestHandler } from "express"; import morgan from "morgan"; import { nanoid } from "nanoid"; import path from "path"; +import { pathToFileURL } from "node:url"; import type { Server as IoServer } from "socket.io"; import type { WebSocketServer } from "ws"; import type { RateLimitMiddleware } from "~/services/apiRateLimit.server"; @@ -74,6 +74,12 @@ function installPrimarySignalHandlers() { }); } +// Bundled to CJS (esbuild rewrites import() to require); vite and the Remix +// server bundle are ESM, so load them via a real dynamic import. +const dynamicImport = new Function("specifier", "return import(specifier)") as ( + specifier: string +) => Promise; + if (ENABLE_CLUSTER && cluster.isPrimary) { process.title = `node webapp-server primary`; console.log(`[cluster] Primary ${process.pid} is starting with ${WORKERS} workers`); @@ -92,6 +98,13 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { installPrimarySignalHandlers(); } else { + startServer().catch((error) => { + console.error("Failed to start server:", error); + process.exit(1); + }); +} + +async function startServer() { const app = express(); if (process.env.DISABLE_COMPRESSION !== "1") { @@ -101,16 +114,25 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header app.disable("x-powered-by"); - // Remix fingerprints its assets so we can cache forever. - app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" })); - // Stale dev builds can request an old hashed manifest; don't fall through to Remix. - app.use("/build", (_req, res) => { - res.status(404).end(); - }); + const MODE = process.env.NODE_ENV; - // Everything else (like favicon.ico) is cached for an hour. You may want to be - // more aggressive with this caching. - app.use(express.static("public", { maxAge: "1h" })); + // In development, Vite serves assets (and handles HMR) via middleware. + const viteDevServer = + MODE === "production" + ? undefined + : await dynamicImport("vite").then((vite) => + vite.createServer({ server: { middlewareMode: true } }) + ); + + if (viteDevServer) { + app.use(viteDevServer.middlewares); + } else { + // Vite fingerprints its assets so we can cache forever. + app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" })); + // Everything else (like favicon.ico) is cached for an hour. You may want to be + // more aggressive with this caching. + app.use(express.static("build/client", { maxAge: "1h" })); + } // On high-volume machine-ingest services (e.g. otel) the per-request access // log dominates log volume. HTTP_ACCESS_LOG_DISABLED suppresses successful @@ -127,9 +149,17 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { ? `node webapp-worker-${cluster.isWorker ? cluster.worker?.id : "solo"}` : "node webapp-server"; - const MODE = process.env.NODE_ENV; - const BUILD_DIR = path.join(process.cwd(), "build"); - const build = require(BUILD_DIR); + const loadBuild = () => { + if (viteDevServer) { + return viteDevServer.ssrLoadModule("virtual:remix/server-build"); + } + return dynamicImport( + pathToFileURL(path.join(process.cwd(), "build", "server", "index.mjs")).href + ); + }; + + // Boots the entry.server singletons (socket.io, wss, rate limiters). + const build = await loadBuild(); const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000; @@ -196,7 +226,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { "*", // @ts-ignore createRequestHandler({ - build, + build: viteDevServer ? loadBuild : build, mode: MODE, }) ); @@ -209,7 +239,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { "/healthcheck", // @ts-ignore createRequestHandler({ - build, + build: viteDevServer ? loadBuild : build, mode: MODE, }) ); @@ -221,12 +251,6 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { ENABLE_CLUSTER && cluster.isWorker ? ` [worker ${cluster.worker?.id}/${process.pid}]` : "" }` ); - - if (MODE === "development") { - broadcastDevReady(build) - .then(() => logDevReady(build)) - .catch(console.error); - } }); server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS; @@ -293,7 +317,6 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { }); }); } else { - require(BUILD_DIR); console.log(`✅ app ready (skipping http server)`); } } diff --git a/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts new file mode 100644 index 00000000000..b008ae04ed0 --- /dev/null +++ b/apps/webapp/vite.config.ts @@ -0,0 +1,66 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defaultClientConditions, defaultServerConditions, defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ["**/.*"], + // .mjs so the CJS server.ts wrapper can dynamic-import it + serverBuildFile: "index.mjs", + }), + tsconfigPaths(), + ], + resolve: { + // Resolve workspace packages to TS source (same condition the CLI uses) + conditions: ["@triggerdotdev/source", ...defaultClientConditions], + // Browser polyfills for node builtins used by client deps (antlr4ts) + alias: [ + { find: /^assert$/, replacement: "assert/" }, + { find: /^util$/, replacement: "util/" }, + ], + }, + optimizeDeps: { + // Crawl all routes up front - mid-session re-optimization duplicates React + entries: ["./app/entry.client.tsx", "./app/root.tsx", "./app/routes/**/*.{ts,tsx}"], + esbuildOptions: { + // node globals for prebundled CJS deps (client-only by construction) + define: { global: "globalThis" }, + inject: ["./vite/node-globals-shim.js"], + }, + }, + server: { + warmup: { + clientFiles: ["./app/entry.client.tsx", "./app/root.tsx", "./app/components/**/*.tsx"], + ssrFiles: ["./app/entry.server.tsx", "./app/root.tsx"], + }, + }, + build: { + sourcemap: true, + rollupOptions: { + // Prisma wrappers and pg have CJS/native pieces Rollup can't inline + external: [/^@trigger\.dev\/database$/, /^@internal\/run-ops-database$/, /^pg$/], + }, + }, + ssr: { + resolve: { + conditions: ["@triggerdotdev/source", ...defaultServerConditions], + externalConditions: ["@triggerdotdev/source", "node"], + }, + // CJS Prisma clients and native pg must load through node + external: ["@trigger.dev/database", "@internal/run-ops-database", "pg"], + // CJS deps whose named exports node's ESM interop can't detect + noExternal: [ + /^@radix-ui\//, + "react-use", + "cron-parser", + "@fingerprintjs/fingerprintjs-pro-react", + "@kapaai/react-sdk", + "@fingerprintjs/fingerprintjs-pro", + "@fingerprintjs/fingerprintjs-pro-spa", + ], + optimizeDeps: { + include: ["cron-parser"], + }, + }, +}); diff --git a/apps/webapp/vite/node-globals-shim.js b/apps/webapp/vite/node-globals-shim.js new file mode 100644 index 00000000000..68d6826c862 --- /dev/null +++ b/apps/webapp/vite/node-globals-shim.js @@ -0,0 +1,10 @@ +// Minimal `process` stand-in injected into prebundled browser deps +// (see vite.config.ts optimizeDeps). Client-only. +export const process = { + env: {}, + browser: true, + version: "", + platform: "browser", + cwd: () => "/", + nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0), +}; diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b140b037f3..2e94f121498 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -91,6 +91,12 @@ COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/database # Run-ops Prisma client (query engine + client). Only constructed when the split is enabled, # but the image must carry it so a split-on deployment doesn't hit a missing query engine. COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/run-ops-database/generated ./internal-packages/run-ops-database/generated +# These workspace packages stay external to the Vite SSR bundle, so their +# built entry points must ship in the image (core is the sdk's runtime dep). +COPY --from=builder --chown=node:node /triggerdotdev/internal-packages/database/dist ./internal-packages/database/dist +COPY --from=builder --chown=node:node /triggerdotdev/internal-packages/run-ops-database/dist ./internal-packages/run-ops-database/dist +COPY --from=builder --chown=node:node /triggerdotdev/packages/trigger-sdk/dist ./packages/trigger-sdk/dist +COPY --from=builder --chown=node:node /triggerdotdev/packages/core/dist ./packages/core/dist COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build/server.js ./apps/webapp/build/server.js COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build ./apps/webapp/build COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/public ./apps/webapp/public diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 46574c2677c..bc682658d21 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -231,10 +231,10 @@ services: "--query", "SELECT 1", ] - interval: "3s" - timeout: "5s" - retries: "5" - start_period: "10s" + interval: 3s + timeout: 5s + retries: 5 + start_period: 10s clickhouse_migrator: build: diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 258dc12f3b1..dcf046f9ab3 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -85,7 +85,8 @@ class LazyController implements RoleBaseAccessController { } const moduleName = "@triggerdotdev/plugins/rbac"; try { - const module = await import(moduleName); + // Optional plugin, resolved at runtime only + const module = await import(/* @vite-ignore */ moduleName); const plugin: RoleBasedAccessControlPlugin = module.default; console.log("RBAC: using plugin implementation"); return plugin.create({ userActorSecret: options?.userActorSecret }); diff --git a/internal-packages/run-engine/src/engine/locking.ts b/internal-packages/run-engine/src/engine/locking.ts index 0c86da80cc1..900b964bee6 100644 --- a/internal-packages/run-engine/src/engine/locking.ts +++ b/internal-packages/run-engine/src/engine/locking.ts @@ -1,8 +1,12 @@ -// import { default: Redlock } from "redlock"; -const { default: Redlock } = require("redlock"); import { AsyncLocalStorage } from "async_hooks"; import type { Redis } from "@internal/redis"; -import type * as redlock from "redlock"; +import * as redlockModule from "redlock"; + +// redlock is CJS with `exports.default`; probe the interop shapes instead of +// a bare require(), which breaks in ESM module runners. +const Redlock = ((redlockModule as any).default?.default ?? + (redlockModule as any).default ?? + redlockModule) as typeof redlockModule.default; import { tryCatch } from "@trigger.dev/core"; import type { Logger } from "@trigger.dev/core/logger"; import type { Tracer, Meter, ObservableResult, Attributes, Histogram } from "@internal/tracing"; @@ -34,12 +38,12 @@ export class LockAcquisitionTimeoutError extends Error { interface LockContext { resources: string; - signal: redlock.RedlockAbortSignal; + signal: redlockModule.RedlockAbortSignal; lockType: string; } interface ManualLockContext { - lock: redlock.Lock; + lock: redlockModule.Lock; timeout: NodeJS.Timeout | null | undefined; extension: Promise | undefined; } @@ -60,7 +64,7 @@ export interface LockRetryConfig { } export class RunLocker { - private redlock: InstanceType; + private redlock: InstanceType; private asyncLocalStorage: AsyncLocalStorage; private logger: Logger; private tracer: Tracer; @@ -216,7 +220,7 @@ export class RunLocker { let totalWaitTime = 0; // Retry the lock acquisition with exponential backoff - let lock: redlock.Lock | undefined; + let lock: redlockModule.Lock | undefined; let lastError: Error | undefined; for (let attempt = 0; attempt <= maxAttempts; attempt++) { @@ -346,7 +350,7 @@ export class RunLocker { // Create an AbortController for our signal const controller = new AbortController(); - const signal = controller.signal as redlock.RedlockAbortSignal; + const signal = controller.signal as redlockModule.RedlockAbortSignal; const manualContext: ManualLockContext = { lock, @@ -425,7 +429,7 @@ export class RunLocker { #setupAutoExtension( context: ManualLockContext, duration: number, - signal: redlock.RedlockAbortSignal, + signal: redlockModule.RedlockAbortSignal, controller: AbortController ): void { if (this.automaticExtensionThreshold > duration - 100) { @@ -460,7 +464,7 @@ export class RunLocker { async #extendLock( context: ManualLockContext, duration: number, - signal: redlock.RedlockAbortSignal, + signal: redlockModule.RedlockAbortSignal, controller: AbortController, scheduleNext: () => void ): Promise { diff --git a/internal-packages/sso/src/index.ts b/internal-packages/sso/src/index.ts index 0ec6df3adbd..422035b141f 100644 --- a/internal-packages/sso/src/index.ts +++ b/internal-packages/sso/src/index.ts @@ -50,7 +50,8 @@ export class LazyController implements SsoController { } const moduleName = "@triggerdotdev/plugins/sso"; const importer = - options?.importer ?? ((m: string) => import(m) as Promise<{ default: SsoPlugin }>); + options?.importer ?? + ((m: string) => import(/* @vite-ignore */ m) as Promise<{ default: SsoPlugin }>); try { const module = await importer(moduleName); const plugin: SsoPlugin = module.default; diff --git a/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts b/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts index 7533dc9bf05..04bf4b9525a 100644 --- a/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts +++ b/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts @@ -42,10 +42,10 @@ async function register(): Promise { if (typeof aiMod.registerTelemetry !== "function") { return; // v5 / v6 — `ai` core emits spans itself, nothing to wire. } - // Computed specifier keeps the optional peer out of static bundler - // resolution; resolves at runtime only when the customer installed it. + // Computed specifier + @vite-ignore keep the optional peer out of static + // bundler resolution; resolves at runtime only when installed. const otelSpecifier = ["@ai-sdk", "otel"].join("/"); - const otelMod: any = await import(otelSpecifier).catch(() => null); + const otelMod: any = await import(/* @vite-ignore */ otelSpecifier).catch(() => null); if (typeof otelMod?.OpenTelemetry !== "function") { return; // optional peer not installed } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72d5a7afc2c..8994da70c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,6 +548,9 @@ importers: ai: specifier: 6.0.116 version: 6.0.116(zod@3.25.76) + assert: + specifier: ^2.1.0 + version: 2.1.0 assert-never: specifier: ^1.2.1 version: 1.2.1 @@ -677,6 +680,9 @@ importers: parse-duration: specifier: ^2.1.0 version: 2.1.4 + pg: + specifier: 8.15.6 + version: 8.15.6 posthog-js: specifier: ^1.93.3 version: 1.93.3 @@ -818,6 +824,9 @@ importers: ulidx: specifier: ^2.2.1 version: 2.2.1 + util: + specifier: ^0.12.5 + version: 0.12.5 uuid: specifier: ^14.0.0 version: 14.0.0 @@ -993,9 +1002,12 @@ importers: tsx: specifier: ^4.20.6 version: 4.20.6 + vite: + specifier: ^6.4.2 + version: 6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) vite-tsconfig-paths: - specifier: ^4.0.5 - version: 4.0.5(typescript@5.5.4) + specifier: ^5.1.4 + version: 5.1.4(typescript@5.5.4)(vite@6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) docs: {} @@ -9171,6 +9183,9 @@ packages: assert-never@1.2.1: resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==} + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -9466,10 +9481,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.7: - resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} - engines: {node: '>= 0.4'} - call-bind@1.0.8: resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} engines: {node: '>= 0.4'} @@ -10268,10 +10279,6 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - define-properties@1.1.4: - resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} - engines: {node: '>= 0.4'} - define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -11845,6 +11852,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-negative-zero@2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -13227,6 +13238,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -13583,9 +13598,6 @@ packages: pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} - pg-connection-string@2.8.5: - resolution: {integrity: sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow==} - pg-connection-string@2.9.1: resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} @@ -13602,11 +13614,6 @@ packages: peerDependencies: pg: '>=8.0' - pg-pool@3.9.6: - resolution: {integrity: sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==} - peerDependencies: - pg: '>=8.0' - pg-protocol@1.10.3: resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} @@ -13624,15 +13631,6 @@ packages: resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} engines: {node: '>=10'} - pg@8.11.5: - resolution: {integrity: sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==} - engines: {node: '>= 8.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - pg@8.15.6: resolution: {integrity: sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg==} engines: {node: '>= 8.0.0'} @@ -15985,6 +15983,14 @@ packages: vite-tsconfig-paths@4.0.5: resolution: {integrity: sha512-/L/eHwySFYjwxoYt1WRJniuK/jPv+WGwgRGBYx3leciR5wBeqntQpUE6Js6+TJemChc+ter7fDBKieyEWDx4yQ==} + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: ^6.4.2 + peerDependenciesMeta: + vite: + optional: true + vite@4.4.9: resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -19055,7 +19061,7 @@ snapshots: '@electric-sql/client@0.4.0': optionalDependencies: - '@rollup/rollup-darwin-arm64': 4.53.2 + '@rollup/rollup-darwin-arm64': 4.60.1 '@electric-sql/client@1.0.14': dependencies: @@ -25602,6 +25608,14 @@ snapshots: assert-never@1.2.1: {} + assert@2.1.0: + dependencies: + call-bind: 1.0.8 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.5 + util: 0.12.5 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.2: @@ -25970,14 +25984,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.7: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bind@1.0.8: dependencies: call-bind-apply-helpers: 1.0.2 @@ -26780,11 +26786,6 @@ snapshots: define-lazy-prop@3.0.0: {} - define-properties@1.1.4: - dependencies: - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -28296,7 +28297,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@5.5.4) graphile-config: 0.0.1-beta.8 json5: 2.2.3 - pg: 8.11.5 + pg: 8.15.6 tslib: 2.6.2 yargs: 17.7.2 transitivePeerDependencies: @@ -28420,7 +28421,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -28756,6 +28757,11 @@ snapshots: is-interactive@1.0.0: {} + is-nan@1.3.2: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + is-negative-zero@2.0.2: {} is-negative-zero@2.0.3: {} @@ -30438,6 +30444,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.5: @@ -30821,19 +30832,13 @@ snapshots: pg-cloudflare@1.2.7: optional: true - pg-connection-string@2.8.5: {} - pg-connection-string@2.9.1: {} pg-int8@1.0.1: {} pg-numeric@1.0.2: {} - pg-pool@3.10.1(pg@8.11.5): - dependencies: - pg: 8.11.5 - - pg-pool@3.9.6(pg@8.15.6): + pg-pool@3.10.1(pg@8.15.6): dependencies: pg: 8.15.6 @@ -30861,26 +30866,16 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 - pg@8.11.5: + pg@8.15.6: dependencies: pg-connection-string: 2.9.1 - pg-pool: 3.10.1(pg@8.11.5) + pg-pool: 3.10.1(pg@8.15.6) pg-protocol: 1.10.3 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.2.7 - pg@8.15.6: - dependencies: - pg-connection-string: 2.8.5 - pg-pool: 3.9.6(pg@8.15.6) - pg-protocol: 1.9.5 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.2.7 - pgpass@1.0.5: dependencies: split2: 4.2.0 @@ -32632,8 +32627,8 @@ snapshots: string.prototype.padend@3.1.4: dependencies: - call-bind: 1.0.7 - define-properties: 1.1.4 + call-bind: 1.0.8 + define-properties: 1.2.1 es-abstract: 1.21.1 string.prototype.trim@1.2.9: @@ -33690,10 +33685,21 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@5.1.4(typescript@5.5.4)(vite@6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + dependencies: + debug: 4.4.3(supports-color@10.0.0) + globrex: 0.1.2 + tsconfck: 3.1.3(typescript@5.5.4) + optionalDependencies: + vite: 6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + - typescript + vite@4.4.9(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.46.1): dependencies: esbuild: 0.18.20 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 3.29.1 optionalDependencies: '@types/node': 22.20.0 @@ -33706,7 +33712,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: @@ -33723,7 +33729,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: @@ -33740,7 +33746,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: From 9ae20e5ac488974464380e8bfdcbeb9b8cbbe8ab Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 8 Jul 2026 00:30:30 +0000 Subject: [PATCH 002/103] fix(webapp): move routeOperationsToRun into a server module Restores the unit-testable export (removed to satisfy the Vite route export rules) by extracting the helper out of the route file. --- .../app/routes/api.v1.runs.$runId.metadata.ts | 115 +---------------- .../mollifier/routeOperationsToRun.server.ts | 116 ++++++++++++++++++ .../metadataRouteOperationsLogging.test.ts | 2 +- 3 files changed, 119 insertions(+), 114 deletions(-) create mode 100644 apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts index 41af5f388fd..6ad19065045 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts @@ -1,21 +1,18 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; -import type { RunMetadataChangeOperation } from "@trigger.dev/core/v3/schemas"; import { UpdateMetadataRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { $replica } from "~/db.server"; -// Aliased to avoid shadowing the local `env: AuthenticatedEnvironment` -// parameter the route handler and `routeOperationsToRun` use. +// Aliased to avoid shadowing the local `env` parameter in the handler. import { env as appEnv } from "~/env.server"; -import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; -import { logger } from "~/services/logger.server"; import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server"; import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/common.server"; import { applyMetadataMutationToBufferedRun } from "~/v3/mollifier/applyMetadataMutation.server"; +import { routeOperationsToRun } from "~/v3/mollifier/routeOperationsToRun.server"; import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server"; import { runStore } from "~/v3/runStore.server"; @@ -67,114 +64,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Run not found" }, { status: 404 }); } -// Route parent/root operations to the existing PG service by directly -// invoking it against the parent/root runId. The service ingests via -// its batching worker, which targets PG by id. If the parent/root is -// itself buffered we recurse through our buffered-mutation helper. -// `_ingestion_only` flag: a synthetic body that has the operations -// promoted to top-level `operations` so the service applies them to -// `targetRunId` directly. -// Exported so the silent-failure logging behaviour can be unit-tested. -// The route handler itself isn't an attractive test target (createActionApiRoute -// wraps it in auth + body parsing + error-handler middleware), but the -// fan-out helper carries the load-bearing logic — including the ops- -// visibility branch this change adds. -async function routeOperationsToRun( - targetRunId: string | undefined, - operations: RunMetadataChangeOperation[] | undefined, - env: AuthenticatedEnvironment -): Promise { - if (!targetRunId || !operations || operations.length === 0) return; - - // Try PG first via the existing service (this is how parent/root - // operations have always landed; preserve that). Accepts the full - // AuthenticatedEnvironment so we don't have to recover the unsafe - // `as unknown` cast that the previous narrowed `{ id, organizationId }` - // signature forced on us. - // - // Two non-success outcomes from `call`: - // * throws — PG threw (e.g. "Cannot update metadata for a completed - // run", or a transient PG outage). - // * resolves with undefined — PG row didn't exist (the target may be - // buffered, not yet materialised). - // Either way we want to try the buffer fallback below; treating the - // undefined-return as success would make the fallback unreachable. - const [error, result] = await tryCatch( - updateMetadataService.call(targetRunId, { operations }, env) - ); - if (!error && result !== undefined) { - // The parent/root run changed too — wake its live feeds (only when something was - // actually written here; buffered writes publish from the flusher). - if (result.updatedAtMs !== undefined) { - publishChangeRecord({ - runId: result.runId, - envId: env.id, - tags: result.runTags, - batchId: result.batchId, - updatedAtMs: result.updatedAtMs, - }); - } - return; - } - - if (error) { - // PG threw — auxiliary op, stay best-effort and don't surface this - // to the caller (the caller's primary mutation already landed). But - // warn so a genuine PG outage on these ops isn't invisible. - logger.warn("metadata route: parent/root PG op failed", { - targetRunId, - error: error instanceof Error ? error.message : String(error), - }); - } - - // Buffer fallback only makes sense for friendlyId-keyed entries. The - // PG-side parent/root IDs are internal cuids; the buffer keys entries - // by friendlyId, so passing the internal id would silently no-op. - // Skip explicitly — a buffered child's parent is always materialised - // in PG already (a buffered run hasn't executed, so it can't have - // triggered the child), so the buffered-parent branch isn't actually - // reachable. Treating the no-op as intentional rather than incidental. - if (!targetRunId.startsWith("run_")) return; - - // Best-effort buffer fallback. Wrap so a transient Redis throw on - // this auxiliary op can't 500 the request after the primary mutation - // already succeeded. - const [bufferError, bufferOutcome] = await tryCatch( - applyMetadataMutationToBufferedRun({ - runId: targetRunId, - environmentId: env.id, - organizationId: env.organizationId, - maximumSize: appEnv.TASK_RUN_METADATA_MAXIMUM_SIZE, - maxRetries: appEnv.TRIGGER_MOLLIFIER_METADATA_MAX_RETRIES, - backoffBaseMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_BASE_MS, - backoffStepMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_STEP_MS, - body: { operations }, - }) - ); - if (bufferError) { - logger.warn("metadata route: buffer fallback for parent/root op failed", { - targetRunId, - error: bufferError instanceof Error ? bufferError.message : String(bufferError), - }); - return; - } - // `applyMetadataMutationToBufferedRun` reports non-throw failures via - // its returned outcome kind: `not_found`, `busy`, `version_exhausted`, - // `metadata_too_large`. Without inspecting `.kind`, the parent/root - // operation can silently disappear — no PG row landed it (handled - // above) and the buffer rejected it for one of these reasons but the - // helper returned cleanly. Surface a warn log per non-success branch - // so ops can trace why a parent/root op went missing. The customer's - // primary mutation has already succeeded by this point; this remains - // best-effort, so we still don't bubble these to the response. - if (bufferOutcome && bufferOutcome.kind !== "applied") { - logger.warn("metadata route: parent/root buffer op did not apply", { - targetRunId, - kind: bufferOutcome.kind, - }); - } -} - const { action } = createActionApiRoute( { params: ParamsSchema, diff --git a/apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts b/apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts new file mode 100644 index 00000000000..387759ff9e8 --- /dev/null +++ b/apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts @@ -0,0 +1,116 @@ +import { tryCatch } from "@trigger.dev/core/utils"; +import type { RunMetadataChangeOperation } from "@trigger.dev/core/v3/schemas"; +import { env as appEnv } from "~/env.server"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server"; +import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server"; +import { applyMetadataMutationToBufferedRun } from "./applyMetadataMutation.server"; + +// Route parent/root operations to the existing PG service by directly +// invoking it against the parent/root runId. The service ingests via +// its batching worker, which targets PG by id. If the parent/root is +// itself buffered we recurse through our buffered-mutation helper. +// `_ingestion_only` flag: a synthetic body that has the operations +// promoted to top-level `operations` so the service applies them to +// `targetRunId` directly. +// Exported so the silent-failure logging behaviour can be unit-tested. +// The route handler itself isn't an attractive test target (createActionApiRoute +// wraps it in auth + body parsing + error-handler middleware), but the +// fan-out helper carries the load-bearing logic — including the ops- +// visibility branch this change adds. +export async function routeOperationsToRun( + targetRunId: string | undefined, + operations: RunMetadataChangeOperation[] | undefined, + env: AuthenticatedEnvironment +): Promise { + if (!targetRunId || !operations || operations.length === 0) return; + + // Try PG first via the existing service (this is how parent/root + // operations have always landed; preserve that). Accepts the full + // AuthenticatedEnvironment so we don't have to recover the unsafe + // `as unknown` cast that the previous narrowed `{ id, organizationId }` + // signature forced on us. + // + // Two non-success outcomes from `call`: + // * throws — PG threw (e.g. "Cannot update metadata for a completed + // run", or a transient PG outage). + // * resolves with undefined — PG row didn't exist (the target may be + // buffered, not yet materialised). + // Either way we want to try the buffer fallback below; treating the + // undefined-return as success would make the fallback unreachable. + const [error, result] = await tryCatch( + updateMetadataService.call(targetRunId, { operations }, env) + ); + if (!error && result !== undefined) { + // The parent/root run changed too — wake its live feeds (only when something was + // actually written here; buffered writes publish from the flusher). + if (result.updatedAtMs !== undefined) { + publishChangeRecord({ + runId: result.runId, + envId: env.id, + tags: result.runTags, + batchId: result.batchId, + updatedAtMs: result.updatedAtMs, + }); + } + return; + } + + if (error) { + // PG threw — auxiliary op, stay best-effort and don't surface this + // to the caller (the caller's primary mutation already landed). But + // warn so a genuine PG outage on these ops isn't invisible. + logger.warn("metadata route: parent/root PG op failed", { + targetRunId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // Buffer fallback only makes sense for friendlyId-keyed entries. The + // PG-side parent/root IDs are internal cuids; the buffer keys entries + // by friendlyId, so passing the internal id would silently no-op. + // Skip explicitly — a buffered child's parent is always materialised + // in PG already (a buffered run hasn't executed, so it can't have + // triggered the child), so the buffered-parent branch isn't actually + // reachable. Treating the no-op as intentional rather than incidental. + if (!targetRunId.startsWith("run_")) return; + + // Best-effort buffer fallback. Wrap so a transient Redis throw on + // this auxiliary op can't 500 the request after the primary mutation + // already succeeded. + const [bufferError, bufferOutcome] = await tryCatch( + applyMetadataMutationToBufferedRun({ + runId: targetRunId, + environmentId: env.id, + organizationId: env.organizationId, + maximumSize: appEnv.TASK_RUN_METADATA_MAXIMUM_SIZE, + maxRetries: appEnv.TRIGGER_MOLLIFIER_METADATA_MAX_RETRIES, + backoffBaseMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_BASE_MS, + backoffStepMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_STEP_MS, + body: { operations }, + }) + ); + if (bufferError) { + logger.warn("metadata route: buffer fallback for parent/root op failed", { + targetRunId, + error: bufferError instanceof Error ? bufferError.message : String(bufferError), + }); + return; + } + // `applyMetadataMutationToBufferedRun` reports non-throw failures via + // its returned outcome kind: `not_found`, `busy`, `version_exhausted`, + // `metadata_too_large`. Without inspecting `.kind`, the parent/root + // operation can silently disappear — no PG row landed it (handled + // above) and the buffer rejected it for one of these reasons but the + // helper returned cleanly. Surface a warn log per non-success branch + // so ops can trace why a parent/root op went missing. The customer's + // primary mutation has already succeeded by this point; this remains + // best-effort, so we still don't bubble these to the response. + if (bufferOutcome && bufferOutcome.kind !== "applied") { + logger.warn("metadata route: parent/root buffer op did not apply", { + targetRunId, + kind: bufferOutcome.kind, + }); + } +} diff --git a/apps/webapp/test/metadataRouteOperationsLogging.test.ts b/apps/webapp/test/metadataRouteOperationsLogging.test.ts index b7e5f860198..588c1547ed4 100644 --- a/apps/webapp/test/metadataRouteOperationsLogging.test.ts +++ b/apps/webapp/test/metadataRouteOperationsLogging.test.ts @@ -52,7 +52,7 @@ vi.mock("~/services/logger.server", () => ({ }, })); -import { routeOperationsToRun } from "~/routes/api.v1.runs.$runId.metadata"; +import { routeOperationsToRun } from "~/v3/mollifier/routeOperationsToRun.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; const env = { From 42244adf82c49b56e2f4189f9aa680532c4735bd Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 8 Jul 2026 13:28:20 +0000 Subject: [PATCH 003/103] fix(webapp): guard prom metrics registration against vite dev HMR re-evaluation --- .../app/utils/reloadingRegistry.server.ts | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/webapp/app/utils/reloadingRegistry.server.ts b/apps/webapp/app/utils/reloadingRegistry.server.ts index 81eb6723457..3bbb9b4e8b1 100644 --- a/apps/webapp/app/utils/reloadingRegistry.server.ts +++ b/apps/webapp/app/utils/reloadingRegistry.server.ts @@ -3,29 +3,34 @@ import { Counter, Gauge } from "prom-client"; import { metricsRegister } from "~/metrics.server"; import { logger } from "~/services/logger.server"; import { signalsEmitter } from "~/services/signals.server"; +import { singleton } from "~/utils/singleton"; -const loadFailures = new Counter({ - name: "reloading_registry_load_failures_total", - help: "Failed loads of a reloading registry", - labelNames: ["name"], - registers: [metricsRegister], -}); - -const lastSuccessfulLoadAt = new Gauge({ - name: "reloading_registry_last_successful_load_timestamp_seconds", - help: "Unix time of the last successful registry load (staleness signal)", - labelNames: ["name"], - registers: [metricsRegister], -}); - -// 0 until the first successful load, then 1. Starts at 0 (not absent) so a -// never-loaded registry is an alertable series, distinct from "feature off". -const registryLoaded = new Gauge({ - name: "reloading_registry_loaded", - help: "1 once the registry has loaded at least once, else 0 (0 = serving cold fallback)", - labelNames: ["name"], - registers: [metricsRegister], -}); +// singleton: module-scope registrations double-register under Vite dev HMR +const { loadFailures, lastSuccessfulLoadAt, registryLoaded } = singleton( + "reloadingRegistryMetrics", + () => ({ + loadFailures: new Counter({ + name: "reloading_registry_load_failures_total", + help: "Failed loads of a reloading registry", + labelNames: ["name"], + registers: [metricsRegister], + }), + lastSuccessfulLoadAt: new Gauge({ + name: "reloading_registry_last_successful_load_timestamp_seconds", + help: "Unix time of the last successful registry load (staleness signal)", + labelNames: ["name"], + registers: [metricsRegister], + }), + // 0 until the first successful load, then 1. Starts at 0 (not absent) so a + // never-loaded registry is an alertable series, distinct from "feature off". + registryLoaded: new Gauge({ + name: "reloading_registry_loaded", + help: "1 once the registry has loaded at least once, else 0 (0 = serving cold fallback)", + labelNames: ["name"], + registers: [metricsRegister], + }), + }) +); export type ReloadingRegistry = { isReady: Promise; From 653cb439b741258f4c7824e5d2aae436c477ce2a Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 8 Jul 2026 13:40:30 +0000 Subject: [PATCH 004/103] fix(webapp): export the builder loader from converted API routes createActionApiRoute's loader handles CORS OPTIONS preflight; the destructured exports never surfaced it (pre-existing), do it now. --- apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts | 2 ++ .../routes/api.v1.queues.$queueParam.concurrency.override.ts | 2 ++ .../app/routes/api.v1.queues.$queueParam.concurrency.reset.ts | 2 ++ apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts index d0304035f7b..2943c21cca6 100644 --- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts +++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts @@ -52,3 +52,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 1d3aa69b152..e5e4a926ee6 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -75,3 +75,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts index 78c2729808e..a7aca141932 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts @@ -75,3 +75,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts index 98ecd290c4b..00f94853f43 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -46,3 +46,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; From 9aadad25e3c449ccba083ee644f1eb0f4e6685c5 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 12 Jul 2026 04:58:22 +0000 Subject: [PATCH 005/103] fix(webapp): close vite dev server on shutdown --- apps/webapp/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 2b96d24ead3..93c0cab917b 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -272,6 +272,8 @@ async function startServer() { console.log("Express server closed gracefully."); } }); + // Dev-only: release Vite's file watchers and HMR websocket + viteDevServer?.close(); } process.on("SIGTERM", closeServer); From 140073d4c1afbe226596e716e16331aeace9bd8e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 9 Jul 2026 15:16:43 +0000 Subject: [PATCH 006/103] feat(webapp): add light theme behind feature flag - semantic token overrides for light, trigger.light editor/code palettes - Interface theme dropdown on /account, gated by hasThemeSwitcher flag - unify light surfaces: buttons, inputs, radios, checkboxes, tabs, clipboard fields - fix checkbox :read-only override and useThemeColor hydration mismatch - resolve Firefox panel animation check from request UA --- .server-changes/light-theme.md | 6 + apps/webapp/app/components/AskAI.tsx | 2 +- .../app/components/admin/debugTooltip.tsx | 2 +- .../billing/AnimatedOrgBannerBar.tsx | 4 +- .../billing/BillingLimitRecoveryPanel.tsx | 2 +- .../app/components/billing/UsageBar.tsx | 2 +- .../app/components/code/ChartConfigPanel.tsx | 4 +- .../components/primitives/AppliedFilter.tsx | 4 +- .../app/components/primitives/Avatar.tsx | 2 +- .../app/components/primitives/Buttons.tsx | 22 ++- .../app/components/primitives/Checkbox.tsx | 14 +- .../app/components/primitives/ClientTabs.tsx | 4 +- .../components/primitives/ClipboardField.tsx | 8 +- .../app/components/primitives/Input.tsx | 8 +- .../app/components/primitives/RadioButton.tsx | 14 +- .../app/components/primitives/Resizable.tsx | 32 +++- .../primitives/SegmentedControl.tsx | 5 +- .../app/components/primitives/Select.tsx | 2 +- .../app/components/primitives/Switch.tsx | 7 +- .../app/components/runs/v3/RunFilters.tsx | 4 +- apps/webapp/app/components/runs/v3/RunTag.tsx | 25 ++- .../app/components/runs/v3/SpanEvents.tsx | 2 +- .../runs/v3/agent/AgentMessageView.tsx | 4 +- .../components/runs/v3/ai/AIChatMessages.tsx | 2 +- .../components/runs/v3/ai/AIModelSummary.tsx | 2 +- .../components/runs/v3/ai/SpanMetricRow.tsx | 2 +- apps/webapp/app/hooks/useThemeColor.ts | 17 +- apps/webapp/app/root.tsx | 21 ++- .../route.tsx | 41 +++-- .../route.tsx | 4 +- .../route.tsx | 4 +- .../route.tsx | 2 +- .../app/routes/account._index/route.tsx | 95 +++++++++- .../route.tsx | 2 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 2 +- ...ces.orgs.$organizationSlug.select-plan.tsx | 2 +- .../services/dashboardPreferences.server.ts | 27 +++ apps/webapp/app/tailwind.css | 166 ++++++++++++++++++ apps/webapp/app/v3/featureFlags.ts | 3 + 39 files changed, 468 insertions(+), 103 deletions(-) create mode 100644 .server-changes/light-theme.md diff --git a/.server-changes/light-theme.md b/.server-changes/light-theme.md new file mode 100644 index 00000000000..ceaae7914f8 --- /dev/null +++ b/.server-changes/light-theme.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Adds an opt-in light theme behind a feature flag. When enabled, an Interface theme setting appears on the account page (dark stays the default). Code editors and syntax highlighting use the trigger.light palette. diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index d62ffa5b33d..e7008e114e8 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -389,7 +389,7 @@ function ChatMessages({
Error generating answer: - + {error} If the problem persists after retrying, please contact support. diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index b4ccb74f88d..0b0954ab361 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -40,7 +40,7 @@ function Content({ children }: { children: React.ReactNode }) { const user = useUser(); return ( -
+
User ID diff --git a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx index b0f11b7eba3..58dcc27b3bc 100644 --- a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx +++ b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx @@ -44,7 +44,9 @@ export function AnimatedOrgBannerBar({ /> {children} diff --git a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx index 53b3c1695de..1dfcdd73707 100644 --- a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx +++ b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx @@ -113,7 +113,7 @@ export function BillingLimitRecoveryPanel({
Action required - + {isGrace ? ( <> Your organization has reached its billing limit. Processing is paused and new runs diff --git a/apps/webapp/app/components/billing/UsageBar.tsx b/apps/webapp/app/components/billing/UsageBar.tsx index 49346492287..fcb6377757c 100644 --- a/apps/webapp/app/components/billing/UsageBar.tsx +++ b/apps/webapp/app/components/billing/UsageBar.tsx @@ -67,7 +67,7 @@ export function UsageBar({ current, billingLimit, tierLimit, isPaying }: UsageBa animate={{ width: tierRunLimitPercentage + "%" }} transition={{ duration: 1.5, type: "spring" }} style={{ width: `${tierRunLimitPercentage}%` }} - className="absolute h-3 rounded-l-sm bg-green-900/50" + className="absolute h-3 rounded-l-sm bg-green-900/20" > @@ -587,7 +587,7 @@ function SeriesColorPicker({ onColorChange(c); setOpen(false); }} - className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30" + className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-text-bright/30" style={{ backgroundColor: c }} title={c} > diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index c8fbc4d3ac3..6390a5826d6 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -4,11 +4,11 @@ import { cn } from "~/utils/cn"; const variants = { "secondary/small": { - box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control group-hover:border-border-brighter text-text-bright border border-border-bright", + box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 shadow-xs group-hover:bg-background-raised text-text-bright border border-border-bright/50", clear: "size-6 text-text-bright hover:text-text-bright transition-colors", }, "tertiary/small": { - box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control", + box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-background-raised", clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", }, "minimal/medium": { diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 9fc6832fcc4..e4de8a8b478 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -138,7 +138,7 @@ function AvatarLetters({ return ( {/* This is the square container */} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f3743826..67d511e3948 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -49,18 +49,17 @@ type Size = keyof typeof sizes; const theme = { primary: { - textColor: - "text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-dimmed", + textColor: "text-white transition group-disabled/button:text-white/60", button: "bg-indigo-600 border border-indigo-500 group-hover/button:bg-indigo-500 group-hover/button:border-indigo-400 group-disabled/button:opacity-50 group-disabled/button:bg-indigo-600 group-disabled/button:border-indigo-500 group-disabled/button:pointer-events-none", shortcut: - "border-text-bright/40 text-text-bright group-hover/button:border-text-bright/60 group-hover/button:text-text-bright", - icon: "text-text-bright", + "border-white/40 text-white group-hover/button:border-white/60 group-hover/button:text-white", + icon: "text-white", }, secondary: { textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80", button: - "bg-secondary group-hover/button:bg-surface-control group-hover/button:border-border-brighter border border-border-bright group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", shortcut: "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-text-bright", @@ -82,17 +81,16 @@ const theme = { icon: "text-text-dimmed", }, danger: { - textColor: - "text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-bright/80", + textColor: "text-white transition group-disabled/button:text-white/80", button: "bg-error group-hover/button:bg-rose-500 disabled:opacity-50 group-disabled/button:bg-error group-disabled/button:pointer-events-none", - shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60", - icon: "text-text-bright", + shortcut: "border-white text-white group-hover/button:border-white/60", + icon: "text-white", }, docs: { - textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80", + textColor: "text-callout-docs-text/70 transition group-disabled/button:text-text-dimmed/80", button: - "bg-background-raised border border-border-bright/50 shadow-sm group-hover/button:bg-secondary group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", shortcut: "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-blue-500", @@ -167,7 +165,7 @@ const variant = { }; const allVariants = { - $all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-hidden group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom", + $all: "cursor-pointer font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-hidden group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom", variant: variant, }; diff --git a/apps/webapp/app/components/primitives/Checkbox.tsx b/apps/webapp/app/components/primitives/Checkbox.tsx index 75850b304d9..da63e2ed993 100644 --- a/apps/webapp/app/components/primitives/Checkbox.tsx +++ b/apps/webapp/app/components/primitives/Checkbox.tsx @@ -139,7 +139,11 @@ export const CheckboxWithLabel = React.forwardRef( : props.readOnly ? "cursor-default" : "cursor-pointer", - "read-only:border-border-bright disabled:border-border-bright disabled:opacity-50 rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! read-only:bg-background-raised! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500 disabled:bg-background-raised!" + // NB: don't use the `read-only:` variant here — checkboxes always + // match :read-only, so it would override the checked style. + "rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500", + props.disabled && "opacity-50", + (props.disabled || props.readOnly) && + "bg-background-raised! checked:bg-background-raised! checked:group-hover:bg-background-raised! group-hover:bg-background-raised!", + className )} {...props} ref={ref} diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index 533a09c84e6..c564c425164 100644 --- a/apps/webapp/app/components/primitives/ClientTabs.tsx +++ b/apps/webapp/app/components/primitives/ClientTabs.tsx @@ -122,10 +122,10 @@ const ClientTabsTrigger = React.forwardRef< ) : ( -
+
) ) : null} diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index 0304e5bca6b..07d82f5efe3 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -5,7 +5,7 @@ import { CopyButton } from "./CopyButton"; const variants = { "primary/small": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", buttonVariant: "primary" as const, @@ -14,7 +14,7 @@ const variants = { }, "secondary/small": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", buttonVariant: "tertiary" as const, @@ -33,7 +33,7 @@ const variants = { }, "primary/medium": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", buttonVariant: "primary" as const, @@ -42,7 +42,7 @@ const variants = { }, "secondary/medium": { container: - "flex items-center text-text-dimmed font-mono rounded bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", buttonVariant: "tertiary" as const, diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 4f20c0c11d6..5c3235a66c9 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -12,21 +12,21 @@ const inputBase = const variants = { large: { container: - "px-1 w-full h-10 rounded-[3px] border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary", + "px-1 w-full h-10 rounded-[3px] border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-2 text-sm", iconSize: "size-4 ml-1", accessory: "pr-1", }, medium: { container: - "px-1 h-8 w-full rounded border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary", + "px-1 h-8 w-full rounded border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-1.5 rounded text-sm", iconSize: "size-4 ml-0.5", accessory: "pr-1", }, small: { container: - "px-1 h-6 w-full rounded border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary", + "px-1 h-6 w-full rounded border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-1 rounded text-xs", iconSize: "size-3 ml-0.5", accessory: "pr-0.5", @@ -39,7 +39,7 @@ const variants = { }, "secondary-small": { container: - "px-1 h-6 w-full rounded border border-border-bright hover:border-border-brighter bg-grid-dimmed hover:bg-secondary", + "px-1 h-6 w-full rounded border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-1 rounded text-xs", iconSize: "size-3 ml-0.5", accessory: "pr-0.5", diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index c52389f788f..bf7c736d728 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -22,7 +22,7 @@ const variants = { }, "button/small": { button: - "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-border-bright border-border-bright hover:border-border-bright transition data-disabled:opacity-70 data-disabled:hover:bg-transparent hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4", + "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 data-disabled:hover:bg-secondary hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", label: "text-sm text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-0", @@ -30,7 +30,7 @@ const variants = { }, button: { button: - "w-fit py-2 pl-3 pr-4 rounded border border-border-bright hover:bg-background-dimmed hover:border-border-brightest transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", + "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", label: "text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-1", @@ -38,7 +38,7 @@ const variants = { }, description: { button: - "w-full p-2.5 hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4 transition data-disabled:opacity-70 hover:border-border-bright border-border-bright hover:data-[state=checked]:border-border-bright border rounded-md", + "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", label: "text-text-bright font-semibold -mt-0.5 text-left text-sm", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -46,7 +46,7 @@ const variants = { }, icon: { button: - "w-full p-2.5 pb-4 hover:bg-background-dimmed transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed border-border-bright border rounded-sm", + "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed", label: "text-text-bright font-semibold -mt-1 text-left", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -81,9 +81,7 @@ export function RadioButtonCircle({ outerCircleClassName )} > - +
)}
@@ -136,7 +134,7 @@ export const RadioGroupItem = React.forwardRef< )} > - +
diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index 59550332c26..0bd4f8e86d9 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -2,6 +2,8 @@ import React, { useRef } from "react"; import { PanelGroup, Panel, PanelResizer } from "@window-splitter/react"; +import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; +import type { loader as rootLoader } from "~/root"; import { cn } from "~/utils/cn"; const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( @@ -14,7 +16,25 @@ const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps ); -const ResizablePanel = Panel; +// react-window-splitter drives the collapse animation through @react-spring/rafz, +// which has timing/interaction issues with Firefox that produce visual glitches +// (alternating frames, panels stuck at min, panelHasSpace invariant violations), +// so the animation is dropped on Firefox. The browser check must agree between +// SSR and hydration (a client-only `navigator` check made the panel tree differ +// and shifted useIds), so it comes from the root loader's user-agent sniff. +const ResizablePanel = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(function ResizablePanel({ collapseAnimation, ...props }, ref) { + const rootData = useTypedMatchesData({ id: "root" }); + return ( + + ); +}); const ResizableHandle = ({ withHandle = true, @@ -69,14 +89,8 @@ const ResizableHandle = ({ ); -// react-window-splitter drives the collapse animation through @react-spring/rafz, -// which has timing/interaction issues with Firefox that produce visual glitches -// (alternating frames, panels stuck at min, panelHasSpace invariant violations). -// Disable the animation on Firefox; it works correctly in Chromium and Safari. -const RESIZABLE_PANEL_ANIMATION = - typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent) - ? undefined - : ({ easing: "ease-in-out", duration: 300 } as const); +// Firefox filtering happens inside ResizablePanel (see above). +const RESIZABLE_PANEL_ANIMATION = { easing: "ease-in-out", duration: 300 } as const; const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200"; diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 2f749215c29..a3c365b930b 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -24,10 +24,11 @@ const theme = { selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary", }, secondary: { - base: "bg-background-raised/50", + base: "bg-transparent dark:bg-background-raised/50", active: "text-text-bright", inactive: "text-text-dimmed transition hover:text-text-bright", - selected: "absolute inset-0 rounded bg-background-raised border border-border-bright", + selected: + "absolute inset-0 rounded bg-white border border-[#e2e4e9] dark:bg-background-raised dark:border-border-bright", }, }; diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 66b291b7524..54b160edfa2 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -30,7 +30,7 @@ const style = { }, secondary: { button: - "bg-secondary focus-custom border border-border-bright hover:text-text-bright hover:border-border-brighter text-text-bright hover:bg-surface-control", + "bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised", }, }; diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 96943e9bebb..27ed530492c 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -36,7 +36,7 @@ const variations = { "secondary/small": { container: cn( small.container, - "border border-border-bright hover:border-border-brighter bg-secondary hover:bg-surface-control" + "border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised" ), root: cn( small.root, @@ -96,10 +96,7 @@ export const Switch = React.forwardRef
); diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index f5980d9e597..8d812a413fd 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -673,7 +673,7 @@ function PermanentStatusFilter() { className="pl-1" /> ) : ( -
+
@@ -852,7 +852,7 @@ function PermanentTasksFilter({ possibleTasks }: Pick ) : ( -
+
{filterIcon("tasks")} Tasks
diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index 3d6c6751ce3..1defae01674 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -1,5 +1,4 @@ import { useCallback, useMemo, useState } from "react"; -import tagLeftPath from "./tag-left.svg"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; @@ -7,6 +6,26 @@ import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react"; type Tag = string | { key: string; value: string }; +function TagNotch() { + return ( + + ); +} + export function RunTag({ tag, to, @@ -26,7 +45,7 @@ export function RunTag({ if (typeof tagResult === "string") { return ( <> - + {tag} @@ -35,7 +54,7 @@ export function RunTag({ } else { return ( <> - + {tagResult.key} diff --git a/apps/webapp/app/components/runs/v3/SpanEvents.tsx b/apps/webapp/app/components/runs/v3/SpanEvents.tsx index c79a147b86f..069246c89b7 100644 --- a/apps/webapp/app/components/runs/v3/SpanEvents.tsx +++ b/apps/webapp/app/components/runs/v3/SpanEvents.tsx @@ -85,7 +85,7 @@ export function SpanEventError({ /> {enhancedException.message && ( -
+          
             {enhancedException.message}
           
diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index a82d8b9d0c6..3941283be40 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -136,7 +136,9 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { return (
-
{p.text ?? ""}
+
+ {p.text ?? ""} +
); diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index a5115897f97..cc02a280e0d 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -483,7 +483,7 @@ function SubAgentContent({ parts }: { parts: any[] }) { if (partType === "reasoning" && part.text) { return (
-
+
{part.text}
diff --git a/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx b/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx index 11ef7aa8a52..775efd7c308 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx @@ -128,7 +128,7 @@ function MetricRow({ bold?: boolean; }) { return ( -
+
{label} +
{label} {value}
diff --git a/apps/webapp/app/hooks/useThemeColor.ts b/apps/webapp/app/hooks/useThemeColor.ts index d78fd39fa79..61e1473e140 100644 --- a/apps/webapp/app/hooks/useThemeColor.ts +++ b/apps/webapp/app/hooks/useThemeColor.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; /** * Normalize any CSS color (hex, oklch, hsl, ...) to rgb()/rgba() by rendering @@ -17,16 +17,17 @@ function toRgb(color: string): string { } /** - * Resolve a theme CSS variable to a concrete, animatable color once on mount. + * Resolve a theme CSS variable to a concrete, animatable color on mount. * framer-motion can't interpolate `var()` strings or oklch values, so animated - * colors must be resolved and normalized first. The fallback is used during - * SSR and should match the default dark theme (see tailwind.css). + * colors must be resolved and normalized first. Resolution happens in an + * effect so server and hydration renders both use the fallback — resolving + * during render caused hydration style mismatches. */ export function useThemeColor(variable: `--${string}`, fallback: string): string { - const [color] = useState(() => { - if (typeof document === "undefined") return fallback; + const [color, setColor] = useState(fallback); + useEffect(() => { const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); - return value ? toRgb(value) : fallback; - }); + if (value) setColor(toRgb(value)); + }, [variable]); return color; } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 66df9ddff53..0270e7be4ee 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -18,6 +18,7 @@ import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; import { getUser } from "./services/session.server"; +import { flag } from "~/v3/featureFlags.server"; import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; import { appEnvTitleTag } from "./utils"; @@ -63,6 +64,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); + // Theme switching is feature-flagged; while off, everyone stays on dark + // even if a preference was saved earlier. + const showThemeSwitcher = user + ? await flag({ key: "hasThemeSwitcher", defaultValue: false }) + : false; const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); @@ -80,6 +86,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { triggerCliTag: env.TRIGGER_CLI_TAG, kapa, timezone, + showThemeSwitcher, + // Consumed by ResizablePanel: the browser check must match between SSR + // and hydration, so it is derived from the request user-agent. + isFirefox: /firefox/i.test(request.headers.get("user-agent") ?? ""), }, { headers } ); @@ -121,12 +131,19 @@ export function ErrorBoundary() { } export default function App() { - const { posthogProjectKey, posthogUiHost, kapa: _kapa } = useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + kapa: _kapa, + user, + showThemeSwitcher, + } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); + const theme = (showThemeSwitcher ? user?.dashboardPreferences.theme : "dark") ?? "dark"; return ( <> - + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index e27b11b7545..37d7c58bc5e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -11,7 +11,6 @@ import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; import { AnimatedNumber } from "~/components/primitives/AnimatedNumber"; -import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; @@ -445,21 +444,24 @@ function RateLimitTypeBadge({ switch (rateLimitType) { case "tokenBucket": return ( - - Token bucket - + ); case "fixedWindow": return ( - - Fixed window - + ); case "slidingWindow": return ( - - Sliding window - + ); default: return null; @@ -858,19 +860,32 @@ function getUsageColorClass( } } +function RateLimitTypePill({ className, label }: { className: string; label: string }) { + return ( + + {label} + + ); +} + function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { const variants: Record = { default: { label: "Default", - className: "bg-indigo-500/20 text-indigo-400", + className: "bg-indigo-500/20 text-indigo-600 dark:text-indigo-400", }, plan: { label: "Plan", - className: "bg-purple-500/20 text-purple-400", + className: "bg-purple-500/20 text-purple-600 dark:text-purple-400", }, override: { label: "Override", - className: "bg-amber-500/20 text-amber-400", + className: "bg-amber-500/20 text-amber-700 dark:text-amber-400", }, }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 34d9a613d9d..aafb685f7d2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -1482,7 +1482,7 @@ function TimelineView({ {(ms) => ( setFaviconError(false)} /> ) : ( - + )} { return json && typeof json === "object" ? (json as Record) : {}; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index e58afdb65df..1e92cb08eeb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -668,7 +668,7 @@ function DomainList({ domains }: { domains: ReadonlyArray }) {
{d.domain} {d.state === "failed" && d.verificationFailedReason && ( - + Reason: {d.verificationFailedReason} )} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index b4b92c8a133..45e14a007b8 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -1,7 +1,14 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; -import { Form, type MetaFunction, useActionData } from "@remix-run/react"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { MoonIcon, SunIcon } from "@heroicons/react/20/solid"; +import { + Form, + type MetaFunction, + useActionData, + useFetcher, + useLoaderData, +} from "@remix-run/react"; +import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; @@ -13,6 +20,7 @@ import { } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; @@ -26,7 +34,9 @@ import { prisma } from "~/db.server"; import { useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; -import { requireUserId } from "~/services/session.server"; +import { updateThemePreference } from "~/services/dashboardPreferences.server"; +import { flag } from "~/v3/featureFlags.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { accountPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { @@ -75,11 +85,28 @@ function createSchema( }); } +export async function loader({ request }: LoaderFunctionArgs) { + await requireUserId(request); + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + return json({ showThemeSwitcher }); +} + export const action: ActionFunction = async ({ request }) => { const userId = await requireUserId(request); const formData = await request.formData(); + if (formData.get("action") === "update-theme") { + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + if (!showThemeSwitcher) { + return json({ error: "Not available" }, { status: 404 }); + } + const user = await requireUser(request); + const theme = formData.get("theme") === "light" ? "light" : "dark"; + await updateThemePreference({ user, theme }); + return json({ success: true }); + } + const formSchema = createSchema({ isEmailUnique: async (email) => { const existingUser = await prisma.user.findFirst({ @@ -126,7 +153,14 @@ export const action: ActionFunction = async ({ request }) => { export default function Page() { const user = useUser(); + const { showThemeSwitcher } = useLoaderData(); const lastSubmission = useActionData(); + const themeFetcher = useFetcher(); + const pendingTheme = themeFetcher.formData?.get("theme"); + const theme = + typeof pendingTheme === "string" + ? (pendingTheme as "dark" | "light") + : (user.dashboardPreferences.theme ?? "dark"); const [form, { name, email, marketingEmails }] = useForm({ id: "account", @@ -195,6 +229,61 @@ export default function Page() { /> + {showThemeSwitcher && ( + <> +
+ Appearance +
+
+ + + value={theme} + setValue={(value) => + themeFetcher.submit( + { action: "update-theme", theme: value }, + { method: "post" } + ) + } + variant="secondary/small" + dropdownIcon + items={["dark", "light"]} + text={(value) => ( + + {value === "dark" ? ( + + + + ) : ( + + )} + {value === "dark" ? "Dark" : "Light"} + + )} + className="w-32" + > + {(items) => + items.map((item) => ( + + + + ) : ( + + ) + } + > + {item === "dark" ? "Dark" : "Light"} + + )) + } + +
+ + )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 4c8f77a582a..6db0b6ff566 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1226,7 +1226,7 @@ function RunError({ error }: { error: TaskRunError }) { {name} {enhancedError.message && ( -
+              
                 {enhancedError.message}
               
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 7a5f61a48dc..b05d22439da 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1125,7 +1125,7 @@ function VercelSettingsPanel({

Failed to load Vercel settings

-

+

There was an error loading the Vercel integration settings. Please refresh the page to try again.

diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 7414960f47a..67e68e1360a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -759,7 +759,7 @@ export function TierEnterprise() { +
Contact us
} diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 7af007dc381..fafd1438427 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -25,6 +25,7 @@ export type { SideMenuSectionId }; const DashboardPreferences = z.object({ version: z.literal("1"), + theme: z.enum(["dark", "light"]).optional(), currentProjectId: z.string().optional(), projects: z.record( z.string(), @@ -101,6 +102,32 @@ export async function updateCurrentProjectEnvironmentId({ }); } +export async function updateThemePreference({ + user, + theme, +}: { + user: UserFromSession; + theme: "dark" | "light"; +}) { + if (user.isImpersonating) { + return; + } + + if (user.dashboardPreferences.theme === theme) { + return; + } + + const updatedPreferences: DashboardPreferences = { + ...user.dashboardPreferences, + theme, + }; + + return prisma.user.update({ + where: { id: user.id }, + data: { dashboardPreferences: updatedPreferences }, + }); +} + export async function clearCurrentProject({ user }: { user: UserFromSession }) { if (user.isImpersonating) { return; diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 5a6429fce80..8bf9c3e1305 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -158,6 +158,7 @@ --color-surface-control: var(--color-charcoal-600); --color-surface-control-hover: var(--color-charcoal-550); --color-surface-control-active: var(--color-charcoal-500); + --color-input-bg: var(--color-charcoal-750); /* Borders, from subtlest to most visible */ --color-grid-dimmed: var(--color-charcoal-750); @@ -599,3 +600,168 @@ @apply leading-relaxed; } } + +/* + Light theme. Overrides the themable variables only; raw palettes stay put. + Code/editor values come from the trigger.light VS Code theme. +*/ +[data-theme="light"] { + --color-secondary: #ffffff; + --color-input-bg: #ffffff; + + /* shadcn vars consumed by charts/streamdown (tooltip cursor fill etc.) */ + --background: #ffffff; + --foreground: #1a1b1f; + --muted: #eceef1; + --muted-foreground: #5f6570; + --border: #e2e4e9; + --sidebar: #f6f7f8; + --primary-foreground: #ffffff; + + /* Text */ + --color-primary: var(--color-apple-600); + --color-tertiary: #eef0f3; + --color-text-link: var(--color-lavender-600); + --color-text-faint: var(--color-charcoal-400); + --color-text-dimmed: var(--color-charcoal-500); + --color-text-bright: var(--color-charcoal-800); + + /* Surfaces */ + --color-background-deep: #f1f2f4; + --color-background-dimmed: #fbfbfc; + --color-background-bright: #ffffff; + --color-background-hover: #f2f3f5; + --color-background-raised: #e9eaee; + --color-surface-control: #dcdee3; + --color-surface-control-hover: #cfd2d9; + --color-surface-control-active: #b8bcc6; + + /* Borders */ + --color-grid-dimmed: #eceef1; + --color-grid-bright: #e2e4e9; + --color-border-bright: #d2d5db; + --color-border-brighter: #b9bdc7; + --color-border-brightest: #9ba1ad; + + /* Status - darker steps for contrast on white */ + --color-success: var(--color-mint-600); + --color-warning: var(--color-amber-600); + --color-dev: var(--color-pink-600); + --color-prod: var(--color-mint-600); + --color-staging: var(--color-orange-600); + --color-preview: var(--color-yellow-700); + + /* Neutral run statuses: soft light grays for sparkbars and charts */ + --color-run-pending: #ccd0d6; + --color-run-delayed: #d3d6db; + --color-run-waiting-to-resume: #c5c9d0; + --color-run-canceled: #d8dbdf; + --color-run-expired: #dee0e4; + + /* Icons that fail contrast on white */ + --color-schedules: var(--color-yellow-600); + --color-previewBranches: var(--color-yellow-600); + --color-customDashboards: var(--color-charcoal-500); + + /* Callouts - deep tints instead of the dark theme's pastels */ + --color-callout-warning-text: var(--color-yellow-800); + --color-callout-error-text: var(--color-rose-700); + --color-callout-success-text: var(--color-green-800); + --color-callout-docs-text: var(--color-blue-800); + --color-callout-pending-bg: var(--color-blue-100); + --color-callout-pending-text: var(--color-blue-800); + --color-callout-pricing-bg: var(--color-indigo-100); + --color-callout-pricing-text: var(--color-indigo-800); + + /* Code syntax - trigger.light */ + --color-code-background: #ffffff; + --color-code-foreground: #333333; + --color-code-line-number: #a8a8ad; + --color-code-plain: #2e2e4b; + --color-code-muted: #333333; + --color-code-comment: #767a81; + --color-code-keyword: #b114d3; + --color-code-storage: #b114d3; + --color-code-type: #b114d3; + --color-code-function: #6532f5; + --color-code-variable: #404040; + --color-code-constant: #1e1e1e; + --color-code-language: #b114d3; + --color-code-object-key: #222222; + --color-code-string: #262626; + --color-code-template-punctuation: #0879e2; + --color-code-number: #262626; + --color-code-builtin: #3080e0; + --color-code-attribute: #222222; + --color-code-escape: #222222; + --color-code-regexp: #dc3545; + --color-code-regexp-constant: #5f6570; + --color-code-invalid: #dc3545; + --color-code-deleted: #dc3545; + --color-code-jsx-text: #2e2e4b; + + /* CodeMirror - trigger.light */ + --color-editor-background: #ffffff; + --color-editor-foreground: #2e2e4b; + --color-editor-keyword: #b114d3; + --color-editor-name: #197c3c; + --color-editor-function: #6532f5; + --color-editor-constant: #3080e0; + --color-editor-type: #2980b9; + --color-editor-operator: #333333; + --color-editor-comment: #767a81; + --color-editor-heading: #2c3e50; + --color-editor-string: #262626; + --color-editor-invalid: #dc3545; + --color-editor-panel-background: #f4f4f6; + --color-editor-highlight-background: #f8f8fa; + --color-editor-tooltip-background: #ffffff; + --color-editor-selection: #d9dce3; + --color-editor-cursor: #2e2e4b; + --color-editor-search-match: #0879e226; + --color-editor-search-match-outline: #0879e2; + --color-editor-search-match-selected: #0879e240; + --color-editor-selection-match: #e8e8ed; + --color-editor-matching-bracket: rgba(240, 241, 244, 0.9); + --color-editor-matching-bracket-outline: rgba(160, 166, 180, 0.5); + --color-editor-fold-placeholder: #555555; + --color-editor-scrollbar-track-active: #e8e9ec; + --color-editor-scrollbar-thumb: #c9ccd4; + --color-editor-scrollbar-thumb-active: #aeb3be; +} + +/* Streamdown's muted surface has no semantic token (charcoal-775); theme it here */ +[data-theme="light"] .streamdown-container { + --muted: #eceef1; +} + +/* The timeline label shadow is a dark-theme legibility aid; drop it on light */ +[data-theme="light"] .text-shadow-custom { + text-shadow: none; +} + +/* Neutral timeline points: invert to a light dot with a gray ring */ +[data-theme="light"] .timeline-point.bg-surface-control-active { + border-color: var(--color-surface-control-active); + background-color: var(--color-background-bright); +} + +/* Run timeline bars: no fade gradient on light */ +[data-theme="light"] .timeline-span { + background-image: none; +} +/* On saturated bars the duration label keeps the dark-theme treatment, + but only when the bar is wide enough to contain the label — on narrow + bars the sticky label overflows onto the page background, where the + default dark-on-light text is correct. */ +[data-theme="light"] .timeline-span.bg-success, +[data-theme="light"] .timeline-span.bg-error { + container-type: inline-size; +} +@container (min-width: 3.5rem) { + [data-theme="light"] .timeline-span.bg-success .text-shadow-custom, + [data-theme="light"] .timeline-span.bg-error .text-shadow-custom { + color: #ffffff; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); + } +} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 637830aef06..9a6270fa6d3 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -10,6 +10,7 @@ export const FEATURE_FLAG = { hasComputeAccess: "hasComputeAccess", hasPrivateConnections: "hasPrivateConnections", hasSso: "hasSso", + hasThemeSwitcher: "hasThemeSwitcher", mollifierEnabled: "mollifierEnabled", workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled", realtimeBackend: "realtimeBackend", @@ -33,6 +34,8 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(), [FEATURE_FLAG.hasSso]: z.coerce.boolean(), + // Gates the Interface theme setting in /account. Off by default. + [FEATURE_FLAG.hasThemeSwitcher]: z.coerce.boolean(), [FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(), [FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(), // Which backend serves the realtime run feed. Controllable From 942c2b1731a2212640bd18ce017ac1bfdc5772c0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 12 Jul 2026 04:55:47 +0000 Subject: [PATCH 007/103] fix(webapp): address light theme review feedback - re-resolve theme colors when data-theme changes - narrow jsonb_set write for theme preference - opaque amber for reasoning text in light mode --- .../runs/v3/agent/AgentMessageView.tsx | 2 +- .../components/runs/v3/ai/AIChatMessages.tsx | 2 +- apps/webapp/app/hooks/useThemeColor.ts | 19 ++++++++++++++---- .../services/dashboardPreferences.server.ts | 20 ++++++++++--------- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 3941283be40..e696f202559 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -136,7 +136,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { return (
-
+
{p.text ?? ""}
diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index cc02a280e0d..ae46cbe867c 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -483,7 +483,7 @@ function SubAgentContent({ parts }: { parts: any[] }) { if (partType === "reasoning" && part.text) { return (
-
+
{part.text}
diff --git a/apps/webapp/app/hooks/useThemeColor.ts b/apps/webapp/app/hooks/useThemeColor.ts index 61e1473e140..f088d16b525 100644 --- a/apps/webapp/app/hooks/useThemeColor.ts +++ b/apps/webapp/app/hooks/useThemeColor.ts @@ -17,17 +17,28 @@ function toRgb(color: string): string { } /** - * Resolve a theme CSS variable to a concrete, animatable color on mount. + * Resolve a theme CSS variable to a concrete, animatable color. * framer-motion can't interpolate `var()` strings or oklch values, so animated * colors must be resolved and normalized first. Resolution happens in an * effect so server and hydration renders both use the fallback — resolving - * during render caused hydration style mismatches. + * during render caused hydration style mismatches. Long-lived components + * (e.g. the side menu) outlive theme switches, so re-resolve whenever + * `data-theme` flips on . */ export function useThemeColor(variable: `--${string}`, fallback: string): string { const [color, setColor] = useState(fallback); useEffect(() => { - const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); - if (value) setColor(toRgb(value)); + const resolve = () => { + const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); + if (value) setColor(toRgb(value)); + }; + resolve(); + const observer = new MutationObserver(resolve); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); }, [variable]); return color; } diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index fafd1438427..7bec9ee7e6d 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -117,15 +117,17 @@ export async function updateThemePreference({ return; } - const updatedPreferences: DashboardPreferences = { - ...user.dashboardPreferences, - theme, - }; - - return prisma.user.update({ - where: { id: user.id }, - data: { dashboardPreferences: updatedPreferences }, - }); + // Narrow jsonb_set write: a full-blob update from the session snapshot can + // race with other preference writes and drop unrelated fields. + return prisma.$executeRaw` + UPDATE "User" + SET "dashboardPreferences" = jsonb_set( + COALESCE("dashboardPreferences", '{}'::jsonb), + '{theme}', + to_jsonb(${theme}::text) + ) + WHERE id = ${user.id} + `; } export async function clearCurrentProject({ user }: { user: UserFromSession }) { From 030b9a5c444e05826ef7804844a3a07474bdbec6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 13 Jul 2026 09:50:31 +0000 Subject: [PATCH 008/103] fix(webapp): address light theme review round 2 - keep dark-theme switch/radio thumb colors, white only in light - pick avatar letter color by background luminance - seed required preference fields when jsonb column is null --- apps/webapp/app/components/primitives/Avatar.tsx | 16 ++++++++++++---- .../app/components/primitives/RadioButton.tsx | 9 +++++++-- apps/webapp/app/components/primitives/Switch.tsx | 5 ++++- .../app/services/dashboardPreferences.server.ts | 5 ++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index e4de8a8b478..c514a7f1790 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -117,6 +117,16 @@ function styleFromSize(size: number) { }; } +// Bright tiles (Yellow, Orange) need dark letters for contrast; the rest read +// best with white. +function letterColorForBackground(hex: string): string { + const match = /^#?([0-9a-f]{6})$/i.exec(hex); + if (!match) return "#fff"; + const n = parseInt(match[1], 16); + const luminance = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); + return luminance > 140 ? "#272A2E" : "#fff"; +} + function AvatarLetters({ avatar, size, @@ -132,15 +142,13 @@ function AvatarLetters({ const style = { backgroundColor: avatar.hex, + color: letterColorForBackground(avatar.hex), }; const scaleFactor = includePadding ? 0.8 : 1; return ( - + {/* This is the square container */} - +
)}
@@ -134,7 +139,7 @@ export const RadioGroupItem = React.forwardRef< )} > - +
diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 27ed530492c..e07187176a8 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -96,7 +96,10 @@ export const Switch = React.forwardRef
); diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 7bec9ee7e6d..46bb393224b 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -122,7 +122,10 @@ export async function updateThemePreference({ return prisma.$executeRaw` UPDATE "User" SET "dashboardPreferences" = jsonb_set( - COALESCE("dashboardPreferences", '{}'::jsonb), + COALESCE( + "dashboardPreferences", + '{"version":"1","projects":{}}'::jsonb + ), '{theme}', to_jsonb(${theme}::text) ) From 68a919cb4581de5a81ed82eb25bd682654ba90e1 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 14 Jul 2026 13:03:07 +0000 Subject: [PATCH 009/103] fix(webapp): light theme polish for query, apikeys, alerts, regions, billing - white query editor and apikeys accordion surfaces - white Cancel and Contact us buttons - drop the doubled hover ring on the regions suggest button - route the section-header menu through the shared ellipsis primitive --- .../components/navigation/SideMenuHeader.tsx | 14 ++++++++------ .../app/components/primitives/Popover.tsx | 19 ++++++++++++++++--- .../app/components/query/QueryEditor.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 1 + ...ces.orgs.$organizationSlug.select-plan.tsx | 2 +- apps/webapp/app/tailwind.css | 7 +++++++ 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 80143e9e0d9..75f6224cd15 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -1,8 +1,7 @@ import { useNavigation } from "@remix-run/react"; import { useEffect, useState } from "react"; import { motion } from "framer-motion"; -import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover"; -import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; +import { Popover, PopoverContent, PopoverEllipseTrigger } from "../primitives/Popover"; export function SideMenuHeader({ title, @@ -30,7 +29,7 @@ export function SideMenuHeader({ return ( {children !== undefined ? ( setHeaderMenuOpen(open)} open={isHeaderMenuOpen}> - - - + ) { const styles = popoverVerticalEllipseVariants[variant]; + const Icon = orientation === "horizontal" ? EllipsisHorizontalIcon : EllipsisVerticalIcon; return ( - + ); } +// Back-compat alias: the trigger now supports both orientations. +const PopoverVerticalEllipseTrigger = PopoverEllipseTrigger; + export { Popover, PopoverArrowTrigger, @@ -314,6 +326,7 @@ export { PopoverCustomTrigger, PopoverMenuItem, PopoverSectionHeader, + PopoverEllipseTrigger, PopoverSideMenuTrigger, PopoverTrigger, PopoverVerticalEllipseTrigger, diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 37747a77dae..d66de588067 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -241,7 +241,7 @@ const QueryEditorForm = forwardRef< ); return ( -
+
Cancel diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 839b66f70b3..029efaa4d54 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -190,7 +190,7 @@ export default function Page() { )} - + How to set these environment variables
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 783b60cb1ca..136cc06658c 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 @@ -282,6 +282,7 @@ export default function Page() { Suggest a new region +
Contact us
} diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 8bf9c3e1305..6ef03402328 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -765,3 +765,10 @@ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); } } + +/* The table row-hover menu wraps the always-visible "Suggest a region" button + in a ring container; on light that ring doubles up with the button's own + border, so drop it here. */ +[data-theme="light"] .suggest-region-cell > div > div { + box-shadow: none; +} From 000ccd16bdef3a47e70c27c572dad23e4476d4d0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 12 Jul 2026 04:55:48 +0000 Subject: [PATCH 010/103] chore(webapp): default theme switcher on for preview (drop before merge) --- apps/webapp/app/root.tsx | 2 +- apps/webapp/app/routes/account._index/route.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 0270e7be4ee..e9d5c7cbafa 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -67,7 +67,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // Theme switching is feature-flagged; while off, everyone stays on dark // even if a preference was saved earlier. const showThemeSwitcher = user - ? await flag({ key: "hasThemeSwitcher", defaultValue: false }) + ? await flag({ key: "hasThemeSwitcher", defaultValue: true }) : false; const headers = new Headers(); diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 45e14a007b8..03bb6ec9147 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -87,7 +87,7 @@ function createSchema( export async function loader({ request }: LoaderFunctionArgs) { await requireUserId(request); - const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: true }); return json({ showThemeSwitcher }); } @@ -97,7 +97,7 @@ export const action: ActionFunction = async ({ request }) => { const formData = await request.formData(); if (formData.get("action") === "update-theme") { - const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: true }); if (!showThemeSwitcher) { return json({ error: "Not available" }, { status: 404 }); } From bb34a2e224cbb4ca8715f5ca7bc79258afb754dd Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 22 Jul 2026 08:20:43 +0100 Subject: [PATCH 011/103] fix(webapp): scope development branches to each member (#4323) ## Summary Allow each organization member to use the same development branch name without colliding with another member's environment. Fixes #4320. ## Fix Development branches now use the existing member-scoped project, slug, and organization-member key for upserts. Preview branches retain their project-wide shortcode behavior. New development branches receive distinct shortcodes while keeping their readable, member-scoped slugs. Existing branches continue to resolve through the member-scoped key, so this requires no migration or backfill. --- .server-changes/dev-branch-member-scoping.md | 6 ++ .../app/services/upsertBranch.server.ts | 39 +++++++++--- apps/webapp/test/devBranchServices.test.ts | 62 ++++++++++++++++++- 3 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 .server-changes/dev-branch-member-scoping.md diff --git a/.server-changes/dev-branch-member-scoping.md b/.server-changes/dev-branch-member-scoping.md new file mode 100644 index 00000000000..d836c6c8357 --- /dev/null +++ b/.server-changes/dev-branch-member-scoping.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Allow different organization members to use the same development branch name without sharing or colliding with each other's branch environments. diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index fa18101b476..3364f3f560a 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -1,4 +1,8 @@ -import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { + type Prisma, + type PrismaClient, + type PrismaClientOrTransaction, +} from "@trigger.dev/database"; import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; @@ -141,7 +145,31 @@ export class UpsertBranchService { const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`; const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); - const shortcode = branchSlug; + const isDevelopmentBranch = parentEnvironment.type === "DEVELOPMENT"; + // Dev branches can share a slug across members, so their identity is scoped by + // orgMemberId. Shortcodes remain project-scoped and use the parent's unique + // shortcode to distinguish otherwise identical branch slugs. + const shortcode = isDevelopmentBranch + ? `${branchSlug}-${parentEnvironment.shortcode}` + : branchSlug; + let branchWhere: Prisma.RuntimeEnvironmentWhereUniqueInput; + if (isDevelopmentBranch) { + invariant(parentEnvironment.orgMemberId, "Development branches require an org member"); + branchWhere = { + projectId_slug_orgMemberId: { + projectId: parentEnvironment.project.id, + slug: branchSlug, + orgMemberId: parentEnvironment.orgMemberId, + }, + }; + } else { + branchWhere = { + projectId_shortcode: { + projectId: parentEnvironment.project.id, + shortcode, + }, + }; + } const billingPause = await getInitialEnvPauseStateForBillingLimit( parentEnvironment.organization.id, parentEnvironment.type @@ -149,12 +177,7 @@ export class UpsertBranchService { const now = new Date(); const branch = await this.#prismaClient.runtimeEnvironment.upsert({ - where: { - projectId_shortcode: { - projectId: parentEnvironment.project.id, - shortcode: shortcode, - }, - }, + where: branchWhere, create: { slug: branchSlug, apiKey, diff --git a/apps/webapp/test/devBranchServices.test.ts b/apps/webapp/test/devBranchServices.test.ts index 06c06f7c7e8..55cf4fcbbc3 100644 --- a/apps/webapp/test/devBranchServices.test.ts +++ b/apps/webapp/test/devBranchServices.test.ts @@ -4,7 +4,11 @@ import slug from "slug"; import { describe, expect, vi } from "vitest"; import { ArchiveBranchService } from "~/services/archiveBranch.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; -import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; +import { + createTestOrgProjectWithMember, + createTestUser, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -77,6 +81,62 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => { } ); + postgresTest("allows different members to use the same branch name", async ({ prisma }) => { + const { + organization, + project, + user: firstUser, + orgMember: firstMember, + } = await createTestOrgProjectWithMember(prisma); + const secondUser = await createTestUser(prisma); + const secondMember = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: secondUser.id, + role: "MEMBER", + }, + }); + + const [firstRoot, secondRoot] = await Promise.all([ + createDevRoot(prisma, project.id, organization.id, firstMember.id), + createDevRoot(prisma, project.id, organization.id, secondMember.id), + ]); + + const options = { + projectId: project.id, + env: "development" as const, + branchName: "shared-name", + }; + const [firstResult, secondResult] = await Promise.all([ + new UpsertBranchService(prisma).call( + { type: "userMembership", userId: firstUser.id }, + options + ), + new UpsertBranchService(prisma).call( + { type: "userMembership", userId: secondUser.id }, + options + ), + ]); + + expect(firstResult.success && secondResult.success).toBe(true); + if (!firstResult.success || !secondResult.success) return; + expect(firstResult.branch.id).not.toBe(secondResult.branch.id); + expect(firstResult.branch.slug).toBe(secondResult.branch.slug); + expect(firstResult.branch.shortcode).toBe(`dev-shared-name-${firstRoot.shortcode}`); + expect(secondResult.branch.shortcode).toBe(`dev-shared-name-${secondRoot.shortcode}`); + expect(firstResult.branch.orgMemberId).toBe(firstMember.id); + expect(secondResult.branch.orgMemberId).toBe(secondMember.id); + + const firstRetry = await new UpsertBranchService(prisma).call( + { type: "userMembership", userId: firstUser.id }, + options + ); + expect(firstRetry.success).toBe(true); + if (!firstRetry.success) return; + expect(firstRetry.alreadyExisted).toBe(true); + expect(firstRetry.branch.id).toBe(firstResult.branch.id); + }); + postgresTest( "rejects an invalid branch name without touching the database", async ({ prisma }) => { From a9815f745c44998701e79b1b9c1e6ad15b38e180 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 22 Jul 2026 08:47:58 +0100 Subject: [PATCH 012/103] fix(cli): stop dev runs crashing when a rebuild removes an in-use build dir (#4276) --- .changeset/fix-dev-build-dir-race.md | 5 +++ packages/cli-v3/src/dev/devSupervisor.ts | 35 ++++++++++++++++++- packages/cli-v3/src/dev/devWatchdog.ts | 18 ++++++++++ .../src/entryPoints/dev-run-controller.ts | 11 ++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-dev-build-dir-race.md diff --git a/.changeset/fix-dev-build-dir-race.md b/.changeset/fix-dev-build-dir-race.md new file mode 100644 index 00000000000..4033f0fa7ec --- /dev/null +++ b/.changeset/fix-dev-build-dir-race.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Fixes intermittent `trigger dev` run crashes where a run could fail at boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a rebuild had cleaned up the build directory the run was launched against. Dev runs now retry cleanly instead of hard-crashing when their build directory is missing, the dev watchdog no longer removes the build tree of a still-running session, and a run assigned to a worker version that was superseded by a rebuild now fails fast with a clear message instead of silently hanging until it times out. diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 7335f947a5a..6a0d1888afd 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -1,7 +1,9 @@ import { tryCatch } from "@trigger.dev/core/utils"; +import { TaskRunErrorCodes } from "@trigger.dev/core/v3"; import type { BuildManifest, CreateBackgroundWorkerRequestBody, + DequeuedMessage, DevConfigResponseBody, WorkerManifest, } from "@trigger.dev/core/v3"; @@ -224,6 +226,7 @@ class DevSupervisor implements WorkerRuntime { this.activeRunsPath = join(triggerDir, `active-runs${suffix}.json`); this.watchdogPidPath = join(triggerDir, `watchdog${suffix}.pid`); + const lockFilePath = join(triggerDir, safeBranch ? `dev.${safeBranch}.lock` : "dev.lock"); // Write empty active-runs file this.#updateActiveRunsFile(); @@ -249,6 +252,7 @@ class DevSupervisor implements WorkerRuntime { WATCHDOG_ACTIVE_RUNS: this.activeRunsPath, WATCHDOG_PID_FILE: this.watchdogPidPath, WATCHDOG_TMP_DIR: getTmpRoot(this.options.config.workingDir, this.options.branch), + WATCHDOG_LOCK_FILE: lockFilePath, }, }); @@ -478,7 +482,9 @@ class DevSupervisor implements WorkerRuntime { } ); - //todo call the API to crash the run with a good message + this.#failRunWithMissingWorker(message).catch((error) => { + logger.debug("[DevSupervisor] Failed to fail run with missing worker", { error }); + }); continue; } @@ -588,6 +594,33 @@ class DevSupervisor implements WorkerRuntime { } } + async #failRunWithMissingWorker(message: DequeuedMessage) { + const start = await this.options.client.dev.startRunAttempt( + message.run.friendlyId, + message.snapshot.friendlyId + ); + + if (!start.success) { + return; + } + + const { run, snapshot, execution } = start.data; + + await this.options.client.dev.completeRunAttempt(run.friendlyId, snapshot.friendlyId, { + completion: { + id: execution.run.id, + ok: false, + retry: undefined, + error: { + type: "INTERNAL_ERROR", + code: TaskRunErrorCodes.COULD_NOT_FIND_EXECUTOR, + message: + "This run was assigned to a background worker version that is no longer available in the dev session because it was superseded by a rebuild. Trigger the run again to use the current version.", + }, + }, + }); + } + async #startPresenceConnection() { try { const eventSource = this.options.client.dev.presenceConnection(); diff --git a/packages/cli-v3/src/dev/devWatchdog.ts b/packages/cli-v3/src/dev/devWatchdog.ts index 15dfae929f3..906da7d1803 100644 --- a/packages/cli-v3/src/dev/devWatchdog.ts +++ b/packages/cli-v3/src/dev/devWatchdog.ts @@ -34,6 +34,7 @@ const apiKey = process.env.WATCHDOG_API_KEY!; const activeRunsPath = process.env.WATCHDOG_ACTIVE_RUNS!; const pidFilePath = process.env.WATCHDOG_PID_FILE!; const tmpDir = process.env.WATCHDOG_TMP_DIR; +const lockFilePath = process.env.WATCHDOG_LOCK_FILE; if (!parentPid || !apiUrl || !apiKey || !activeRunsPath || !pidFilePath) { process.exit(1); @@ -77,8 +78,25 @@ function cleanup() { } catch {} } +function tmpDirOwnedByLiveSession(): boolean { + if (!lockFilePath) return false; + try { + const pid = Number(readFileSync(lockFilePath, "utf8").trim()); + if (!pid || pid === parentPid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } catch { + return false; + } +} + function cleanupTmpDir() { if (!tmpDir) return; + if (tmpDirOwnedByLiveSession()) return; try { rmSync(tmpDir, { recursive: true, force: true }); } catch { diff --git a/packages/cli-v3/src/entryPoints/dev-run-controller.ts b/packages/cli-v3/src/entryPoints/dev-run-controller.ts index 9ef8f2007c9..7b679b6490f 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-controller.ts @@ -14,6 +14,7 @@ import { isOOMRunError, SuspendedProcessError, } from "@trigger.dev/core/v3"; +import { UnexpectedExitError } from "@trigger.dev/core/v3/errors"; import { type WorkloadRunAttemptStartResponseBody } from "@trigger.dev/core/v3/workers"; import { setTimeout as sleep } from "timers/promises"; import type { CliApiClient } from "../apiClient.js"; @@ -22,6 +23,7 @@ import { assertExhaustive } from "../utilities/assertExhaustive.js"; import { logger } from "../utilities/logger.js"; import { sanitizeEnvVars } from "../utilities/sanitizeEnvVars.js"; import { join } from "node:path"; +import { existsSync } from "node:fs"; import type { BackgroundWorker } from "../dev/backgroundWorker.js"; import { eventBus } from "../utilities/eventBus.js"; import type { TaskRunProcessPool } from "../dev/taskRunProcessPool.js"; @@ -600,6 +602,15 @@ export class DevRunController { throw new Error(`No worker manifest for Dev ${run.friendlyId}`); } + const workerEntryPoint = this.opts.worker.manifest.workerEntryPoint; + if (!existsSync(workerEntryPoint)) { + throw new UnexpectedExitError( + 1, + null, + `Dev worker build directory was removed before the run could start, likely cleaned up by a concurrent rebuild. Missing worker entry: ${workerEntryPoint}` + ); + } + this.snapshotPoller.start(); logger.debug("getProcess", { From 7a141886637efdd32013c6800dc91372bee1aded Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 22 Jul 2026 10:29:30 +0100 Subject: [PATCH 013/103] fix(webapp): limit account email address length (#4330) ## Summary Limits user account email addresses to 254 characters in profile settings and onboarding. Oversized values are rejected before the uniqueness lookup, and the form fields enforce the same limit in the browser. ## Fix Both email update flows use a shared bounded email schema. Basic validation completes before the uniqueness lookup runs. --- .server-changes/limit-account-email-length.md | 6 ++++++ apps/webapp/app/routes/account._index/route.tsx | 11 ++++++----- apps/webapp/app/routes/confirm-basic-details.tsx | 14 ++++++++------ apps/webapp/app/utils/emailValidation.ts | 8 ++++++++ 4 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 .server-changes/limit-account-email-length.md create mode 100644 apps/webapp/app/utils/emailValidation.ts diff --git a/.server-changes/limit-account-email-length.md b/.server-changes/limit-account-email-length.md new file mode 100644 index 00000000000..2259f007e06 --- /dev/null +++ b/.server-changes/limit-account-email-length.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Limit account settings email input to 254 characters. diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 99ef9b87bb3..341141e638e 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -22,6 +22,7 @@ import { useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { requireUserId } from "~/services/session.server"; +import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { accountPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { @@ -42,10 +43,8 @@ function createSchema( .string({ required_error: "You must enter a name" }) .min(2, "Your name must be at least 2 characters long") .max(50), - email: z - .string() - .email() - .superRefine((email, ctx) => { + email: emailSchema.pipe( + z.string().superRefine((email, ctx) => { if (constraints.isEmailUnique === undefined) { //client-side validation skips this ctx.addIssue({ @@ -65,7 +64,8 @@ function createSchema( }); }); } - }), + }) + ), marketingEmails: z.preprocess((value) => value === "on", z.boolean()), }); } @@ -177,6 +177,7 @@ export default function Page() {
diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 9187823a734..4e370913240 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -27,6 +27,7 @@ import { useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { requireUserId } from "~/services/session.server"; +import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { rootPath } from "~/utils/pathBuilder"; import { getVercelInstallParams } from "~/v3/vercel"; @@ -72,10 +73,8 @@ function createSchema( return z .object({ name: z.string().min(3, "Your name must be at least 3 characters").max(50), - email: z - .string() - .email() - .superRefine((email, ctx) => { + email: emailSchema.pipe( + z.string().superRefine((email, ctx) => { if (constraints.isEmailUnique === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -93,8 +92,9 @@ function createSchema( }); }); } - }), - confirmEmail: z.string(), + }) + ), + confirmEmail: emailSchema, referralSource: z.string().optional(), referralSourceOther: z.string().optional(), role: z.string().optional(), @@ -290,6 +290,7 @@ export default function Page() { { setEnteredEmail(e.target.value); @@ -306,6 +307,7 @@ export default function Page() { Date: Wed, 22 Jul 2026 12:49:37 +0100 Subject: [PATCH 014/103] fix(webapp): remove Enterprise badge from SSO & Directory Sync menu item (#4333) ## What The organization side menu previously showed an "Enterprise" badge next to the SSO & Directory Sync item for any org not on the enterprise plan. That badge is now removed so the item renders without it. ## Screenshot (before) CleanShot 2026-07-10 at 08 24 41 --- .../components/navigation/OrganizationSettingsSideMenu.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 17bc4a047bb..8790e479421 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -163,11 +163,6 @@ export function OrganizationSettingsSideMenu({ inactiveIconColor="text-text-dimmed" to={organizationSsoPath(organization)} data-action="sso" - badge={ - currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : ( - Enterprise - ) - } /> )}
From 81eac67069485c80c78b597a2b378537e48efe35 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 22 Jul 2026 12:51:06 +0100 Subject: [PATCH 015/103] fix(webapp): correct docs link on the blank prompts page (#4247) ## Summary The empty-state panel on the Prompts page linked to a docs path that no longer exists, so the "Prompts docs" button returned a 404. It now points to the current prompts documentation at /docs/ai/prompts, matching the link already used in the page header. --- .server-changes/prompts-blank-state-docs-link.md | 6 ++++++ apps/webapp/app/components/BlankStatePanels.tsx | 6 +----- 2 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 .server-changes/prompts-blank-state-docs-link.md diff --git a/.server-changes/prompts-blank-state-docs-link.md b/.server-changes/prompts-blank-state-docs-link.md new file mode 100644 index 00000000000..562b92ac863 --- /dev/null +++ b/.server-changes/prompts-blank-state-docs-link.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix the docs link on the empty Prompts page, which pointed to a page that no longer exists. diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 56a7c0da45d..5d166158e0b 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -748,11 +748,7 @@ export function PromptsNone() { iconClassName="text-aiPrompts" panelClassName="max-w-lg" accessory={ - + Prompts docs } From 11d8a05fa6733ee6ea7181df45cccb074ec645cb Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 22 Jul 2026 13:10:31 +0100 Subject: [PATCH 016/103] fix(webapp): restore admin debug tooltip on Tasks and Runs, make its IDs copyable (#4332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the debug panel on the **Tasks** and **Runs** pages, and makes the data it shows copyable. Admin/impersonation only — no change for regular users, so there's no `.server-changes` CleanShot 2026-07-22 at 12 05 27@2x --- .../app/components/admin/debugTooltip.tsx | 28 ++++++--- .../components/primitives/CopyableText.tsx | 59 +++++++++++-------- .../route.tsx | 2 + .../route.tsx | 5 +- .../route.tsx | 4 +- .../route.tsx | 5 +- .../route.tsx | 17 ++++-- .../route.tsx | 4 +- .../route.tsx | 5 +- .../route.tsx | 4 +- .../route.tsx | 16 +++-- .../route.tsx | 2 + .../route.tsx | 13 ++-- .../route.tsx | 5 +- 14 files changed, 118 insertions(+), 51 deletions(-) diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index b4ccb74f88d..4157f898163 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -1,4 +1,5 @@ import { ShieldCheckIcon } from "@heroicons/react/20/solid"; +import { CopyableText } from "~/components/primitives/CopyableText"; import * as Property from "~/components/primitives/PropertyTable"; import { Tooltip, @@ -25,7 +26,10 @@ export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) - + {/* The copy controls below pass `hideTooltip` so their own tooltips don't fire + Radix's global close and dismiss this panel. `pr-8` leaves room for the + copy button, which is absolutely positioned to the right of each value. */} + {children} @@ -44,23 +48,31 @@ function Content({ children }: { children: React.ReactNode }) { User ID - {user.id} + + + {organization && ( Org ID - {organization.id} + + + )} {project && ( <> Project ID - {project.id} + + + Project ref - {project.externalRef} + + + )} @@ -68,7 +80,9 @@ function Content({ children }: { children: React.ReactNode }) { <> Environment ID - {environment.id} + + + Environment type @@ -81,7 +95,7 @@ function Content({ children }: { children: React.ReactNode }) { )} -
{children}
+ {children &&
{children}
}
); } diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index f8e19402aed..9634ad1ac01 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -11,12 +11,19 @@ export function CopyableText({ className, asChild, variant, + hideTooltip, }: { value: string; copyValue?: string; className?: string; asChild?: boolean; variant?: "icon-right" | "text-below"; + /** + * Hide the "Copy"/"Copied" hint tooltip. Use when this is rendered inside another + * Radix tooltip (e.g. the admin debug panel): the nested tooltip would otherwise + * fire Radix's global "one tooltip open at a time" close and dismiss the parent. + */ + hideTooltip?: boolean; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); @@ -24,6 +31,24 @@ export function CopyableText({ const resolvedVariant = variant ?? "icon-right"; if (resolvedVariant === "icon-right") { + const iconButton = ( + + {copied ? ( + + ) : ( + + )} + + ); + return ( - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - className="font-sans" - disableHoverableContent - asChild={asChild} - /> + {hideTooltip ? ( + iconButton + ) : ( + + )} ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index c16e96b8f51..99c73f33849 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -16,6 +16,7 @@ import { PlusIcon } from "~/assets/icons/PlusIcon"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CodeBlock } from "~/components/code/CodeBlock"; import { InlineCode } from "~/components/code/InlineCode"; import { HasNoTasksDeployed, HasNoTasksDev } from "~/components/BlankStatePanels"; @@ -261,6 +262,7 @@ export default function Page() { } /> + {environment.slug} - {environment.id} + + + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 8c1de33c6f6..909a8bb6381 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -242,7 +242,9 @@ export default function Page() { {branches.map((branch) => ( {branch.branchName} - {branch.id} + + + ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx index 1bc359af598..e9542b5c8f5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -21,6 +21,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import simplur from "simplur"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { @@ -270,7 +271,9 @@ export default function Page() { {environment.type}{" "} {environment.branchName ? ` (${environment.branchName})` : ""} - {environment.id} + + + ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 20dd91d34b4..c4950277288 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -18,6 +18,7 @@ import { GitMetadata } from "~/components/GitMetadata"; import { VercelLink } from "~/components/integrations/VercelLink"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Badge } from "~/components/primitives/Badge"; import { LinkButton } from "~/components/primitives/Buttons"; @@ -283,20 +284,28 @@ export default function Page() { ID - {deployment.id} + + + Project ID - {deployment.projectId} + + + Org ID - {deployment.organizationId} + + + {deployment.imageReference && ( Image - {deployment.imageReference} + + + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx index 9f704a54f9b..dd2679085e4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx @@ -95,7 +95,9 @@ export default function Page() { {branches.map((branch) => ( {branch.branchName} - {branch.id} + + + ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index e27b11b7545..7ace6919a55 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -7,6 +7,7 @@ import { Gauge } from "lucide-react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; @@ -125,7 +126,9 @@ export default function Page() { Organization ID - {data.organizationId} + + + 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 7992f28cc92..588647f22a7 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 @@ -163,7 +163,9 @@ export default function Page() { {regions.map((region) => ( {region.name} - {region.id} + + + ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index dc5e8e591c1..95dbe5f7926 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -489,19 +489,27 @@ export default function Page() { ID - {run.id} + + + Trace ID - {run.traceId} + + + Env ID - {run.environment.id} + + + Org ID - {run.environment.organizationId} + + + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index a69e3e82a97..dfb921e2691 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -11,6 +11,7 @@ import { import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence"; import { InlineCode } from "~/components/code/InlineCode"; import { StepContentContainer } from "~/components/StepContentContainer"; @@ -166,6 +167,7 @@ export default function Page() { )} + ID - {project.id} -
- {project.id} -
+ + +
Org ID - {project.organizationId} + + +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 32920a678ff..3e11636aeec 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -18,6 +18,7 @@ import { z } from "zod"; import { Feedback } from "~/components/Feedback"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Alert, @@ -372,7 +373,9 @@ export default function Page() { Org ID - {organization.id} + + + {members.map((member) => ( From 509a4597bda649a03236d11d49a798ad546b0721 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 22 Jul 2026 13:32:37 +0100 Subject: [PATCH 017/103] fix(cli): redact task run env values from debug log (#4336) --- .changeset/redact-taskrunprocess-env-log.md | 5 +++++ packages/cli-v3/src/executions/taskRunProcess.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/redact-taskrunprocess-env-log.md diff --git a/.changeset/redact-taskrunprocess-env-log.md b/.changeset/redact-taskrunprocess-env-log.md new file mode 100644 index 00000000000..5c94fdec4d9 --- /dev/null +++ b/.changeset/redact-taskrunprocess-env-log.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Avoid logging task run environment variable values at debug level diff --git a/packages/cli-v3/src/executions/taskRunProcess.ts b/packages/cli-v3/src/executions/taskRunProcess.ts index 66b30269ada..03504c412e4 100644 --- a/packages/cli-v3/src/executions/taskRunProcess.ts +++ b/packages/cli-v3/src/executions/taskRunProcess.ts @@ -151,7 +151,7 @@ export class TaskRunProcess { }; logger.debug(`initializing task run process`, { - env: fullEnv, + envKeys: Object.keys(fullEnv), path: workerManifest.workerEntryPoint, cwd, }); From 84add4ad3d6627d16934ea66704cad1382e8478e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:38:07 +0100 Subject: [PATCH 018/103] feat(supervisor): export workload_token_enforcement_mode gauge (#4335) Add a Prometheus gauge `workload_token_enforcement_mode` set to 1 for the active `WORKLOAD_TOKEN_ENFORCEMENT` value (`disabled`/`log`/`enforce`), emitted at startup on the shared registry. The existing mint/verify counters don't distinguish `log` from `enforce` (the verify outcome is recorded before the reject decision), so dashboards can't tell which mode a cluster is running. This gauge makes the active mode queryable at a glance. Supervisor typecheck passes. --- apps/supervisor/src/workloadToken.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/supervisor/src/workloadToken.ts b/apps/supervisor/src/workloadToken.ts index 914411dd735..d28a6150744 100644 --- a/apps/supervisor/src/workloadToken.ts +++ b/apps/supervisor/src/workloadToken.ts @@ -4,7 +4,7 @@ import { type WorkloadDeploymentTokenClaims, type WorkloadDeploymentTokenInput, } from "@trigger.dev/core/v3"; -import { Counter } from "prom-client"; +import { Counter, Gauge } from "prom-client"; import { env } from "./env.js"; import { register } from "./metrics.js"; @@ -37,6 +37,16 @@ const verifyCounter = new Counter({ registers: [register], }); +// Exports the active mode (value 1 for the current WORKLOAD_TOKEN_ENFORCEMENT) so dashboards can show +// disabled/log/enforce at a glance — the counters alone don't distinguish log from enforce. +const enforcementModeGauge = new Gauge({ + name: "workload_token_enforcement_mode", + help: "Active runner-boundary auth mode: value 1 for the label matching WORKLOAD_TOKEN_ENFORCEMENT", + labelNames: ["mode"] as const, + registers: [register], +}); +enforcementModeGauge.set({ mode: env.WORKLOAD_TOKEN_ENFORCEMENT }, 1); + export async function mintDeploymentToken( claims: WorkloadDeploymentTokenInput ): Promise { From 14fa90672b59c5d9d030074cb848ba3521f2c046 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:38:20 +0100 Subject: [PATCH 019/103] chore: ignore .worktrees/ in the repo gitignore (#4334) Add `.worktrees/` to the repo `.gitignore`. The pre-push hook runs `oxfmt --check .` and `oxlint .` over the whole tree, and those tools only read the in-repo ignore files (not a user's global gitignore). Local git-worktree checkouts placed under `.worktrees/` therefore got linted/formatted, failing the hook on unrelated code. Ignoring the directory keeps both tools out of worktree checkouts. No source changes. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c1fe3103332..1ee5643d57d 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ apps/**/public/build ailogger-output.log # per-package vitest timing capture (transient; merged into root test-timings.json) .vitest-timing.json + +# local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them +.worktrees/ From 55a3bf28587cef86f9b63ff9b03642cce6d01bc9 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 22 Jul 2026 14:10:27 +0100 Subject: [PATCH 020/103] feat(core,cli): add node-24 and node-26 runtimes, deprecate experimental aliases (#4337) ## Summary Adds `node-24` and `node-26` as first-class `runtime` options in `trigger.config.ts`. Previously these Node versions were only reachable via the `experimental-node-24` / `experimental-node-26` names. Those experimental names are now **deprecated aliases**: they still resolve to `node-24` / `node-26` for backwards compatibility, but loading a config that uses them prints a deprecation warning pointing at the new name. ```ts export default defineConfig({ runtime: "node-24", project: "", }); ``` ## Details - `ConfigRuntime` (the public config schema) now accepts `node-24` and `node-26` directly; the internal `BuildRuntime` already supported them, so base images and the deploy path are unchanged. - `resolveBuildRuntime` passes the new names straight through and keeps mapping the experimental aliases to their replacements. - Renamed the runtime helper from `isExperimentalConfigRuntime` to `isDeprecatedConfigRuntime` and added `deprecatedRuntimeReplacement` so the CLI can name the replacement in its warning. - Docs snippet updated to list the new versions and flag the deprecated names. --- .changeset/node-24-26-runtimes.md | 15 +++++++++++ docs/snippets/node-versions.mdx | 12 +++------ packages/cli-v3/src/config.test.ts | 6 ++++- packages/cli-v3/src/config.ts | 9 ++++--- packages/core/src/v3/build/runtime.test.ts | 31 +++++++++++++--------- packages/core/src/v3/build/runtime.ts | 22 ++++++++++++--- packages/core/src/v3/schemas/build.ts | 3 +++ 7 files changed, 70 insertions(+), 28 deletions(-) create mode 100644 .changeset/node-24-26-runtimes.md diff --git a/.changeset/node-24-26-runtimes.md b/.changeset/node-24-26-runtimes.md new file mode 100644 index 00000000000..4a2f7ebc9a8 --- /dev/null +++ b/.changeset/node-24-26-runtimes.md @@ -0,0 +1,15 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + runtime: "node-24", + project: "", +}); +``` diff --git a/docs/snippets/node-versions.mdx b/docs/snippets/node-versions.mdx index 76bc386f810..d11f50f3fc9 100644 --- a/docs/snippets/node-versions.mdx +++ b/docs/snippets/node-versions.mdx @@ -1,13 +1,9 @@ Trigger.dev runs your tasks on specific Node.js versions: -### v3 - -- Node.js `21.7.3` - -### v4 - - Node.js `21.7.3` (default) - Node.js `22.16.0` (`node-22`) +- Node.js `24.18.0` (`node-24`) +- Node.js `26.4.0` (`node-26`) - Bun `1.3.3` (`bun`) You can change the runtime by setting the `runtime` field in your `trigger.config.ts` file. @@ -16,8 +12,8 @@ You can change the runtime by setting the `runtime` field in your `trigger.confi import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ - // "node", "node-22" or "bun" - runtime: "node-22", + // "node", "node-22", "node-24", "node-26" or "bun" + runtime: "node-24", project: "", }); ``` diff --git a/packages/cli-v3/src/config.test.ts b/packages/cli-v3/src/config.test.ts index 910b2362420..cafee4b9a3d 100644 --- a/packages/cli-v3/src/config.test.ts +++ b/packages/cli-v3/src/config.test.ts @@ -33,6 +33,10 @@ async function createProject(runtime?: string) { describe("loadConfig runtime", () => { it.each([ + ["node", "node"], + ["node-22", "node-22"], + ["node-24", "node-24"], + ["node-26", "node-26"], ["experimental-node-24", "node-24"], ["experimental-node-26", "node-26"], ] as const)("normalizes %s before returning the resolved config", async (runtime, expected) => { @@ -47,7 +51,7 @@ describe("loadConfig runtime", () => { await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: "node" }); }); - it.each(["node-24", "node-26", "node-23"])( + it.each(["node-23"])( "rejects unsupported public runtime %s while loading config", async (runtime) => { const cwd = await createProject(runtime); diff --git a/packages/cli-v3/src/config.ts b/packages/cli-v3/src/config.ts index 8b162fbc409..2e7ffdee509 100644 --- a/packages/cli-v3/src/config.ts +++ b/packages/cli-v3/src/config.ts @@ -8,7 +8,8 @@ import type { import type { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { DEFAULT_RUNTIME, - isExperimentalConfigRuntime, + deprecatedRuntimeReplacement, + isDeprecatedConfigRuntime, resolveBuildRuntime, } from "@trigger.dev/core/v3/build"; import * as c12 from "c12"; @@ -184,9 +185,11 @@ async function resolveConfig( const configuredRuntime = overrides?.runtime ?? config.runtime ?? defaultRuntime; const runtime = resolveBuildRuntime(configuredRuntime); - if (warn && isExperimentalConfigRuntime(configuredRuntime)) { + if (warn && isDeprecatedConfigRuntime(configuredRuntime)) { prettyWarning( - `The "${configuredRuntime}" runtime is experimental and may change before general availability.` + `The "${configuredRuntime}" runtime is deprecated. Use "${deprecatedRuntimeReplacement( + configuredRuntime + )}" instead.` ); } diff --git a/packages/core/src/v3/build/runtime.test.ts b/packages/core/src/v3/build/runtime.test.ts index 2c166f2b01f..0073c15b22a 100644 --- a/packages/core/src/v3/build/runtime.test.ts +++ b/packages/core/src/v3/build/runtime.test.ts @@ -1,31 +1,38 @@ import { describe, expect, it } from "vitest"; import { BuildManifest, BuildRuntime, ConfigRuntime, WorkerManifest } from "../schemas/build.js"; -import { isExperimentalConfigRuntime, resolveBuildRuntime } from "./runtime.js"; +import { isDeprecatedConfigRuntime, resolveBuildRuntime } from "./runtime.js"; describe("runtime configuration", () => { - it.each(["node", "node-22", "experimental-node-24", "experimental-node-26", "bun"] as const)( - "accepts %s as a public config runtime", - (runtime) => { - expect(ConfigRuntime.parse(runtime)).toBe(runtime); - } - ); + it.each([ + "node", + "node-22", + "node-24", + "node-26", + "experimental-node-24", + "experimental-node-26", + "bun", + ] as const)("accepts %s as a public config runtime", (runtime) => { + expect(ConfigRuntime.parse(runtime)).toBe(runtime); + }); it.each([ ["experimental-node-24", "node-24"], ["experimental-node-26", "node-26"], ["node", "node"], ["node-22", "node-22"], + ["node-24", "node-24"], + ["node-26", "node-26"], ["bun", "bun"], ] as const)("normalizes %s to %s", (runtime, expected) => { expect(resolveBuildRuntime(runtime)).toBe(expected); }); it.each(["node-24", "node-26"] as const)( - "keeps internal runtime %s out of the public config schema", + "accepts internal runtime %s in both public and internal schemas", (runtime) => { - expect(ConfigRuntime.safeParse(runtime).success).toBe(false); + expect(ConfigRuntime.safeParse(runtime).success).toBe(true); expect(BuildRuntime.safeParse(runtime).success).toBe(true); - expect(() => resolveBuildRuntime(runtime)).toThrowError(/Unsupported runtime/); + expect(resolveBuildRuntime(runtime)).toBe(runtime); } ); @@ -36,12 +43,12 @@ describe("runtime configuration", () => { }); it.each(["experimental-node-24", "experimental-node-26"] as const)( - "keeps %s out of internal runtime schemas", + "treats %s as a deprecated alias kept out of internal runtime schemas", (runtime) => { expect(BuildRuntime.safeParse(runtime).success).toBe(false); expect(BuildManifest.shape.runtime.safeParse(runtime).success).toBe(false); expect(WorkerManifest.shape.runtime.safeParse(runtime).success).toBe(false); - expect(isExperimentalConfigRuntime(runtime)).toBe(true); + expect(isDeprecatedConfigRuntime(runtime)).toBe(true); } ); }); diff --git a/packages/core/src/v3/build/runtime.ts b/packages/core/src/v3/build/runtime.ts index 10fcc962b78..aa4617c9df5 100644 --- a/packages/core/src/v3/build/runtime.ts +++ b/packages/core/src/v3/build/runtime.ts @@ -6,14 +6,28 @@ import { homedir } from "node:os"; export const DEFAULT_RUNTIME = "node" satisfies BuildRuntime; -export type ExperimentalConfigRuntime = "experimental-node-24" | "experimental-node-26"; +export type DeprecatedConfigRuntime = "experimental-node-24" | "experimental-node-26"; -export function isExperimentalConfigRuntime( - runtime: unknown -): runtime is ExperimentalConfigRuntime { +export function isDeprecatedConfigRuntime(runtime: unknown): runtime is DeprecatedConfigRuntime { return runtime === "experimental-node-24" || runtime === "experimental-node-26"; } +/** Maps a deprecated runtime alias to the runtime that should be used instead. */ +export function deprecatedRuntimeReplacement(runtime: DeprecatedConfigRuntime): BuildRuntime { + switch (runtime) { + case "experimental-node-24": + return "node-24"; + case "experimental-node-26": + return "node-26"; + } +} + +/** @deprecated Renamed to {@link DeprecatedConfigRuntime}. */ +export type ExperimentalConfigRuntime = DeprecatedConfigRuntime; + +/** @deprecated Renamed to {@link isDeprecatedConfigRuntime}. */ +export const isExperimentalConfigRuntime = isDeprecatedConfigRuntime; + export function resolveBuildRuntime(runtime: unknown): BuildRuntime { const parsedRuntime = ConfigRuntime.safeParse(runtime); diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index 1ab5b5b8169..52cae0b2385 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -16,6 +16,9 @@ export type BuildTarget = z.infer; export const ConfigRuntime = z.enum([ "node", "node-22", + "node-24", + "node-26", + // Deprecated aliases, kept for backwards compatibility. Use "node-24"/"node-26" instead. "experimental-node-24", "experimental-node-26", "bun", From b3b1441df9348872b3e965dfa87a50e7261b981b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 22 Jul 2026 15:54:18 +0200 Subject: [PATCH 021/103] fix(webapp): guard workload auth gate metric against dev HMR re-registration (#4339) Wraps the workload_auth_gate_total Counter in the singleton helper (same pattern as reloadingRegistry.server.ts) so a dev hot reload doesn't crash with "A metric with the name workload_auth_gate_total has already been registered". No production behavior change. --- .../worker/workerGroupTokenService.server.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts index 23d20e108fb..ba4741a5ecc 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts @@ -62,12 +62,17 @@ if (workloadCreatedAtGateEnabled && !workloadTokenCutoff) { type WorkloadGateAction = "start" | "complete" | "continue" | "snapshots_since"; -const workloadAuthGateCounter = new Counter({ - name: "workload_auth_gate_total", - help: "Deployment token authorization outcomes on worker actions", - labelNames: ["outcome", "action"] as const, - registers: [metricsRegister], -}); +// singleton: module-scope registration double-registers under dev HMR +const workloadAuthGateCounter = singleton( + "workloadAuthGateCounter", + () => + new Counter({ + name: "workload_auth_gate_total", + help: "Deployment token authorization outcomes on worker actions", + labelNames: ["outcome", "action"] as const, + registers: [metricsRegister], + }) +); function createAuthenticatedWorkerInstanceCache() { return createCache({ From aafc3335239782041c3458924e41c1f76f53925d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:51:29 +0100 Subject: [PATCH 022/103] chore: release v4.5.7 (#4319) ## Summary 5 improvements, 5 bug fixes. ## Improvements - Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. ([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337)) ```ts import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ runtime: "node-24", project: "", }); ``` - Avoid logging task run environment variable values at debug level ([#4336](https://github.com/triggerdotdev/trigger.dev/pull/4336)) - Custom chat agent loops get two ergonomic wins for owning the turn loop. ([#4304](https://github.com/triggerdotdev/trigger.dev/pull/4304)) `chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client. ```ts const { lastEventId, sessionInEventId } = await chat.writeTurnComplete(); await db.chats.update(chatId, { lastEventId, sessionInEventId }); ``` `chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result: ```ts const { message, status, error } = await chat.pipeAndCapture(result, { signal, }); if (message) conversation.addResponse(message); if (status === "error") logger.error("turn failed", { error }); ``` Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result. - Suppress a build-time warning that could appear in Vite-based projects when the optional `@ai-sdk/otel` package is not installed. ([#4188](https://github.com/triggerdotdev/trigger.dev/pull/4188)) ## Bug fixes - Fixes intermittent `trigger dev` run crashes where a run could fail at boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a rebuild had cleaned up the build directory the run was launched against. Dev runs now retry cleanly instead of hard-crashing when their build directory is missing, the dev watchdog no longer removes the build tree of a still-running session, and a run assigned to a worker version that was superseded by a rebuild now fails fast with a clear message instead of silently hanging until it times out. ([#4276](https://github.com/triggerdotdev/trigger.dev/pull/4276)) ## Server changes These changes affect the self-hosted Docker image and Trigger.dev Cloud: - Refreshed the side menu: separate organization and account menus, a new project switcher, and the menu is now resizable by dragging its edge. The account Profile page has also been redesigned. ([#4066](https://github.com/triggerdotdev/trigger.dev/pull/4066)) - Allow different organization members to use the same development branch name without sharing or colliding with each other's branch environments. ([#4323](https://github.com/triggerdotdev/trigger.dev/pull/4323)) - Limit account settings email input to 254 characters. ([#4330](https://github.com/triggerdotdev/trigger.dev/pull/4330)) - Prevent duplicate Staging and Preview environments when account setup requests overlap ([#4261](https://github.com/triggerdotdev/trigger.dev/pull/4261)) - Fix the docs link on the empty Prompts page, which pointed to a page that no longer exists. ([#4247](https://github.com/triggerdotdev/trigger.dev/pull/4247))
Raw changeset output # Releases ## @trigger.dev/build@4.5.7 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.7` ## trigger.dev@4.5.7 ### Patch Changes - Fixes intermittent `trigger dev` run crashes where a run could fail at boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a rebuild had cleaned up the build directory the run was launched against. Dev runs now retry cleanly instead of hard-crashing when their build directory is missing, the dev watchdog no longer removes the build tree of a still-running session, and a run assigned to a worker version that was superseded by a rebuild now fails fast with a clear message instead of silently hanging until it times out. ([#4276](https://github.com/triggerdotdev/trigger.dev/pull/4276)) - Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. ([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337)) ```ts import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ runtime: "node-24", project: "", }); ``` - Avoid logging task run environment variable values at debug level ([#4336](https://github.com/triggerdotdev/trigger.dev/pull/4336)) - Updated dependencies: - `@trigger.dev/core@4.5.7` - `@trigger.dev/build@4.5.7` - `@trigger.dev/schema-to-json@4.5.7` ## @trigger.dev/core@4.5.7 ### Patch Changes - Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. ([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337)) ```ts import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ runtime: "node-24", project: "", }); ``` ## @trigger.dev/python@4.5.7 ### Patch Changes - Updated dependencies: - `@trigger.dev/sdk@4.5.7` - `@trigger.dev/core@4.5.7` - `@trigger.dev/build@4.5.7` ## @trigger.dev/react-hooks@4.5.7 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.7` ## @trigger.dev/redis-worker@4.5.7 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.7` ## @trigger.dev/rsc@4.5.7 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.7` ## @trigger.dev/schema-to-json@4.5.7 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.7` ## @trigger.dev/sdk@4.5.7 ### Patch Changes - Custom chat agent loops get two ergonomic wins for owning the turn loop. ([#4304](https://github.com/triggerdotdev/trigger.dev/pull/4304)) `chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client. ```ts const { lastEventId, sessionInEventId } = await chat.writeTurnComplete(); await db.chats.update(chatId, { lastEventId, sessionInEventId }); ``` `chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result: ```ts const { message, status, error } = await chat.pipeAndCapture(result, { signal, }); if (message) conversation.addResponse(message); if (status === "error") logger.error("turn failed", { error }); ``` Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result. - Suppress a build-time warning that could appear in Vite-based projects when the optional `@ai-sdk/otel` package is not installed. ([#4188](https://github.com/triggerdotdev/trigger.dev/pull/4188)) - Updated dependencies: - `@trigger.dev/core@4.5.7`
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/chat-custom-agent-capture.md | 22 -------------- .changeset/fix-dev-build-dir-race.md | 5 ---- .changeset/node-24-26-runtimes.md | 15 ---------- .changeset/redact-taskrunprocess-env-log.md | 5 ---- .changeset/vite-ignore-optional-imports.md | 5 ---- .server-changes/dev-branch-member-scoping.md | 6 ---- .server-changes/limit-account-email-length.md | 6 ---- .../prevent-duplicate-root-environments.md | 6 ---- .../prompts-blank-state-docs-link.md | 6 ---- .../side-menu-project-and-org-menus.md | 6 ---- hosting/k8s/helm/Chart.yaml | 4 +-- packages/build/CHANGELOG.md | 7 +++++ packages/build/package.json | 4 +-- packages/cli-v3/CHANGELOG.md | 22 ++++++++++++++ packages/cli-v3/package.json | 8 ++--- packages/core/CHANGELOG.md | 15 ++++++++++ packages/core/package.json | 2 +- packages/python/CHANGELOG.md | 9 ++++++ packages/python/package.json | 12 ++++---- packages/react-hooks/CHANGELOG.md | 7 +++++ packages/react-hooks/package.json | 4 +-- packages/redis-worker/CHANGELOG.md | 7 +++++ packages/redis-worker/package.json | 4 +-- packages/rsc/CHANGELOG.md | 7 +++++ packages/rsc/package.json | 6 ++-- packages/schema-to-json/CHANGELOG.md | 7 +++++ packages/schema-to-json/package.json | 2 +- packages/trigger-sdk/CHANGELOG.md | 29 +++++++++++++++++++ packages/trigger-sdk/package.json | 4 +-- pnpm-lock.yaml | 24 +++++++-------- 30 files changed, 147 insertions(+), 119 deletions(-) delete mode 100644 .changeset/chat-custom-agent-capture.md delete mode 100644 .changeset/fix-dev-build-dir-race.md delete mode 100644 .changeset/node-24-26-runtimes.md delete mode 100644 .changeset/redact-taskrunprocess-env-log.md delete mode 100644 .changeset/vite-ignore-optional-imports.md delete mode 100644 .server-changes/dev-branch-member-scoping.md delete mode 100644 .server-changes/limit-account-email-length.md delete mode 100644 .server-changes/prevent-duplicate-root-environments.md delete mode 100644 .server-changes/prompts-blank-state-docs-link.md delete mode 100644 .server-changes/side-menu-project-and-org-menus.md diff --git a/.changeset/chat-custom-agent-capture.md b/.changeset/chat-custom-agent-capture.md deleted file mode 100644 index 8729cda8c94..00000000000 --- a/.changeset/chat-custom-agent-capture.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Custom chat agent loops get two ergonomic wins for owning the turn loop. - -`chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client. - -```ts -const { lastEventId, sessionInEventId } = await chat.writeTurnComplete(); -await db.chats.update(chatId, { lastEventId, sessionInEventId }); -``` - -`chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result: - -```ts -const { message, status, error } = await chat.pipeAndCapture(result, { signal }); -if (message) conversation.addResponse(message); -if (status === "error") logger.error("turn failed", { error }); -``` - -Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result. diff --git a/.changeset/fix-dev-build-dir-race.md b/.changeset/fix-dev-build-dir-race.md deleted file mode 100644 index 4033f0fa7ec..00000000000 --- a/.changeset/fix-dev-build-dir-race.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fixes intermittent `trigger dev` run crashes where a run could fail at boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a rebuild had cleaned up the build directory the run was launched against. Dev runs now retry cleanly instead of hard-crashing when their build directory is missing, the dev watchdog no longer removes the build tree of a still-running session, and a run assigned to a worker version that was superseded by a rebuild now fails fast with a clear message instead of silently hanging until it times out. diff --git a/.changeset/node-24-26-runtimes.md b/.changeset/node-24-26-runtimes.md deleted file mode 100644 index 4a2f7ebc9a8..00000000000 --- a/.changeset/node-24-26-runtimes.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@trigger.dev/core": patch -"trigger.dev": patch ---- - -Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. - -```ts -import { defineConfig } from "@trigger.dev/sdk"; - -export default defineConfig({ - runtime: "node-24", - project: "", -}); -``` diff --git a/.changeset/redact-taskrunprocess-env-log.md b/.changeset/redact-taskrunprocess-env-log.md deleted file mode 100644 index 5c94fdec4d9..00000000000 --- a/.changeset/redact-taskrunprocess-env-log.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Avoid logging task run environment variable values at debug level diff --git a/.changeset/vite-ignore-optional-imports.md b/.changeset/vite-ignore-optional-imports.md deleted file mode 100644 index 286f80132de..00000000000 --- a/.changeset/vite-ignore-optional-imports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Suppress a build-time warning that could appear in Vite-based projects when the optional `@ai-sdk/otel` package is not installed. diff --git a/.server-changes/dev-branch-member-scoping.md b/.server-changes/dev-branch-member-scoping.md deleted file mode 100644 index d836c6c8357..00000000000 --- a/.server-changes/dev-branch-member-scoping.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Allow different organization members to use the same development branch name without sharing or colliding with each other's branch environments. diff --git a/.server-changes/limit-account-email-length.md b/.server-changes/limit-account-email-length.md deleted file mode 100644 index 2259f007e06..00000000000 --- a/.server-changes/limit-account-email-length.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Limit account settings email input to 254 characters. diff --git a/.server-changes/prevent-duplicate-root-environments.md b/.server-changes/prevent-duplicate-root-environments.md deleted file mode 100644 index 0710595deb7..00000000000 --- a/.server-changes/prevent-duplicate-root-environments.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Prevent duplicate Staging and Preview environments when account setup requests overlap diff --git a/.server-changes/prompts-blank-state-docs-link.md b/.server-changes/prompts-blank-state-docs-link.md deleted file mode 100644 index 562b92ac863..00000000000 --- a/.server-changes/prompts-blank-state-docs-link.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Fix the docs link on the empty Prompts page, which pointed to a page that no longer exists. diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md deleted file mode 100644 index 5558a1cec3d..00000000000 --- a/.server-changes/side-menu-project-and-org-menus.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Refreshed the side menu: separate organization and account menus, a new project switcher, and the menu is now resizable by dragging its edge. The account Profile page has also been redesigned. diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index 899346acfd3..e40b51845c3 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: trigger description: The official Trigger.dev Helm chart type: application -version: 4.5.6 -appVersion: v4.5.6 +version: 4.5.7 +appVersion: v4.5.7 home: https://trigger.dev sources: - https://github.com/triggerdotdev/trigger.dev diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index def8d5fe79d..c8bbf39dd05 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/build +## 4.5.7 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index dcadb68db99..e598555da86 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/build", - "version": "4.5.6", + "version": "4.5.7", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -79,7 +79,7 @@ }, "dependencies": { "@prisma/config": "^6.10.0", - "@trigger.dev/core": "workspace:4.5.6", + "@trigger.dev/core": "workspace:4.5.7", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index 6cb6c8b3086..5840b31cdf6 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,27 @@ # trigger.dev +## 4.5.7 + +### Patch Changes + +- Fixes intermittent `trigger dev` run crashes where a run could fail at boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a rebuild had cleaned up the build directory the run was launched against. Dev runs now retry cleanly instead of hard-crashing when their build directory is missing, the dev watchdog no longer removes the build tree of a still-running session, and a run assigned to a worker version that was superseded by a rebuild now fails fast with a clear message instead of silently hanging until it times out. ([#4276](https://github.com/triggerdotdev/trigger.dev/pull/4276)) +- Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. ([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337)) + + ```ts + import { defineConfig } from "@trigger.dev/sdk"; + + export default defineConfig({ + runtime: "node-24", + project: "", + }); + ``` + +- Avoid logging task run environment variable values at debug level ([#4336](https://github.com/triggerdotdev/trigger.dev/pull/4336)) +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + - `@trigger.dev/build@4.5.7` + - `@trigger.dev/schema-to-json@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 0aa3b9aa288..c6d8d9719dc 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "4.5.6", + "version": "4.5.7", "description": "A Command-Line Interface for Trigger.dev projects", "type": "module", "license": "MIT", @@ -96,9 +96,9 @@ "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "1.41.1", "@s2-dev/streamstore": "^0.22.10", - "@trigger.dev/build": "workspace:4.5.6", - "@trigger.dev/core": "workspace:4.5.6", - "@trigger.dev/schema-to-json": "workspace:4.5.6", + "@trigger.dev/build": "workspace:4.5.7", + "@trigger.dev/core": "workspace:4.5.7", + "@trigger.dev/schema-to-json": "workspace:4.5.7", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 0696d5123f1..6c2fb47dd01 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,20 @@ # internal-platform +## 4.5.7 + +### Patch Changes + +- Add `node-24` and `node-26` as supported `runtime` options in `trigger.config.ts`. The `experimental-node-24` and `experimental-node-26` names are now deprecated aliases and emit a deprecation warning; switch to `node-24` / `node-26` instead. ([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337)) + + ```ts + import { defineConfig } from "@trigger.dev/sdk"; + + export default defineConfig({ + runtime: "node-24", + project: "", + }); + ``` + ## 4.5.6 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index ca827985641..f0c3adfcf2f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "4.5.6", + "version": "4.5.7", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "publishConfig": { diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index 48ca36c0ca2..bd9c54d71c7 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.5.7 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/sdk@4.5.7` + - `@trigger.dev/core@4.5.7` + - `@trigger.dev/build@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index 635f74ef382..bebd4ade64f 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/python", - "version": "4.5.6", + "version": "4.5.7", "description": "Python runtime and build extension for Trigger.dev", "license": "MIT", "publishConfig": { @@ -45,7 +45,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.5.6", + "@trigger.dev/core": "workspace:4.5.7", "tinyexec": "^0.3.2" }, "devDependencies": { @@ -56,12 +56,12 @@ "tsx": "4.17.0", "esbuild": "^0.23.0", "@arethetypeswrong/cli": "^0.18.5", - "@trigger.dev/build": "workspace:4.5.6", - "@trigger.dev/sdk": "workspace:4.5.6" + "@trigger.dev/build": "workspace:4.5.7", + "@trigger.dev/sdk": "workspace:4.5.7" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.5.6", - "@trigger.dev/build": "workspace:^4.5.6" + "@trigger.dev/sdk": "workspace:^4.5.7", + "@trigger.dev/build": "workspace:^4.5.7" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index dc99b3b456f..f422e8cb94f 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react-hooks +## 4.5.7 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 9a348b8ad35..b0b0ae668d9 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react-hooks", - "version": "4.5.6", + "version": "4.5.7", "description": "trigger.dev react hooks", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.5.6", + "@trigger.dev/core": "workspace:^4.5.7", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index db6f152a23a..5ddc4f32d44 100644 --- a/packages/redis-worker/CHANGELOG.md +++ b/packages/redis-worker/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/redis-worker +## 4.5.7 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index ed7c16d3c1f..edd27f40e41 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/redis-worker", - "version": "4.5.6", + "version": "4.5.7", "description": "Redis worker for trigger.dev", "license": "MIT", "publishConfig": { @@ -23,7 +23,7 @@ "test": "vitest --sequence.concurrent=false --no-file-parallelism" }, "dependencies": { - "@trigger.dev/core": "workspace:4.5.6", + "@trigger.dev/core": "workspace:4.5.7", "lodash.omit": "^4.5.0", "nanoid": "^5.0.7", "p-limit": "^6.2.0", diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md index 17bf179f3d1..89b0364191d 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.5.7 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index 98624b20e20..46b17e36ba9 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/rsc", - "version": "4.5.6", + "version": "4.5.7", "description": "trigger.dev rsc", "license": "MIT", "publishConfig": { @@ -37,14 +37,14 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.5.6", + "@trigger.dev/core": "workspace:^4.5.7", "mlly": "^1.7.1", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", - "@trigger.dev/build": "workspace:^4.5.6", + "@trigger.dev/build": "workspace:^4.5.7", "@types/node": "^24.13.3", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/schema-to-json/CHANGELOG.md b/packages/schema-to-json/CHANGELOG.md index 68fbd531a88..50c400b068b 100644 --- a/packages/schema-to-json/CHANGELOG.md +++ b/packages/schema-to-json/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/schema-to-json +## 4.5.7 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json index 5148d5f0dae..03e9c936210 100644 --- a/packages/schema-to-json/package.json +++ b/packages/schema-to-json/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/schema-to-json", - "version": "4.5.6", + "version": "4.5.7", "description": "Convert various schema validation libraries to JSON Schema", "license": "MIT", "publishConfig": { diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index 6a4ebef26bc..ecb8fc3bacf 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,34 @@ # @trigger.dev/sdk +## 4.5.7 + +### Patch Changes + +- Custom chat agent loops get two ergonomic wins for owning the turn loop. ([#4304](https://github.com/triggerdotdev/trigger.dev/pull/4304)) + + `chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client. + + ```ts + const { lastEventId, sessionInEventId } = await chat.writeTurnComplete(); + await db.chats.update(chatId, { lastEventId, sessionInEventId }); + ``` + + `chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result: + + ```ts + const { message, status, error } = await chat.pipeAndCapture(result, { + signal, + }); + if (message) conversation.addResponse(message); + if (status === "error") logger.error("turn failed", { error }); + ``` + + Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result. + +- Suppress a build-time warning that could appear in Vite-based projects when the optional `@ai-sdk/otel` package is not installed. ([#4188](https://github.com/triggerdotdev/trigger.dev/pull/4188)) +- Updated dependencies: + - `@trigger.dev/core@4.5.7` + ## 4.5.6 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 77889cb09f3..50141b73cd0 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "4.5.6", + "version": "4.5.7", "description": "trigger.dev Node.JS SDK", "license": "MIT", "publishConfig": { @@ -77,7 +77,7 @@ "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", - "@trigger.dev/core": "workspace:4.5.6", + "@trigger.dev/core": "workspace:4.5.7", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f505b0ab08c..cd2d2d93fba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1417,7 +1417,7 @@ importers: specifier: ^6.10.0 version: 6.19.0(magicast@0.3.5) '@trigger.dev/core': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../core mlly: specifier: ^1.7.1 @@ -1493,13 +1493,13 @@ importers: specifier: ^0.22.10 version: 0.22.10(supports-color@10.0.0) '@trigger.dev/build': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../build '@trigger.dev/core': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../core '@trigger.dev/schema-to-json': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../schema-to-json ansi-escapes: specifier: ^7.0.0 @@ -1886,7 +1886,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1896,10 +1896,10 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@trigger.dev/build': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../trigger-sdk '@types/node': specifier: 24.13.3 @@ -1923,7 +1923,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.5.6 + specifier: workspace:^4.5.7 version: link:../core react: specifier: 18.3.1 @@ -1957,7 +1957,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../core cron-parser: specifier: ^4.9.0 @@ -2006,7 +2006,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.5.6 + specifier: workspace:^4.5.7 version: link:../core mlly: specifier: ^1.7.1 @@ -2022,7 +2022,7 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@trigger.dev/build': - specifier: workspace:^4.5.6 + specifier: workspace:^4.5.7 version: link:../build '@types/node': specifier: 24.13.3 @@ -2101,7 +2101,7 @@ importers: specifier: 1.41.1 version: 1.41.1 '@trigger.dev/core': - specifier: workspace:4.5.6 + specifier: workspace:4.5.7 version: link:../core chalk: specifier: ^5.2.0 From a2d382b2be3964c9b0ea7edc5fced8844258d8f2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 22 Jul 2026 17:48:44 +0100 Subject: [PATCH 023/103] feat(webapp): add emission fan-out metrics to the native realtime feed (#4341) ## Summary Adds two counters to the native realtime backend so we can see how much duplicate row serialization the change router does per batch. When a run changes it can match several held feeds at once (a run subscription plus one or more tag/list feeds), and today each matching feed serializes that run's wire value independently. These counters quantify that fan-out so we can decide whether a shared serialization step is worth it. ## What they measure - `realtime_native.emission_run_serializations`: total wire-value serializations performed across feeds per batch (what the current path does). - `realtime_native.emission_distinct_serializations`: distinct (columns, run) rows those serializations cover (what a serialize-once-per-batch step would do). Average feeds-per-run is `run_serializations / distinct_serializations`, and `1 - distinct / run_serializations` is the serialization work a shared step could save. Wired through a new optional `onEmissionFanout` callback on the router. No behavior change. --- .../realtime-emission-fanout-metrics.md | 6 ++++++ .../realtime/envChangeRouter.server.ts | 21 +++++++++++++++++++ .../nativeRealtimeClientInstance.server.ts | 14 +++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 .server-changes/realtime-emission-fanout-metrics.md diff --git a/.server-changes/realtime-emission-fanout-metrics.md b/.server-changes/realtime-emission-fanout-metrics.md new file mode 100644 index 00000000000..bd9ef83afbc --- /dev/null +++ b/.server-changes/realtime-emission-fanout-metrics.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch. diff --git a/apps/webapp/app/services/realtime/envChangeRouter.server.ts b/apps/webapp/app/services/realtime/envChangeRouter.server.ts index 95e25b7d0c0..e183bf636a3 100644 --- a/apps/webapp/app/services/realtime/envChangeRouter.server.ts +++ b/apps/webapp/app/services/realtime/envChangeRouter.server.ts @@ -51,6 +51,13 @@ export type EnvChangeRouterOptions = { /** Observability: a buffered record was evicted. `cap` evictions mean the env churns more * runs inside the window than the buffer holds (the replay guarantee is degrading). */ onReplayEviction?: (reason: "cap" | "window") => void; + /** Observability: per-batch emission fan-out. `deliveries` = total (feed,run) rows matched and + * resolved to feeds this batch. It is an upper bound on the per-feed wire serializations, since + * the client working-set diff drops already-seen rows before encoding. `distinctRuns` = distinct + * (columnSig,runId) among them (the serialize-once-per-batch floor). `deliveries / distinctRuns` + * is the average number of feeds a changed run is delivered to; a shared-serialization step would + * save at most `deliveries - distinctRuns` encodings. */ + onEmissionFanout?: (stats: { distinctRuns: number; deliveries: number; feeds: number }) => void; /** Read-your-writes gate over the replica: delays wake-path hydrates until the replica * should have applied the change (record.updatedAtMs + lag + margin), and re-hydrates * rows the tripwire still finds stale. Omit to hydrate immediately (legacy behavior). */ @@ -609,6 +616,8 @@ export class EnvChangeRouter { // 4. Assemble each feed's matched rows (post-filtering tag feeds against the // authoritative hydrated row) and resolve its pending wait. + let deliveries = 0; + const distinctRunKeys = new Set(); for (const [feed, runIds] of matchedRunIdsByFeed) { if (!feed.resolve) { continue; // stopped waiting while we hydrated; its next poll/backstop covers it @@ -631,10 +640,22 @@ export class EnvChangeRouter { if (rows.length > 0) { feed.resolve({ reason: "notify", rows }); + deliveries += rows.length; + for (const matched of rows) { + distinctRunKeys.add(`${feed.columnSig}${matched.row.id}`); + } } // No surviving rows (e.g. a partial-record candidate that didn't actually match): // leave the feed waiting; nothing relevant changed for it. } + + if (deliveries > 0) { + this.options.onEmissionFanout?.({ + distinctRuns: distinctRunKeys.size, + deliveries, + feeds: matchedRunIdsByFeed.size, + }); + } } /** Runs whose hydrated row is provably behind its record's watermark (stale content), diff --git a/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts b/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts index 3f29f3faa47..77a9a02e5fc 100644 --- a/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts +++ b/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts @@ -71,6 +71,16 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient { unit: "rows", }); + const emissionFeedDeliveries = meter.createCounter("realtime_native.emission_feed_deliveries", { + description: + "Matched (feed,run) rows resolved to feeds per batch, summed. Upper bound on per-feed wire serializations, since the client working-set diff drops already-seen rows before encoding. Divide by realtime_native.emission_distinct_runs for average feeds-per-run fan-out.", + }); + + const emissionDistinctRuns = meter.createCounter("realtime_native.emission_distinct_runs", { + description: + "Distinct (columnSig,run) among the deliveries per batch, summed. The serialize-once-per-batch floor: a shared-serialization step would encode at most this many rows, saving at most (feed_deliveries minus distinct_runs) encodings.", + }); + const backstops = meter.createCounter("realtime_native.backstops", { description: "Backstop full resolves by outcome. 'empty' is normal idle behavior; sustained 'delivered' means the notify/replay path missed changes — alert on it.", @@ -166,6 +176,10 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient { unsubscribeLingerMs: env.REALTIME_BACKEND_NATIVE_UNSUBSCRIBE_LINGER_MS, onReplay: (result) => replays.add(1, { result }), onReplayEviction: (reason) => replayEvictions.add(1, { reason }), + onEmissionFanout: ({ distinctRuns, deliveries }) => { + emissionFeedDeliveries.add(deliveries); + emissionDistinctRuns.add(distinctRuns); + }, replicaLag: lagEstimator ? { getLagMs: () => lagEstimator.getLagMs(), From 0ef9d5b2a4492eaa805345cfcd1f4da5a7371ccc Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 22 Jul 2026 19:07:06 +0000 Subject: [PATCH 024/103] feat(webapp): add Classic, System preference, Dark and Light interface themes - Theme preference widens to classic | system | dark | light (default classic, the unchanged original dark theme) - System preference resolves to dark/light from the OS setting via a pre-paint inline script and follows OS changes live - Dark and Light are the pinned halves of System preference; Dark derives from Classic with shared cross-mode accent overrides only - Contrast-tuned light-mode accents (icons, callouts, run-status and shadcn chart colors) and fixed raw-palette bypasses in components - Themes override semantic tokens only, keeping room for a future contrast control --- .server-changes/light-theme.md | 2 +- .../webapp/app/components/LoginPageLayout.tsx | 2 +- .../components/navigation/TreeConnectors.tsx | 4 +- .../components/primitives/BreadcrumbIcon.tsx | 2 +- .../app/components/primitives/ClientTabs.tsx | 4 +- .../primitives/SegmentedControl.tsx | 2 +- .../components/primitives/charts/Chart.tsx | 4 +- apps/webapp/app/components/runs/v3/RunTag.tsx | 6 +- apps/webapp/app/hooks/useSystemThemeSync.ts | 25 ++++++ apps/webapp/app/root.tsx | 35 +++++++-- .../route.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 2 +- .../app/routes/account._index/route.tsx | 78 +++++++++++-------- .../services/dashboardPreferences.server.ts | 14 +++- apps/webapp/app/tailwind.css | 55 +++++++++++-- apps/webapp/test/themePreference.test.ts | 46 +++++++++++ 17 files changed, 225 insertions(+), 60 deletions(-) create mode 100644 apps/webapp/app/hooks/useSystemThemeSync.ts create mode 100644 apps/webapp/test/themePreference.test.ts diff --git a/.server-changes/light-theme.md b/.server-changes/light-theme.md index ceaae7914f8..6d12eb6067a 100644 --- a/.server-changes/light-theme.md +++ b/.server-changes/light-theme.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -Adds an opt-in light theme behind a feature flag. When enabled, an Interface theme setting appears on the account page (dark stays the default). Code editors and syntax highlighting use the trigger.light palette. +Adds an opt-in Interface theme setting on the account page behind a feature flag. Choose Classic (the default), System preference (follows your OS), Dark, or Light. System preference switches automatically when your OS appearance changes. diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index 3e698ef87ce..1db614eb926 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -72,7 +72,7 @@ export function LoginPageLayout({ {rightContent ?? ( <>
- + {randomQuote?.quote} {randomQuote?.person} diff --git a/apps/webapp/app/components/navigation/TreeConnectors.tsx b/apps/webapp/app/components/navigation/TreeConnectors.tsx index 8479179a491..25e8212b899 100644 --- a/apps/webapp/app/components/navigation/TreeConnectors.tsx +++ b/apps/webapp/app/components/navigation/TreeConnectors.tsx @@ -5,7 +5,7 @@ import { cn } from "~/utils/cn"; export function TreeConnectorBranch({ className }: { className?: string }) { return ( @@ -18,7 +18,7 @@ export function TreeConnectorBranch({ className }: { className?: string }) { export function TreeConnectorEnd({ className }: { className?: string }) { return ( diff --git a/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx b/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx index b5dfb55ba7e..7d7fae46be0 100644 --- a/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx +++ b/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx @@ -3,7 +3,7 @@ import { cn } from "~/utils/cn"; export function BreadcrumbIcon({ className }: { className?: string }) { return ( ) : ( -
+
) ) : null} diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index a3c365b930b..5cf65fce4dc 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -28,7 +28,7 @@ const theme = { active: "text-text-bright", inactive: "text-text-dimmed transition hover:text-text-bright", selected: - "absolute inset-0 rounded bg-white border border-[#e2e4e9] dark:bg-background-raised dark:border-border-bright", + "absolute inset-0 rounded bg-white border border-grid-bright dark:bg-background-raised dark:border-border-bright", }, }; diff --git a/apps/webapp/app/components/primitives/charts/Chart.tsx b/apps/webapp/app/components/primitives/charts/Chart.tsx index 85d28336837..3ec2e6e976d 100644 --- a/apps/webapp/app/components/primitives/charts/Chart.tsx +++ b/apps/webapp/app/components/primitives/charts/Chart.tsx @@ -5,7 +5,9 @@ import { AnimatedNumber } from "../AnimatedNumber"; import TooltipPortal from "../TooltipPortal"; // Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: '[data-theme="dark"]' } as const; +// dark covers Classic too; :is() keeps it one scoped selector when the prefix is +// interpolated as `${prefix} [data-chart=...]` below (a comma would break scoping). +const THEMES = { light: "", dark: ':is([data-theme="dark"], [data-theme="classic"])' } as const; export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined; diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index 1defae01674..16d2333632e 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -46,7 +46,7 @@ export function RunTag({ return ( <> - + {tag} @@ -55,10 +55,10 @@ export function RunTag({ return ( <> - + {tagResult.key} - + {tagResult.value} diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts new file mode 100644 index 00000000000..172492dc83d --- /dev/null +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { type ThemePreference } from "~/services/dashboardPreferences.server"; + +/** + * When the preference is `system`, keep `data-theme` on in sync with the + * OS color scheme live. The single resolution rule (dark vs light) lives here + * and in the blocking inline script in root.tsx; downstream consumers react to + * the `data-theme` mutation (see useThemeColor). No-op for pinned themes. + */ +export function useSystemThemeSync(preference: ThemePreference) { + useEffect(() => { + if (preference !== "system") { + return; + } + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => { + document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light"); + }; + + apply(); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [preference]); +} diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index c5b928064a4..8999da17ed1 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -19,7 +19,12 @@ import { TimezoneSetter } from "./components/TimezoneSetter"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; +import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getUser } from "./services/session.server"; +import { + normalizeThemePreference, + type ThemePreference, +} from "./services/dashboardPreferences.server"; import { flag } from "~/v3/featureFlags.server"; import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; import { appEnvTitleTag } from "./utils"; @@ -71,11 +76,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); - // Theme switching is feature-flagged; while off, everyone stays on dark - // even if a preference was saved earlier. + // Theme switching is feature-flagged; while off, everyone stays on the + // classic theme even if a preference was saved earlier. const showThemeSwitcher = user ? await flag({ key: "hasThemeSwitcher", defaultValue: true }) : false; + const themePreference: ThemePreference = showThemeSwitcher + ? normalizeThemePreference(user?.dashboardPreferences.theme) + : "classic"; const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); @@ -94,6 +102,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { kapa, timezone, showThemeSwitcher, + themePreference, // Consumed by ResizablePanel: the browser check must match between SSR // and hydration, so it is derived from the request user-agent. isFirefox: /firefox/i.test(request.headers.get("user-agent") ?? ""), @@ -115,7 +124,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => { export function ErrorBoundary() { return ( <> - + @@ -143,16 +152,28 @@ export default function App() { posthogProjectKey, posthogUiHost, kapa: _kapa, - user, - showThemeSwitcher, + themePreference, } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); - const theme = (showThemeSwitcher ? user?.dashboardPreferences.theme : "dark") ?? "dark"; + useSystemThemeSync(themePreference); + // SSR falls back to dark for `system`; the inline script below corrects it + // before paint, and useSystemThemeSync keeps it live afterwards. + const resolvedTheme = themePreference === "system" ? "dark" : themePreference; return ( <> - + +