From ba9f6e7f31c8dc45aae07a6333b775ed18dbea27 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 7 Jul 2026 23:57:47 +0000 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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 08/28] 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 23d5771d561567377c46de7df2d349178580f8d4 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 22 Jul 2026 21:23:52 +0200 Subject: [PATCH 24/28] feat(webapp): unconfigured billing limit UX and default billing alerts (#4328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Default billing alerts + billing limit page UX - New orgs get default billing alerts: $5, $100, $500, $1000, $2500. Existing orgs are backfilled by a billing-side data migration (companion [PR](https://github.com/triggerdotdev/cloud/pull/1657)). - The billing limit form starts with nothing selected for orgs that never set a limit — the save button appears once an option is picked. - The yellow banner now also shows on the billing limits page itself, asking to configure a limit. Hidden everywhere for members who can't manage billing. - Also fixes billing limit alert preview. Tests - `apps/webapp/test/billingLimitsRoute.test.ts` — dirty logic for empty/selected mode - `apps/webapp/test/billingAlertsDefaults.test.ts` — default values - `apps/webapp/test/billingAlertsFormat.test.ts` — preview after a limit change --- .server-changes/default-billing-alerts.md | 6 +++ .../billing/BillingLimitConfigSection.tsx | 45 +++++++++++++------ .../components/billing/billingAlertsFormat.ts | 8 +++- apps/webapp/app/models/organization.server.ts | 39 +++++++++++++++- .../services/billingAlertsDefaults.server.ts | 17 +++++++ .../webapp/test/billingAlertsDefaults.test.ts | 26 +++++++++++ apps/webapp/test/billingAlertsFormat.test.ts | 35 +++++++++++++-- apps/webapp/test/billingLimitsRoute.test.ts | 41 +++++++++++++++++ 8 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 .server-changes/default-billing-alerts.md create mode 100644 apps/webapp/app/services/billingAlertsDefaults.server.ts create mode 100644 apps/webapp/test/billingAlertsDefaults.test.ts diff --git a/.server-changes/default-billing-alerts.md b/.server-changes/default-billing-alerts.md new file mode 100644 index 00000000000..e828c506050 --- /dev/null +++ b/.server-changes/default-billing-alerts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit. diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index c69d6f0c175..e6362d4cb1d 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat"; import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat"; import { AnimatedCallout } from "~/components/primitives/AnimatedCallout"; +import { Callout } from "~/components/primitives/Callout"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -51,10 +52,14 @@ type BillingLimitActionData = { export function isBillingLimitFormDirty(input: { billingLimit: BillingLimitResult; - mode: "none" | "plan" | "custom"; + mode: "" | "none" | "plan" | "custom"; customAmount: string; cancelInProgressRuns: boolean; }): boolean { + if (input.mode === "") { + return false; + } + const needsInitialSave = !input.billingLimit.isConfigured; const savedMode = getBillingLimitMode(input.billingLimit); const savedCustomAmount = @@ -75,7 +80,7 @@ export function isBillingLimitFormDirty(input: { export function getBillingLimitFormLastSubmission( submission: BillingLimitActionData["submission"] | undefined, - mode: "none" | "plan" | "custom", + mode: "" | "none" | "plan" | "custom", isDirty: boolean ) { if (!isDirty || !submission) { @@ -111,17 +116,20 @@ export function BillingLimitConfigSection({ : ""; const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns; - const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode); + // Unconfigured limit starts with nothing selected. + const resetMode: "" | "none" | "plan" | "custom" = billingLimit.isConfigured ? savedMode : ""; + + const [mode, setMode] = useState<"" | "none" | "plan" | "custom">(resetMode); const [customAmount, setCustomAmount] = useState(savedCustomAmount); const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns); const customAmountInputRef = useRef(null); const formRef = useRef(null); useEffect(() => { - setMode(savedMode); + setMode(resetMode); setCustomAmount(savedCustomAmount); setCancelInProgressRuns(savedCancelInProgressRuns); - }, [savedMode, savedCustomAmount, savedCancelInProgressRuns]); + }, [resetMode, savedCustomAmount, savedCancelInProgressRuns]); function handleModeChange(value: string) { const nextMode = value as typeof mode; @@ -183,6 +191,13 @@ export function BillingLimitConfigSection({
+ {!billingLimit.isConfigured && ( + + Configure a monthly billing limit below to cap your spend, or set no limit to let runs + keep going. + + )} +
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
- {mode !== "none" && ( + {(mode === "plan" || mode === "custom") && ( )} - - Save billing limit - - } - /> + {mode !== "" && ( + + Save billing limit + + } + /> + )}
diff --git a/apps/webapp/app/components/billing/billingAlertsFormat.ts b/apps/webapp/app/components/billing/billingAlertsFormat.ts index dc99c49ff1d..5940405a84c 100644 --- a/apps/webapp/app/components/billing/billingAlertsFormat.ts +++ b/apps/webapp/app/components/billing/billingAlertsFormat.ts @@ -208,6 +208,11 @@ export function isLegacyDollarAmountField( return false; } + // The exact $1 absolute base marker always wins, even with levels below 100 (e.g. a $5 alert). + if (rawAmount === ABSOLUTE_ALERT_BASE_CENTS) { + return false; + } + if (!Number.isFinite(rawAmount) || rawAmount < 10) { return false; } @@ -315,8 +320,9 @@ export function getAlertPreviewLimitCents( planLimitCents: number ): number { const amountCents = getSavedAlertAmountCents(alerts); + // Percentages always apply to the current limit, not the base stored at last save. if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) { - return amountCents; + return effectiveLimitCents; } if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) { return amountCents; diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index a4b8a8ab5f5..9f7fb382315 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -6,6 +6,7 @@ import type { RuntimeEnvironment, User, } from "@trigger.dev/database"; +import { tryCatch } from "@trigger.dev/core/utils"; import { customAlphabet } from "nanoid"; import { generate } from "random-words"; import slug from "slug"; @@ -13,8 +14,14 @@ import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; -import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; +import { + getDefaultEnvironmentConcurrencyLimit, + isBillingConfigured, + setBillingAlert, +} from "~/services/platform.v3.server"; +import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server"; import { enqueueAttioWorkspaceSync } from "~/services/attio.server"; +import { logger } from "~/services/logger.server"; import { applyBillingLimitPauseAfterEnvCreate, getInitialEnvPauseStateForBillingLimit, @@ -122,9 +129,39 @@ export async function createOrganization( adminUserId: userId, }); + // Awaited so the seed can't land after the user's first alert edit. + await seedDefaultBillingAlerts(organization.id); + return { ...organization }; } +// The platform client has no request timeout; don't let a slow billing backend stall org creation. +const SEED_ALERTS_TIMEOUT_MS = 5_000; + +/** Seed default billing alerts for a new org. Never fails org creation. */ +async function seedDefaultBillingAlerts(organizationId: string): Promise { + if (!isBillingConfigured()) { + return; + } + + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Timed out")), SEED_ALERTS_TIMEOUT_MS); + }); + + const [error] = await tryCatch( + Promise.race([setBillingAlert(organizationId, buildDefaultBillingAlerts()), timeout]).finally( + () => clearTimeout(timer) + ) + ); + if (error) { + logger.warn("Failed to seed default billing alerts for new org", { + organizationId, + error: error instanceof Error ? error.message : error, + }); + } +} + export async function createEnvironment({ organization, project, diff --git a/apps/webapp/app/services/billingAlertsDefaults.server.ts b/apps/webapp/app/services/billingAlertsDefaults.server.ts new file mode 100644 index 00000000000..14db67bf4e7 --- /dev/null +++ b/apps/webapp/app/services/billingAlertsDefaults.server.ts @@ -0,0 +1,17 @@ +import type { UpdateBillingAlertsRequest } from "@trigger.dev/platform"; +import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat"; + +/** Default absolute alert thresholds in dollars. */ +const DEFAULT_ALERT_THRESHOLD_DOLLARS = [5, 100, 500, 1000, 2500]; + +/** + * Alerts fire at `usage / amount >= level`; amount = 100 cents makes levels + * absolute dollar thresholds. Empty emails fall back to org admins. + */ +export function buildDefaultBillingAlerts(): UpdateBillingAlertsRequest { + return { + amount: ABSOLUTE_ALERT_BASE_CENTS, + emails: [], + alertLevels: [...DEFAULT_ALERT_THRESHOLD_DOLLARS], + }; +} diff --git a/apps/webapp/test/billingAlertsDefaults.test.ts b/apps/webapp/test/billingAlertsDefaults.test.ts new file mode 100644 index 00000000000..6e655e38fea --- /dev/null +++ b/apps/webapp/test/billingAlertsDefaults.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server"; +import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat"; + +describe("buildDefaultBillingAlerts", () => { + it("uses the absolute dollar base so alert levels read as dollar thresholds", () => { + expect(buildDefaultBillingAlerts().amount).toBe(ABSOLUTE_ALERT_BASE_CENTS); + expect(buildDefaultBillingAlerts().amount).toBe(100); + }); + + it("starts with no recipients so the platform falls back to org members", () => { + expect(buildDefaultBillingAlerts().emails).toEqual([]); + }); + + it("seeds the default dollar alert thresholds", () => { + expect(buildDefaultBillingAlerts().alertLevels).toEqual([5, 100, 500, 1000, 2500]); + }); + + it("returns a fresh alertLevels array each call (no shared mutable state)", () => { + const first = buildDefaultBillingAlerts(); + const second = buildDefaultBillingAlerts(); + expect(first.alertLevels).not.toBe(second.alertLevels); + first.alertLevels.push(9999); + expect(second.alertLevels).toEqual([5, 100, 500, 1000, 2500]); + }); +}); diff --git a/apps/webapp/test/billingAlertsFormat.test.ts b/apps/webapp/test/billingAlertsFormat.test.ts index 7ef84770d7b..98a61ca8b86 100644 --- a/apps/webapp/test/billingAlertsFormat.test.ts +++ b/apps/webapp/test/billingAlertsFormat.test.ts @@ -90,6 +90,19 @@ describe("billingAlertsFormat", () => { ).toBe(10_000); }); + it("previews saved percentage alerts against the current limit after it changes", () => { + // Percentage alerts saved against a $30 custom limit, limit later raised to $300. + const alerts = { amount: 30, emails: [], alertLevels: [0.75, 1.0] }; + + expect(getAlertPreviewLimitCents(alerts, 30_000, 10_000)).toBe(30_000); + expect( + previewDollarAmountForPercent(100, getAlertPreviewLimitCents(alerts, 30_000, 10_000)) + ).toBe(300); + expect( + previewDollarAmountForPercent(75, getAlertPreviewLimitCents(alerts, 30_000, 10_000)) + ).toBe(225); + }); + it("normalizes legacy API alerts with dollar amount field and whole percents", () => { expect( normalizeBillingAlertsFromApi( @@ -150,6 +163,22 @@ describe("billingAlertsFormat", () => { ); }); + it("keeps seeded absolute defaults absolute when the plan limit is exactly $100", () => { + const normalized = normalizeBillingAlertsFromApi( + { + amount: 100, + emails: [], + alertLevels: [5, 100, 500, 1000, 2500], + }, + { planLimitCents: 10_000, effectiveLimitCents: 10_000 } + ); + + expect(normalized.amount).toBe(1); + expect(storedAlertsToThresholds(normalized, "none", 10_000, 10_000)).toEqual([ + 5, 100, 500, 1000, 2500, + ]); + }); + it("normalizes cents-based alerts with whole-number levels when amount matches limit", () => { const normalized = normalizeBillingAlertsFromApi( { @@ -173,14 +202,14 @@ describe("billingAlertsFormat", () => { expect( normalizeBillingAlertsFromApi( { - amount: 100, + amount: 300, emails: [], alertLevels: [10, 50, 80], }, - { planLimitCents: 10_000, effectiveLimitCents: 10_000 } + { planLimitCents: 30_000, effectiveLimitCents: 30_000 } ) ).toEqual({ - amount: 100, + amount: 300, emails: [], alertLevels: [10, 50, 80], }); diff --git a/apps/webapp/test/billingLimitsRoute.test.ts b/apps/webapp/test/billingLimitsRoute.test.ts index d85a48ffb24..66f1ac5a7b5 100644 --- a/apps/webapp/test/billingLimitsRoute.test.ts +++ b/apps/webapp/test/billingLimitsRoute.test.ts @@ -266,6 +266,27 @@ describe("isBillingLimitFormDirty", () => { gracePeriodMs: 86_400_000, }; + it("is not dirty when no mode is selected, regardless of other inputs", () => { + expect( + isBillingLimitFormDirty({ + billingLimit: unconfiguredLimit, + mode: "", + customAmount: "999.00", + cancelInProgressRuns: true, + }) + ).toBe(false); + + // Even a configured limit stays clean while the form has no selection. + expect( + isBillingLimitFormDirty({ + billingLimit: configuredPlanLimit, + mode: "", + customAmount: "50.00", + cancelInProgressRuns: true, + }) + ).toBe(false); + }); + it("is dirty when billing limit has never been saved", () => { expect( isBillingLimitFormDirty({ @@ -277,6 +298,26 @@ describe("isBillingLimitFormDirty", () => { ).toBe(true); }); + it("is dirty when an unconfigured limit picks a real mode (initial save)", () => { + expect( + isBillingLimitFormDirty({ + billingLimit: unconfiguredLimit, + mode: "plan", + customAmount: "", + cancelInProgressRuns: false, + }) + ).toBe(true); + + expect( + isBillingLimitFormDirty({ + billingLimit: unconfiguredLimit, + mode: "custom", + customAmount: "100.00", + cancelInProgressRuns: false, + }) + ).toBe(true); + }); + it("is clean when configured values match saved state", () => { expect( isBillingLimitFormDirty({ From e9ac98b7a11cdb5f987571579c752cccdc1f84b9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 22 Jul 2026 23:03:24 +0100 Subject: [PATCH 25/28] perf(run-store): route id-set reads to the owning store, not both DBs (#4342) ## Summary The split run-store's id-set read path (`#findRunsByIdSet`, used by the runs-list hydrate, the realtime hydrator, and engine sweeps) queried the new store for the entire id set and then probed the legacy store for the misses. A run's residency is a total function of its id (run-ops ids live in the new store, every other id in legacy), so each id belongs to exactly one store. Route each id to its owner and query each store only for its own ids, in parallel. Same result set, and while a split is active with most runs still on legacy it removes a wasted new-store query from every id-set read. ## Change `#findRunsByIdSet` now partitions the ids by `classifyResidency` and runs one bounded query per store (skipping an empty side), in parallel, mirroring `expireRunsBatch` and the single-run `#route`. `finalizeRows` still applies orderBy/take/skip globally over the merged set. This drops the id-set path's cross-store fallback, which existed to prefer the new-store copy when the same id was present in both stores. That collision cannot arise when each id maps to exactly one store (nothing writes a legacy-shaped id into the new store), so the fallback is dead code. The two id-set tests that asserted "new copy wins on collision" now assert the routing invariant: a legacy-shaped id resolves to the legacy store and the path never consults the new store. The open-predicate path (`#findRunsOpen`) is unchanged: an open `where` has no id to route on, so it still unions both stores and dedupes. --- ...ointPresenter.connectedRunsBounded.test.ts | 2 + ...intPresenter.danglingConnectedRuns.test.ts | 2 + ...dedicatedConnectedRuns.readthrough.test.ts | 7 +- ...tpointPresenter.splitConnectedRuns.test.ts | 7 +- .../runOpsStore.idSetResidencyRouting.test.ts | 132 ++++++++++++++++++ .../src/runOpsStore.mixedResidency.test.ts | 9 +- .../run-store/src/runOpsStore.test.ts | 6 +- .../run-store/src/runOpsStore.ts | 42 +++--- 8 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 internal-packages/run-store/src/runOpsStore.idSetResidencyRouting.test.ts diff --git a/apps/webapp/test/waitpointPresenter.connectedRunsBounded.test.ts b/apps/webapp/test/waitpointPresenter.connectedRunsBounded.test.ts index 9e2b0bcafb6..ba50d312fd6 100644 --- a/apps/webapp/test/waitpointPresenter.connectedRunsBounded.test.ts +++ b/apps/webapp/test/waitpointPresenter.connectedRunsBounded.test.ts @@ -64,6 +64,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({ import { heteroRunOpsPostgresTest } from "@internal/testcontainers"; import { PostgresRunStore, RoutingRunStore } from "@internal/run-store"; +import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; import type { PrismaClient } from "@trigger.dev/database"; import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { @@ -156,6 +157,7 @@ async function seedRun( ) { return (prisma as PrismaClient).taskRun.create({ data: { + id: `run_${generateRunOpsId()}`, friendlyId, taskIdentifier: "my-task", status: "PENDING", diff --git a/apps/webapp/test/waitpointPresenter.danglingConnectedRuns.test.ts b/apps/webapp/test/waitpointPresenter.danglingConnectedRuns.test.ts index db10bdff620..26c6c6125e5 100644 --- a/apps/webapp/test/waitpointPresenter.danglingConnectedRuns.test.ts +++ b/apps/webapp/test/waitpointPresenter.danglingConnectedRuns.test.ts @@ -58,6 +58,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({ import { heteroRunOpsPostgresTest } from "@internal/testcontainers"; import { PostgresRunStore, RoutingRunStore } from "@internal/run-store"; +import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; import type { PrismaClient } from "@trigger.dev/database"; import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { @@ -141,6 +142,7 @@ async function seedWaitpoint(prisma: RunOpsPrismaClient, ctx: SeedContext, frien async function seedRun(prisma: RunOpsPrismaClient, ctx: SeedContext, friendlyId: string) { return (prisma as unknown as PrismaClient).taskRun.create({ data: { + id: `run_${generateRunOpsId()}`, friendlyId, taskIdentifier: "my-task", status: "PENDING", diff --git a/apps/webapp/test/waitpointPresenter.dedicatedConnectedRuns.readthrough.test.ts b/apps/webapp/test/waitpointPresenter.dedicatedConnectedRuns.readthrough.test.ts index 7b1838d4729..990ad8f9796 100644 --- a/apps/webapp/test/waitpointPresenter.dedicatedConnectedRuns.readthrough.test.ts +++ b/apps/webapp/test/waitpointPresenter.dedicatedConnectedRuns.readthrough.test.ts @@ -59,6 +59,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({ import { heteroRunOpsPostgresTest } from "@internal/testcontainers"; import { PostgresRunStore, RoutingRunStore } from "@internal/run-store"; +import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; import type { PrismaClient } from "@trigger.dev/database"; import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server"; @@ -147,10 +148,12 @@ async function seedWaitpoint( async function seedRun( prisma: PrismaClient | RunOpsPrismaClient, ctx: SeedContext, - friendlyId: string + friendlyId: string, + id?: string ) { return (prisma as PrismaClient).taskRun.create({ data: { + ...(id ? { id } : {}), friendlyId, taskIdentifier: "my-task", status: "PENDING", @@ -216,7 +219,7 @@ describe("WaitpointPresenter against the REAL dedicated run-ops client", () => { const waitpoint = await seedWaitpoint(prisma14, ctx, "waitpoint_crossdb"); // The connected run + join live only on the NEW dedicated DB (co-resident with the run). - const run = await seedRun(prisma17, ctx, "run_crossnew"); + const run = await seedRun(prisma17, ctx, "run_crossnew", `run_${generateRunOpsId()}`); await prisma17.waitpointRunConnection.create({ data: { taskRunId: run.id, waitpointId: waitpoint.id }, }); diff --git a/apps/webapp/test/waitpointPresenter.splitConnectedRuns.test.ts b/apps/webapp/test/waitpointPresenter.splitConnectedRuns.test.ts index c6523ac3d47..351a7cb7fa7 100644 --- a/apps/webapp/test/waitpointPresenter.splitConnectedRuns.test.ts +++ b/apps/webapp/test/waitpointPresenter.splitConnectedRuns.test.ts @@ -62,6 +62,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({ import { heteroRunOpsPostgresTest } from "@internal/testcontainers"; import { PostgresRunStore, RoutingRunStore } from "@internal/run-store"; +import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; import type { PrismaClient } from "@trigger.dev/database"; import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { @@ -128,10 +129,12 @@ async function seedParents(prisma: PrismaClient, slug: string): Promise "k".repeat(20) + String(i).padStart(4, "0") + "01"; +const cuidId = (i: number) => "c".repeat(21) + String(i).padStart(4, "0"); + +async function seedShared(prisma: PrismaClient, suffix: string) { + await prisma.organization.create({ + data: { id: ORG_ID, title: `Route ${suffix}`, slug: `route-${suffix}` }, + }); + await prisma.project.create({ + data: { + id: PROJ_ID, + name: `Route ${suffix}`, + slug: `route-${suffix}`, + externalRef: `proj_route_${suffix}`, + organizationId: ORG_ID, + }, + }); + await prisma.runtimeEnvironment.create({ + data: { + id: ENV_ID, + type: "PRODUCTION", + slug: "prod", + projectId: PROJ_ID, + organizationId: ORG_ID, + apiKey: `tr_prod_${suffix}`, + pkApiKey: `pk_prod_${suffix}`, + shortcode: `short_${suffix}`, + }, + }); +} + +const BASE = new Date("2026-01-01T00:00:00.000Z").getTime(); + +async function seedRun(prisma: PrismaClient, id: string, offsetSec: number) { + await prisma.taskRun.create({ + data: { + id, + engine: "V2", + status: "COMPLETED_SUCCESSFULLY", + friendlyId: `run_${id}`, + runtimeEnvironmentId: ENV_ID, + environmentType: "PRODUCTION", + organizationId: ORG_ID, + projectId: PROJ_ID, + taskIdentifier: "route-task", + payload: "{}", + payloadType: "application/json", + traceId: `trace_${id}`, + spanId: `span_${id}`, + queue: "task/route", + isTest: false, + taskEventStore: "taskEvent", + depth: 0, + createdAt: new Date(BASE + offsetSec * 1000), + }, + }); +} + +describe("RoutingRunStore id-set residency routing", () => { + heteroPostgresTest( + "routes each id to its owning store and merges in orderBy order", + { timeout: 120000 }, + async ({ prisma14, prisma17 }) => { + for (let i = 0; i < 5; i++) { + expect(classifyResidency(newId(i))).toBe("NEW"); + expect(classifyResidency(cuidId(i))).toBe("LEGACY"); + } + + await seedShared(prisma14, "legacy"); + await seedShared(prisma17, "new"); + + for (let i = 0; i < 5; i++) { + await seedRun(prisma17, newId(i), i * 2 + 1); + await seedRun(prisma14, cuidId(i), i * 2); + } + + const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 }); + const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 }); + const router = new RoutingRunStore({ new: newStore, legacy: legacyStore }); + + const mixedIds = [0, 1, 2, 3, 4].flatMap((i) => [newId(i), cuidId(i)]); + const globalDesc = [ + newId(4), + cuidId(4), + newId(3), + cuidId(3), + newId(2), + cuidId(2), + newId(1), + cuidId(1), + newId(0), + cuidId(0), + ]; + + const all = (await router.findRuns({ + where: { id: { in: mixedIds } }, + orderBy: { createdAt: "desc" }, + take: 100, + })) as Array<{ id: string }>; + expect(all.map((r) => r.id)).toEqual(globalDesc); + + const top4 = (await router.findRuns({ + where: { id: { in: mixedIds } }, + orderBy: { createdAt: "desc" }, + take: 4, + })) as Array<{ id: string }>; + expect(top4.map((r) => r.id)).toEqual(globalDesc.slice(0, 4)); + + const newOnly = (await router.findRuns({ + where: { id: { in: [newId(0), newId(2), newId(4)] } }, + orderBy: { createdAt: "asc" }, + })) as Array<{ id: string }>; + expect(newOnly.map((r) => r.id)).toEqual([newId(0), newId(2), newId(4)]); + + const legacyOnly = (await router.findRuns({ + where: { id: { in: [cuidId(1), cuidId(3)] } }, + orderBy: { createdAt: "asc" }, + })) as Array<{ id: string }>; + expect(legacyOnly.map((r) => r.id)).toEqual([cuidId(1), cuidId(3)]); + } + ); +}); diff --git a/internal-packages/run-store/src/runOpsStore.mixedResidency.test.ts b/internal-packages/run-store/src/runOpsStore.mixedResidency.test.ts index ba87c75ff5e..cf21212016b 100644 --- a/internal-packages/run-store/src/runOpsStore.mixedResidency.test.ts +++ b/internal-packages/run-store/src/runOpsStore.mixedResidency.test.ts @@ -262,11 +262,8 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id } ); - // ── Case 1b: NEW-wins on id collision in #findRunsByIdSet ── - // The copy→fence window can leave the same id on both DBs. The id-set path queries NEW first; an id - // already found on NEW must NOT be re-fetched from LEGACY, so the NEW copy wins. heteroRunOpsPostgresTest( - "case 1b: findRuns by id-set with a colliding id resolves to the NEW copy", + "case 1b: findRuns by id-set routes a cuid id to LEGACY only, ignoring any NEW copy", async ({ prisma14, prisma17 }) => { const { router } = makeSplitRouter(prisma14, prisma17); const env = await seedSharedEnv(prisma14, "m1b"); @@ -304,8 +301,8 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id where: { id: { in: [collidingId] } }, select: { id: true, taskIdentifier: true }, }); - expect(rows).toHaveLength(1); // deduped, not double-reported - expect((rows[0] as any).taskIdentifier).toBe("new-copy-wins"); // NEW wins + expect(rows).toHaveLength(1); + expect((rows[0] as any).taskIdentifier).toBe("my-task"); } ); diff --git a/internal-packages/run-store/src/runOpsStore.test.ts b/internal-packages/run-store/src/runOpsStore.test.ts index 75f838deb34..a7c5adbbfb2 100644 --- a/internal-packages/run-store/src/runOpsStore.test.ts +++ b/internal-packages/run-store/src/runOpsStore.test.ts @@ -1004,10 +1004,8 @@ describe("RoutingRunStore.findRuns split-mode fan-out + drain", () => { } ); - // A run present on BOTH DBs (the copy->fence migration window) must be returned ONCE, - // and the NEW copy wins. heteroPostgresTest( - "id-set dedupes a run present on both DBs, preferring NEW", + "id-set routes a cuid id to its LEGACY owner and does not consult NEW", async ({ prisma14, prisma17 }) => { const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 }); const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 }); @@ -1031,7 +1029,7 @@ describe("RoutingRunStore.findRuns split-mode fan-out + drain", () => { select: { id: true, taskIdentifier: true }, })) as Array<{ id: string; taskIdentifier: string }>; expect(rows).toHaveLength(1); - expect(rows[0]!.taskIdentifier).toBe("from-new"); + expect(rows[0]!.taskIdentifier).toBe("from-legacy"); } ); diff --git a/internal-packages/run-store/src/runOpsStore.ts b/internal-packages/run-store/src/runOpsStore.ts index ba496980768..467df81df26 100644 --- a/internal-packages/run-store/src/runOpsStore.ts +++ b/internal-packages/run-store/src/runOpsStore.ts @@ -301,12 +301,12 @@ export class RoutingRunStore implements RunStore { }, client?: ReadClient ): Promise { - // SPLIT-mode fan-out across NEW + LEGACY. A `findRuns` `where` can span ids of mixed - // residency, so we resolve each owning store and merge, preserving orderBy/take/skip. - // The caller's client is never forwarded verbatim (it is the control-plane client); its - // presence routes each leg to that store's OWN primary (read-your-writes), else each store - // reads its own replica as before. NEW wins on id collisions (the copy->fence migration - // window) so a half-migrated run is never double-reported. + // SPLIT-mode routing across NEW + LEGACY. A bounded id set is routed per id to its owning + // store by residency (#findRunsByIdSet); an open predicate with no id to route on unions both + // stores and dedupes NEW-wins (#findRunsOpen). Either way orderBy/take/skip are re-imposed + // globally over the merged rows. The caller's client is never forwarded verbatim (it is the + // control-plane client); its presence routes each leg to that store's OWN primary + // (read-your-writes), else each store reads its own replica as before. return this.#findRunsRouted(args, client); } @@ -324,33 +324,27 @@ export class RoutingRunStore implements RunStore { return idList ? this.#findRunsByIdSet(args, idList, client) : this.#findRunsOpen(args, client); } - // Bounded id-set (the list hydrate + engine sweeps). Query NEW for the whole set first - // (it holds run-ops runs); probe LEGACY only for the ids NEW missed that could still live - // there (cuid). The two id sets are disjoint by construction, so the merge needs no dedupe. + // Bounded id-set (the list hydrate + engine sweeps). Residency is a total function of the id + // (classifyResidency), so route each id to its owning store and query each store only for its + // own ids, in parallel; never query NEW for a cuid or LEGACY for a run-ops id. The partitions + // are disjoint by construction, so the merge needs no dedupe. take/skip are never pushed per + // store (that would truncate a store's page before the merge knows membership); finalizeRows + // re-imposes orderBy/take/skip once, globally, over the merged rows. async #findRunsByIdSet( args: FindRunsArgs, ids: string[], client?: ReadClient ): Promise { const { args: selArgs, addedFields } = ensureProjected(args); - // The id set already bounds the per-store result, so never push take/skip down — doing - // so would truncate a store's page before the merge knows membership and mis-attribute - // rows. take/skip are applied once, globally, in finalizeRows. const fan = { ...selArgs, take: undefined, skip: undefined }; + const newIds = ids.filter((id) => this.#classifySafe(id) === "NEW"); + const legacyIds = ids.filter((id) => this.#classifySafe(id) !== "NEW"); const findNew = this.#findManyOn(this.#new, client); const findLegacy = this.#findManyOn(this.#legacy, client); - - const newRows = await findNew(fan); - const foundIds = new Set(newRows.map((r) => r.id as string)); - - const toLegacy: string[] = []; - for (const id of ids) { - if (foundIds.has(id)) continue; - if (this.#classifySafe(id) === "NEW") continue; // run-ops id: cannot live on LEGACY - toLegacy.push(id); - } - - const legacyRows = toLegacy.length > 0 ? await findLegacy(narrowToIds(fan, toLegacy)) : []; + const [newRows, legacyRows] = await Promise.all([ + newIds.length > 0 ? findNew(narrowToIds(fan, newIds)) : [], + legacyIds.length > 0 ? findLegacy(narrowToIds(fan, legacyIds)) : [], + ]); return finalizeRows([...newRows, ...legacyRows], args, addedFields); } From 3c822489400757bb030fc955a10c97582dd7252f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:59:16 +0100 Subject: [PATCH 26/28] chore(deps): bump tar to 7.5.19 (#4345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins `tar` to `7.5.19` via a root `pnpm.overrides` entry, replacing a stale range override (`tar@>=7 <7.5.11`) that no longer matched any installed copy. The single override collapses all resolved `tar` copies onto one version: - `packages/cli-v3` — direct dependency (was 7.5.13) - `@kubernetes/client-node` (apps/supervisor) — transitive (was 7.5.13) - `cacache` — transitive (was 6.2.1) - `giget` — transitive (was 6.2.1) No source changes; cli-v3's published `^7.5.13` spec already permits `7.5.19`, so no changeset is needed. --- package.json | 2 +- pnpm-lock.yaml | 84 +++++++++----------------------------------------- 2 files changed, 15 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index dddb2275380..3b00102ac83 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@ai-sdk/provider-utils@^4": "4.0.29", "express@^4>body-parser": "1.20.3", "@remix-run/dev@2.17.5>tar-fs": "2.1.4", - "tar@>=7 <7.5.11": "^7.5.11", + "tar": "7.5.19", "form-data@^2": "2.5.4", "form-data@^3": "3.0.5", "form-data@^4": "4.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd2d2d93fba..b09dc718a6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ overrides: '@ai-sdk/provider-utils@^4': 4.0.29 express@^4>body-parser: 1.20.3 '@remix-run/dev@2.17.5>tar-fs': 2.1.4 - tar@>=7 <7.5.11: ^7.5.11 + tar: 7.5.19 form-data@^2: 2.5.4 form-data@^3: 3.0.5 form-data@^4: 4.0.6 @@ -1625,8 +1625,8 @@ importers: specifier: ^10.0.0 version: 10.0.0 tar: - specifier: ^7.5.13 - version: 7.5.13 + specifier: 7.5.19 + version: 7.5.19 tiny-invariant: specifier: ^1.2.0 version: 1.3.1 @@ -3226,19 +3226,19 @@ packages: '@esbuild-kit/core-utils@3.0.0': resolution: {integrity: sha512-TXmwH9EFS3DC2sI2YJWJBgHGhlteK0Xyu1VabwetMULfm3oYhbrsWV5yaSr2NTWZIgDGVLHbRf0inxbjXqAcmQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.5.4': resolution: {integrity: sha512-afmtLf6uqxD5IgwCzomtqCYIgz/sjHzCWZFvfS5+FzeYxOURPUo4QcHtqJxbxWOMOogKriZanN/1bJQE/ZL93A==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.23.0': resolution: {integrity: sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==} @@ -8896,10 +8896,6 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -10576,10 +10572,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - fs-minipass@3.0.3: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -12212,22 +12204,10 @@ packages: resolution: {integrity: sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==} engines: {node: '>=8'} - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} - minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -12239,11 +12219,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -14526,13 +14501,8 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + tar@7.5.19: + resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} engines: {node: '>=18'} tdigest@0.1.2: @@ -18795,7 +18765,7 @@ snapshots: openid-client: 6.3.3 rfc4648: 1.5.3 stream-buffers: 3.0.2 - tar: 7.5.13 + tar: 7.5.19 tmp-promise: 3.0.3 tslib: 2.6.2 ws: 8.21.0(bufferutil@4.0.9) @@ -23713,7 +23683,7 @@ snapshots: minipass-pipeline: 1.2.4 p-map: 4.0.0 ssri: 10.0.5 - tar: 6.2.1 + tar: 7.5.19 unique-filename: 3.0.0 cacheable-request@6.1.0: @@ -23823,8 +23793,6 @@ snapshots: chownr@1.1.4: {} - chownr@2.0.0: {} - chownr@3.0.0: {} chrome-trace-event@1.0.4: {} @@ -25801,10 +25769,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - fs-minipass@3.0.3: dependencies: minipass: 7.1.3 @@ -25910,7 +25874,7 @@ snapshots: nypm: 0.3.9 ohash: 1.1.3 pathe: 1.1.2 - tar: 6.2.1 + tar: 7.5.19 giget@2.0.0: dependencies: @@ -27832,17 +27796,8 @@ snapshots: dependencies: yallist: 4.0.0 - minipass@5.0.0: {} - - minipass@7.1.2: {} - minipass@7.1.3: {} - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - minizlib@3.1.0: dependencies: minipass: 7.1.3 @@ -27851,8 +27806,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} ml-array-max@1.2.4: @@ -30524,20 +30477,11 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - tar@7.5.13: + tar@7.5.19: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 From 88ca0091a9c53fc1e220bcb34cf52ed471a6bcb7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 23 Jul 2026 11:51:34 +0100 Subject: [PATCH 27/28] fix(docker): stop the container entrypoint printing database connection strings in logs (#4346) ## Summary The container entrypoint runs under `set -x`, which echoes every command to the logs with its variables expanded. Several startup guards reference full database connection strings, so the DSN (including the password) was printed to the container logs on every boot. This turns tracing off around those lines so connection strings are never traced, while leaving migration behavior and ordinary startup logging unchanged. ## Fix The leaking lines are the `[ -n "$RUN_OPS_DATABASE_URL" ]` and `[ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]` guards, and the ClickHouse block (its `[ -n "$CLICKHOUSE_URL" ]` guard plus the lines that build `GOOSE_DBSTRING` from `CLICKHOUSE_URL`). `set -x` prints each of these with the credential expanded. Tracing is now disabled around each region and restored afterward, so non-secret tracing is preserved everywhere else. The existing legacy-migration subshell already protected its own command body; this adds the missing protection for the guards and the ClickHouse block. ```sh { set +x; } 2>/dev/null if [ -n "$RUN_OPS_DATABASE_URL" ]; then set -x ... ``` ## Verification Built the webapp image and ran it with dummy sentinel connection strings whose password token is `S3NTINEL_PW_DoNotLog`, then grepped the boot logs. Before (unmodified), the token appears in the traced guards: ``` + [ -n postgresql://user:S3NTINEL_PW_DoNotLog@fake-host:6432/run-ops ] + [ -n postgresql://user:S3NTINEL_PW_DoNotLog@fake-host:5432/legacy ] + [ -n https://default:S3NTINEL_PW_DoNotLog@fake-host:8443 ] ``` After, `grep S3NTINEL_PW_DoNotLog` on the same run returns nothing, and the normal "skipping ... migrations" lines still log. --- .../entrypoint-no-log-db-connection-strings.md | 6 ++++++ docker/scripts/entrypoint.sh | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 .server-changes/entrypoint-no-log-db-connection-strings.md diff --git a/.server-changes/entrypoint-no-log-db-connection-strings.md b/.server-changes/entrypoint-no-log-db-connection-strings.md new file mode 100644 index 00000000000..611408cf14c --- /dev/null +++ b/.server-changes/entrypoint-no-log-db-connection-strings.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs. diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index 1871755f985..1e5c7c7cab0 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -15,7 +15,9 @@ fi # Run-ops split: migrate the dedicated NEW run-ops database only when it is configured. Single-DB # installs never set the URL, so this is a no-op there. +{ set +x; } 2>/dev/null if [ -n "$RUN_OPS_DATABASE_URL" ]; then + set -x if [ "$SKIP_RUN_OPS_MIGRATIONS" != "1" ]; then echo "Running run-ops migrations" pnpm --filter @internal/run-ops-database db:migrate:deploy @@ -24,13 +26,16 @@ if [ -n "$RUN_OPS_DATABASE_URL" ]; then echo "SKIP_RUN_OPS_MIGRATIONS=1, skipping run-ops migrations." fi else + set -x echo "RUN_OPS_DATABASE_URL not set, skipping run-ops migrations." fi # Run-ops split: keep the legacy runs DB's schema current by applying the full @trigger.dev/database # migrations to it too, pointed at its direct (non-pooled) URL. Only runs when that URL is configured; # installs that never set it skip this entirely. +{ set +x; } 2>/dev/null if [ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]; then + set -x if [ "$SKIP_RUN_OPS_LEGACY_MIGRATIONS" != "1" ]; then echo "Running legacy run-ops migrations" # Subshell with tracing off so `set -x` does not print the DSN (with credentials) to the logs. @@ -40,6 +45,7 @@ if [ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]; then echo "SKIP_RUN_OPS_LEGACY_MIGRATIONS=1, skipping legacy run-ops migrations." fi else + set -x echo "RUN_OPS_LEGACY_DIRECT_URL not set, skipping legacy run-ops migrations." fi @@ -51,6 +57,7 @@ else echo "SKIP_DASHBOARD_AGENT_MIGRATIONS=1, skipping dashboard agent migrations." fi +{ set +x; } 2>/dev/null if [ -n "$CLICKHOUSE_URL" ] && [ "$SKIP_CLICKHOUSE_MIGRATIONS" != "1" ]; then # Run ClickHouse migrations echo "Running ClickHouse migrations..." @@ -76,6 +83,7 @@ elif [ "$SKIP_CLICKHOUSE_MIGRATIONS" = "1" ]; then else echo "CLICKHOUSE_URL not set, skipping ClickHouse migrations." fi +set -x # Copy over required prisma files cp internal-packages/database/prisma/schema.prisma apps/webapp/prisma/ From 722e240e4d74a72fd072bd7f6e1f2adbb6a3f36e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:18:58 +0100 Subject: [PATCH 28/28] feat(supervisor): add prometheus metric for outbound http requests (#4350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Prometheus metrics so the supervisor's outbound HTTP calls are observable - including client-side failures that previously only surfaced as a log line. - `supervisor_outbound_request_total{name, method, status, outcome}` - counts every outbound request. `outcome` separates a transport failure (`network_error`), an HTTP error response (`http_error`), a response that failed schema validation (`invalid_response`), and success (`ok`). - `supervisor_outbound_request_duration_seconds{name, outcome}` - latency histogram. Leaner labels than the counter (no `status`) to avoid bucket×label cardinality; buckets match the existing dequeue-latency histogram since these calls share the same retrying HTTP client and long-poll envelope. Coverage: - The warm-start request (a one-off `fetch`) - instrumented inline; the response status code is now also included in the failure log (it was previously dropped). - All worker API client calls (`SupervisorHttpClient`: dequeue, run attempt start/complete, heartbeats, snapshots, continue, suspend, debug-log, connect) - routed through a single instrumented `request()` helper that reports via an optional `onHttpRequestComplete` callback on the client, which the supervisor wires into the counter + histogram. Low cardinality by design: `name` is a **static per-endpoint label** (e.g. `dequeue`, `start_run_attempt`), never the interpolated URL - so no run/snapshot IDs land in labels, mirroring the templated `route` labels on the inbound HTTP server. Registered on the existing metrics registry, exposed on `/metrics` with no new wiring. Internal-only change (no package release needed), so the changelog note is a single `.server-changes` entry. --- .../supervisor-outbound-request-metrics.md | 6 ++ apps/supervisor/src/index.ts | 39 +++++++- .../src/v3/runEngineWorker/supervisor/http.ts | 88 ++++++++++++++++--- .../v3/runEngineWorker/supervisor/types.ts | 9 ++ 4 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 .server-changes/supervisor-outbound-request-metrics.md diff --git a/.server-changes/supervisor-outbound-request-metrics.md b/.server-changes/supervisor-outbound-request-metrics.md new file mode 100644 index 00000000000..c66a499b815 --- /dev/null +++ b/.server-changes/supervisor-outbound-request-metrics.md @@ -0,0 +1,6 @@ +--- +area: supervisor +type: improvement +--- + +Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor. diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index cf73be90eea..f30203df547 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -22,7 +22,7 @@ import { isKubernetesEnvironment, } from "@trigger.dev/core/v3/serverOnly"; import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js"; -import { collectDefaultMetrics, Gauge, Histogram } from "prom-client"; +import { collectDefaultMetrics, Counter, Gauge, Histogram } from "prom-client"; import { register } from "./metrics.js"; import { PodCleaner } from "./services/podCleaner.js"; import { FailedPodHandler } from "./services/failedPodHandler.js"; @@ -60,6 +60,21 @@ const workloadCreateDuration = new Histogram({ registers: [register], }); +const outboundRequestsTotal = new Counter({ + name: "supervisor_outbound_request_total", + help: "Count of outbound HTTP requests from the supervisor, by target name, method, response status, and outcome (ok, http_error, invalid_response, network_error).", + labelNames: ["name", "method", "status", "outcome"], + registers: [register], +}); + +const outboundRequestDuration = new Histogram({ + name: "supervisor_outbound_request_duration_seconds", + help: "Duration of outbound HTTP requests from the supervisor, by target name and outcome. Includes the HTTP client's internal retries and backoff.", + labelNames: ["name", "outcome"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 11, 12.5, 15, 20, 30, 60], + registers: [register], +}); + class ManagedSupervisor { private readonly workerSession: SupervisorSession; private readonly metricsServer?: HttpServer; @@ -322,6 +337,10 @@ class ManagedSupervisor { runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED, heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS, sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS, + onHttpRequestComplete: ({ name, method, status, outcome, durationMs }) => { + outboundRequestsTotal.inc({ name, method, status, outcome }); + outboundRequestDuration.observe({ name, outcome }, durationMs / 1000); + }, preDequeue: async () => { // Synchronous, hot-path-safe cached read; false when no monitors are active. const skipForBackpressure = this.backpressureMonitors.some((m) => m.shouldSkipDequeue()); @@ -692,6 +711,18 @@ class ManagedSupervisor { headers.traceparent = traceparent; } + const requestStart = performance.now(); + const record = ( + status: string, + outcome: "ok" | "http_error" | "invalid_response" | "network_error" + ) => { + outboundRequestsTotal.inc({ name: "warm_start", method: "POST", status, outcome }); + outboundRequestDuration.observe( + { name: "warm_start", outcome }, + (performance.now() - requestStart) / 1000 + ); + }; + try { const res = await fetch(warmStartUrlWithPath.href, { method: "POST", @@ -700,8 +731,10 @@ class ManagedSupervisor { }); if (!res.ok) { + record(String(res.status), "http_error"); this.logger.error("Warm start failed", { runId: dequeuedMessage.run.id, + statusCode: res.status, }); return false; } @@ -710,6 +743,7 @@ class ManagedSupervisor { const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data); if (!parsedData.success) { + record(String(res.status), "invalid_response"); this.logger.error("Warm start response invalid", { runId: dequeuedMessage.run.id, data, @@ -717,8 +751,11 @@ class ManagedSupervisor { return false; } + record(String(res.status), "ok"); + return parsedData.data.didWarmStart; } catch (error) { + record("none", "network_error"); this.logger.error("Warm start error", { runId: dequeuedMessage.run.id, error, diff --git a/packages/core/src/v3/runEngineWorker/supervisor/http.ts b/packages/core/src/v3/runEngineWorker/supervisor/http.ts index d6d51b1c2b2..450be432577 100644 --- a/packages/core/src/v3/runEngineWorker/supervisor/http.ts +++ b/packages/core/src/v3/runEngineWorker/supervisor/http.ts @@ -21,9 +21,9 @@ import { WorkerApiSuspendRunResponseBody, WorkerApiRunSnapshotsSinceResponseBody, } from "./schemas.js"; -import type { SupervisorClientCommonOptions } from "./types.js"; +import type { SupervisorClientCommonOptions, SupervisorHttpRequestMetric } from "./types.js"; import { getDefaultWorkerHeaders } from "./util.js"; -import { wrapZodFetch } from "../../zodfetch.js"; +import { wrapZodFetch, type ApiResult, type ZodFetchOptions } from "../../zodfetch.js"; import { createHeaders } from "../util.js"; import { WORKER_HEADERS } from "../consts.js"; import { SimpleStructuredLogger } from "../../utils/structuredLogger.js"; @@ -36,6 +36,7 @@ export class SupervisorHttpClient { private readonly instanceName: string; private readonly defaultHeaders: Record; private readonly sendRunDebugLogs: boolean; + private readonly onHttpRequestComplete?: (metric: SupervisorHttpRequestMetric) => void; private readonly logger = new SimpleStructuredLogger("supervisor-http-client"); @@ -45,6 +46,7 @@ export class SupervisorHttpClient { this.instanceName = opts.instanceName; this.defaultHeaders = getDefaultWorkerHeaders(opts); this.sendRunDebugLogs = opts.sendRunDebugLogs ?? false; + this.onHttpRequestComplete = opts.onHttpRequestComplete; if (!this.apiUrl) { throw new Error("apiURL is required and needs to be a non-empty string"); @@ -59,8 +61,55 @@ export class SupervisorHttpClient { } } + private async request( + name: string, + schema: T, + url: string, + requestInit?: RequestInit, + options?: ZodFetchOptions> + ): Promise>> { + const start = performance.now(); + const result = await wrapZodFetch(schema, url, requestInit, options); + + if (this.onHttpRequestComplete) { + const durationMs = performance.now() - start; + const method = requestInit?.method ?? "GET"; + + if (result.success) { + this.onHttpRequestComplete({ name, method, status: "2xx", outcome: "ok", durationMs }); + } else if (result.statusCode === 200) { + this.onHttpRequestComplete({ + name, + method, + status: "200", + outcome: "invalid_response", + durationMs, + }); + } else if (typeof result.statusCode === "number") { + this.onHttpRequestComplete({ + name, + method, + status: String(result.statusCode), + outcome: "http_error", + durationMs, + }); + } else { + this.onHttpRequestComplete({ + name, + method, + status: "none", + outcome: "network_error", + durationMs, + }); + } + } + + return result; + } + async connect(body: WorkerApiConnectRequestBody) { - return wrapZodFetch( + return this.request( + "connect", WorkerApiConnectResponseBody, `${this.apiUrl}/engine/v1/worker-actions/connect`, { @@ -75,7 +124,8 @@ export class SupervisorHttpClient { } async dequeue(body: WorkerApiDequeueRequestBody) { - return wrapZodFetch( + return this.request( + "dequeue", WorkerApiDequeueResponseBody, `${this.apiUrl}/engine/v1/worker-actions/dequeue`, { @@ -91,7 +141,8 @@ export class SupervisorHttpClient { /** @deprecated Not currently used */ async dequeueFromVersion(deploymentId: string, maxRunCount = 1, runnerId?: string) { - return wrapZodFetch( + return this.request( + "dequeue_from_version", WorkerApiDequeueResponseBody, `${this.apiUrl}/engine/v1/worker-actions/deployments/${deploymentId}/dequeue?maxRunCount=${maxRunCount}`, { @@ -104,7 +155,8 @@ export class SupervisorHttpClient { } async heartbeatWorker(body: WorkerApiHeartbeatRequestBody) { - return wrapZodFetch( + return this.request( + "heartbeat_worker", WorkerApiHeartbeatResponseBody, `${this.apiUrl}/engine/v1/worker-actions/heartbeat`, { @@ -124,7 +176,8 @@ export class SupervisorHttpClient { body: WorkerApiRunHeartbeatRequestBody, runnerId?: string ) { - return wrapZodFetch( + return this.request( + "heartbeat_run", WorkerApiRunHeartbeatResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/heartbeat`, { @@ -146,7 +199,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "start_run_attempt", WorkerApiRunAttemptStartResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/attempts/start`, { @@ -168,7 +222,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "complete_run_attempt", WorkerApiRunAttemptCompleteResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/attempts/complete`, { @@ -184,7 +239,8 @@ export class SupervisorHttpClient { } async getLatestSnapshot(runId: string, runnerId?: string, environmentId?: string) { - return wrapZodFetch( + return this.request( + "get_latest_snapshot", WorkerApiRunLatestSnapshotResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/latest`, { @@ -204,7 +260,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "get_snapshots_since", WorkerApiRunSnapshotsSinceResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/since/${snapshotId}`, { @@ -224,7 +281,8 @@ export class SupervisorHttpClient { } try { - const res = await wrapZodFetch( + const res = await this.request( + "send_debug_log", z.unknown(), `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/logs/debug`, { @@ -252,7 +310,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "continue_run_execution", WorkerApiContinueRunExecutionRequestBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/continue`, { @@ -292,7 +351,8 @@ export class SupervisorHttpClient { runnerId?: string; body: WorkerApiSuspendRunRequestBody; }) { - return wrapZodFetch( + return this.request( + "submit_suspend_completion", WorkerApiSuspendRunResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/suspend`, { diff --git a/packages/core/src/v3/runEngineWorker/supervisor/types.ts b/packages/core/src/v3/runEngineWorker/supervisor/types.ts index 4d0479cbcac..aff6ffa7d2d 100644 --- a/packages/core/src/v3/runEngineWorker/supervisor/types.ts +++ b/packages/core/src/v3/runEngineWorker/supervisor/types.ts @@ -1,5 +1,13 @@ import type { MachineResources } from "../../schemas/runEngine.js"; +export type SupervisorHttpRequestMetric = { + name: string; + method: string; + status: string; + outcome: "ok" | "http_error" | "invalid_response" | "network_error"; + durationMs: number; +}; + export type SupervisorClientCommonOptions = { apiUrl: string; workerToken: string; @@ -7,6 +15,7 @@ export type SupervisorClientCommonOptions = { deploymentId?: string; managedWorkerSecret?: string; sendRunDebugLogs?: boolean; + onHttpRequestComplete?: (metric: SupervisorHttpRequestMetric) => void; }; export type PreDequeueFn = () => Promise<{