From 73ea5865d8309b06ef999a4b9c0d7583c0b7311e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 15 Apr 2026 10:42:58 +0100 Subject: [PATCH 001/279] fix(run-engine): distinguish oneTimeUseToken P2002 from idempotency key collision (#3374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent retrying when retrying won’t actually do any good --- internal-packages/run-engine/src/engine/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 6757894fbbe..b3e85b7839f 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -46,7 +46,7 @@ import { RunQueue } from "../run-queue/index.js"; import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; import { AuthenticatedEnvironment, MinimalAuthenticatedEnvironment } from "../shared/index.js"; import { BillingCache } from "./billingCache.js"; -import { NotImplementedError, RunDuplicateIdempotencyKeyError } from "./errors.js"; +import { NotImplementedError, RunDuplicateIdempotencyKeyError, RunOneTimeUseTokenError } from "./errors.js"; import { EventBus, EventBusEvents } from "./eventBus.js"; import { RunLocker } from "./locking.js"; import { getFinalRunStatuses } from "./statuses.js"; @@ -703,15 +703,25 @@ export class RunEngine { }); if (error.code === "P2002") { - this.logger.debug("engine.trigger(): throwing RunDuplicateIdempotencyKeyError", { + const target = (error.meta as Record)?.target; + const targetFields = Array.isArray(target) ? target : []; + + this.logger.debug("engine.trigger(): P2002 unique constraint violation", { code: error.code, message: error.message, meta: error.meta, + target: targetFields, idempotencyKey, environmentId: environment.id, }); - //this happens if a unique constraint failed, i.e. duplicate idempotency + if (targetFields.includes("oneTimeUseToken")) { + throw new RunOneTimeUseTokenError( + `One-time use token has already been used` + ); + } + + // Only idempotency key collisions should be retried throw new RunDuplicateIdempotencyKeyError( `Run with idempotency key ${idempotencyKey} already exists` ); From 7d82041809a28b5480e50dc972236f935dc0ccaa Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:50:22 +0100 Subject: [PATCH 002/279] =?UTF-8?q?fix(security):=20upgrade=20Remix=20pack?= =?UTF-8?q?ages=202.1.0=20=E2=86=92=202.17.4=20(#3372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Upgrades all `@remix-run/*` packages in `apps/webapp` from **2.1.0 → 2.17.4** to address security vulnerabilities. Recreation of #2951 on a fresh checkout of `main`. **Updated packages (`apps/webapp/package.json`):** - `@remix-run/express`, `@remix-run/node`, `@remix-run/react`, `@remix-run/serve`, `@remix-run/server-runtime`: 2.1.0 → 2.17.4 - `@remix-run/router`: ^1.15.3 → ^1.23.2 - `@remix-run/dev`, `@remix-run/eslint-config`, `@remix-run/testing`: 2.1.0 → 2.17.4 **Root `package.json` overrides:** - `@remix-run/dev@2.17.4>tar-fs`: 2.1.3 → 2.1.4 - `testcontainers@10.28.0>tar-fs`: 3.0.9 → 3.1.1 **Documentation:** Updated Remix version references in `CLAUDE.md`, `apps/webapp/CLAUDE.md`, and `.cursor/rules/webapp.mdc`. **Server changes:** Added `.server-changes/upgrade-remix-security.md` for release tracking per `CONTRIBUTING.md`. No application code changes — only `package.json` files, documentation, a server-changes entry, and the regenerated `pnpm-lock.yaml`. ### Updates since last revision Addressed all 3 Devin Review findings: 1. **Missing `.server-changes/` file** — added `.server-changes/upgrade-remix-security.md` (commit ce22a0bd4) 2. **Sentry Remix patch (`@sentry/remix@9.46.0`)** — verified the patch at `patches/@sentry__remix@9.46.0.patch` applies cleanly against 2.17.4. The patch modifies Sentry's own `RemixInstrumentation` wrapper (removing `request.clone()` and form data attributes), not Remix internals. The underlying Remix APIs it hooks into (`callRouteAction`, `callRouteLoader`) are stable across 2.1→2.17. 3. **`remix-typedjson@0.3.1` compatibility** — peer deps declare `@remix-run/react: ^1.16.0 || ^2.0`, covering 2.17.4. Confirmed working at runtime across all 22 tested pages that use it (root.tsx, hooks, route loaders). ### Verification performed during this session - **Runtime:** Express+Remix integration, magic link login, client-side routing, MetaFunction rendering - **Operational:** hello-world task triggered via API, runs list, run detail, tasks page - **Comprehensive UI:** 22 pages, 11 filter types, environment/project switchers, interactive elements - **Docker:** Production Dockerfile (`docker/webapp/Dockerfile`) builds successfully - **Changelog audit:** All 16 minor versions reviewed — every breaking change is behind opt-in future flags the webapp doesn't enable ## Review & Testing Checklist for Human - [ ] **Verify auth flows in staging** — `remix-auth`, `remix-auth-email-link`, and `remix-auth-github` declare peer deps on `@remix-run/server-runtime@^1.x`, which is now 2.17.4. Login (magic link + OAuth) should be tested in a staging environment since local dev testing may not exercise all auth code paths. - [ ] **Verify tar-fs override versions** resolve the targeted security advisories (2.1.4 and 3.1.1) - [ ] **Review new transitive dependencies** added by the upgrade: `turbo-stream@2.4.1`, `undici@6.25.0`, `valibot@1.3.1`, `ws@7.5.10` Recommended test plan: deploy to staging and exercise core webapp flows — login (email magic link + GitHub OAuth), dashboard navigation, task triggering/viewing, and API endpoints — to catch runtime regressions not covered by local testing. ### Notes - Peer dependency warnings for `remix-auth-*` packages (expecting `@remix-run/server-runtime@^1.x`) were present in the original PR #2951 as well and appear to be pre-existing - The lockfile diff is large (~1200 lines) but mechanical — driven by the Remix version bump cascading through transitive dependencies - CI failures (`audit`, `units/internal/1-of-8`) are unrelated: `audit` is a `claude-code-action` bot permissions issue; the internal test failure is a ClickHouse testcontainers `Failed to connect to Reaper` flake Link to Devin session: https://app.devin.ai/sessions/d9fa9953b9bf40e5a8d12b8f5ba5b86b Requested by: @ericallam --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Eric Allam --- .cursor/rules/webapp.mdc | 2 +- .server-changes/upgrade-remix-security.md | 6 + CLAUDE.md | 2 +- apps/webapp/CLAUDE.md | 2 +- apps/webapp/package.json | 18 +- package.json | 6 +- pnpm-lock.yaml | 1034 +++++++++------------ 7 files changed, 441 insertions(+), 629 deletions(-) create mode 100644 .server-changes/upgrade-remix-security.md diff --git a/.cursor/rules/webapp.mdc b/.cursor/rules/webapp.mdc index a362f14fe12..f1333febdc0 100644 --- a/.cursor/rules/webapp.mdc +++ b/.cursor/rules/webapp.mdc @@ -4,7 +4,7 @@ globs: apps/webapp/**/*.tsx,apps/webapp/**/*.ts alwaysApply: false --- -The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.1.0 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo: +The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.17.4 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo: - `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma) - `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json) diff --git a/.server-changes/upgrade-remix-security.md b/.server-changes/upgrade-remix-security.md new file mode 100644 index 00000000000..cfb19bacf58 --- /dev/null +++ b/.server-changes/upgrade-remix-security.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Upgrade Remix packages from 2.1.0 to 2.17.4 to address security vulnerabilities in React Router diff --git a/CLAUDE.md b/CLAUDE.md index 0a54cced672..79d931a4548 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Superv ### Apps -- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server. +- **apps/webapp**: Remix 2.17.4 app - main API, dashboard, orchestration. Uses Express server. - **apps/supervisor**: Manages task execution containers (Docker/Kubernetes). ### Public Packages diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index b0f5e09b829..dff3ca4eb85 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -1,6 +1,6 @@ # Webapp -Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`). +Remix 2.17.4 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`). ## Verifying Changes diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 451d2785097..007c9f39350 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -104,12 +104,12 @@ "@react-aria/datepicker": "^3.9.1", "@react-stately/datepicker": "^3.9.1", "@react-types/datepicker": "^3.7.1", - "@remix-run/express": "2.1.0", - "@remix-run/node": "2.1.0", - "@remix-run/react": "2.1.0", - "@remix-run/router": "^1.15.3", - "@remix-run/serve": "2.1.0", - "@remix-run/server-runtime": "2.1.0", + "@remix-run/express": "2.17.4", + "@remix-run/node": "2.17.4", + "@remix-run/react": "2.17.4", + "@remix-run/router": "^1.23.2", + "@remix-run/serve": "2.17.4", + "@remix-run/server-runtime": "2.17.4", "@remix-run/v1-meta": "^0.1.3", "@s2-dev/streamstore": "^0.22.5", "@sentry/remix": "9.46.0", @@ -237,9 +237,9 @@ "@internal/clickhouse": "workspace:*", "@internal/replication": "workspace:*", "@internal/testcontainers": "workspace:*", - "@remix-run/dev": "2.1.0", - "@remix-run/eslint-config": "2.1.0", - "@remix-run/testing": "^2.1.0", + "@remix-run/dev": "2.17.4", + "@remix-run/eslint-config": "2.17.4", + "@remix-run/testing": "^2.17.4", "@sentry/cli": "2.50.2", "@swc/core": "^1.3.4", "@swc/helpers": "^0.4.11", diff --git a/package.json b/package.json index 6777bef721b..ce34f5bad27 100644 --- a/package.json +++ b/package.json @@ -89,8 +89,8 @@ "typescript": "5.5.4", "@types/node": "20.14.14", "express@^4>body-parser": "1.20.3", - "@remix-run/dev@2.1.0>tar-fs": "2.1.3", - "testcontainers@10.28.0>tar-fs": "3.0.9", + "@remix-run/dev@2.17.4>tar-fs": "2.1.4", + "testcontainers@10.28.0>tar-fs": "3.1.1", "form-data@^2": "2.5.4", "form-data@^3": "3.0.4", "form-data@^4": "4.0.4", @@ -120,4 +120,4 @@ "turbo" ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd453dd2c43..f3631a68b63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,8 @@ overrides: typescript: 5.5.4 '@types/node': 20.14.14 express@^4>body-parser: 1.20.3 - '@remix-run/dev@2.1.0>tar-fs': 2.1.3 - testcontainers@10.28.0>tar-fs: 3.0.9 + '@remix-run/dev@2.17.4>tar-fs': 2.1.4 + testcontainers@10.28.0>tar-fs: 3.1.1 form-data@^2: 2.5.4 form-data@^3: 3.0.4 form-data@^4: 4.0.4 @@ -456,32 +456,32 @@ importers: specifier: ^3.7.1 version: 3.7.1(react@18.2.0) '@remix-run/express': - specifier: 2.1.0 - version: 2.1.0(express@4.20.0)(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(express@4.20.0)(typescript@5.5.4) '@remix-run/node': - specifier: 2.1.0 - version: 2.1.0(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(typescript@5.5.4) '@remix-run/react': - specifier: 2.1.0 - version: 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) '@remix-run/router': - specifier: ^1.15.3 - version: 1.15.3 + specifier: ^1.23.2 + version: 1.23.2 '@remix-run/serve': - specifier: 2.1.0 - version: 2.1.0(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(typescript@5.5.4) '@remix-run/server-runtime': - specifier: 2.1.0 - version: 2.1.0(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(typescript@5.5.4) '@remix-run/v1-meta': specifier: ^0.1.3 - version: 0.1.3(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) + version: 0.1.3(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)) '@s2-dev/streamstore': specifier: ^0.22.5 version: 0.22.5(supports-color@10.0.0) '@sentry/remix': specifier: 9.46.0 - version: 9.46.0(patch_hash=146126b032581925294aaed63ab53ce3f5e0356a755f1763d7a9a76b9846943b)(@remix-run/node@2.1.0(typescript@5.5.4))(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(encoding@0.1.13)(react@18.2.0) + version: 9.46.0(patch_hash=146126b032581925294aaed63ab53ce3f5e0356a755f1763d7a9a76b9846943b)(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(encoding@0.1.13)(react@18.2.0) '@slack/web-api': specifier: 7.9.1 version: 7.9.1 @@ -751,22 +751,22 @@ importers: version: 2.0.1 remix-auth: specifier: ^3.6.0 - version: 3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) + version: 3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)) remix-auth-email-link: specifier: 2.0.2 - version: 2.0.2(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))) + version: 2.0.2(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))) remix-auth-github: specifier: ^1.6.0 - version: 1.6.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))) + version: 1.6.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))) remix-auth-google: specifier: ^2.0.0 - version: 2.0.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))) + version: 2.0.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))) remix-typedjson: specifier: 0.3.1 - version: 0.3.1(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(react@18.2.0) + version: 0.3.1(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(react@18.2.0) remix-utils: specifier: ^7.7.0 - version: 7.7.0(@remix-run/node@2.1.0(typescript@5.5.4))(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.15.3)(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76) + version: 7.7.0(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.23.2)(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76) seedrandom: specifier: ^3.0.5 version: 3.0.5 @@ -850,14 +850,14 @@ importers: specifier: workspace:* version: link:../../internal-packages/testcontainers '@remix-run/dev': - specifier: 2.1.0 - version: 2.1.0(@remix-run/serve@2.1.0(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(encoding@0.1.13)(lightningcss@1.29.2)(terser@5.44.1)(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/serve@2.17.4(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(lightningcss@1.29.2)(terser@5.44.1)(typescript@5.5.4)(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1)) '@remix-run/eslint-config': - specifier: 2.1.0 - version: 2.1.0(eslint@8.31.0)(react@18.2.0)(typescript@5.5.4) + specifier: 2.17.4 + version: 2.17.4(eslint@8.31.0)(react@18.2.0)(typescript@5.5.4) '@remix-run/testing': - specifier: ^2.1.0 - version: 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + specifier: ^2.17.4 + version: 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) '@sentry/cli': specifier: 2.50.2 version: 2.50.2(encoding@0.1.13) @@ -3722,10 +3722,6 @@ packages: resolution: {integrity: sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww==} engines: {node: '>=18.0.0'} - '@babel/code-frame@7.22.13': - resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -3745,10 +3741,6 @@ packages: '@babel/core': '>=7.11.0' eslint: ^7.5.0 || ^8.0.0 - '@babel/generator@7.22.15': - resolution: {integrity: sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.24.7': resolution: {integrity: sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==} engines: {node: '>=6.9.0'} @@ -3767,10 +3759,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-environment-visitor@7.22.20': - resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} - engines: {node: '>=6.9.0'} - '@babel/helper-environment-visitor@7.24.7': resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} engines: {node: '>=6.9.0'} @@ -3801,10 +3789,6 @@ packages: resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.22.5': - resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} - engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.24.0': resolution: {integrity: sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==} engines: {node: '>=6.9.0'} @@ -3823,10 +3807,6 @@ packages: resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==} engines: {node: '>=6.9.0'} - '@babel/helper-split-export-declaration@7.22.6': - resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} - engines: {node: '>=6.9.0'} - '@babel/helper-split-export-declaration@7.24.7': resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} @@ -3835,10 +3815,6 @@ packages: resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.25.9': - resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -3847,10 +3823,6 @@ packages: resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.25.9': - resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} @@ -3863,10 +3835,6 @@ packages: resolution: {integrity: sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==} engines: {node: '>=6.9.0'} - '@babel/highlight@7.22.13': - resolution: {integrity: sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==} - engines: {node: '>=6.9.0'} - '@babel/highlight@7.24.7': resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} engines: {node: '>=6.9.0'} @@ -3881,11 +3849,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.27.0': - resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.27.5': resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==} engines: {node: '>=6.0.0'} @@ -3973,10 +3936,6 @@ packages: resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.27.0': - resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.27.4': resolution: {integrity: sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==} engines: {node: '>=6.9.0'} @@ -3985,10 +3944,6 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/template@7.22.15': - resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} - engines: {node: '>=6.9.0'} - '@babel/template@7.24.7': resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} engines: {node: '>=6.9.0'} @@ -3997,18 +3952,10 @@ packages: resolution: {integrity: sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==} engines: {node: '>=6.9.0'} - '@babel/types@7.24.0': - resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} - engines: {node: '>=6.9.0'} - '@babel/types@7.24.7': resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} engines: {node: '>=6.9.0'} - '@babel/types@7.27.0': - resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} - engines: {node: '>=6.9.0'} - '@babel/types@7.27.3': resolution: {integrity: sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==} engines: {node: '>=6.9.0'} @@ -5846,18 +5793,10 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.2': - resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} - engines: {node: '>=6.0.0'} - '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} - '@jridgewell/resolve-uri@3.1.0': - resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} - engines: {node: '>=6.0.0'} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -5869,18 +5808,9 @@ packages: '@jridgewell/source-map@0.3.3': resolution: {integrity: sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.19': - resolution: {integrity: sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==} - - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -9278,23 +9208,29 @@ packages: '@remix-run/changelog-github@0.0.5': resolution: {integrity: sha512-43tqwUqWqirbv6D9uzo55ASPsCJ61Ein1k/M8qn+Qpros0MmbmuzjLVPmtaxfxfe2ANX0LefLvCD0pAgr1tp4g==} - '@remix-run/dev@2.1.0': - resolution: {integrity: sha512-Hn5lw46F+a48dp5uHKe68ckaHgdStW4+PmLod+LMFEqrMbkF0j4XD1ousebxlv989o0Uy/OLgfRMgMy4cBOvHg==} + '@remix-run/dev@2.17.4': + resolution: {integrity: sha512-El7r5W6ErX9KIy27+urbc4SIZnIlVDgTOUqzA7Zbv7caKYrsvgj/Z3i/LPy4VNfv0G1EdawPOrygJgIKT4r2FA==} engines: {node: '>=18.0.0'} hasBin: true peerDependencies: - '@remix-run/serve': ^2.1.0 + '@remix-run/react': ^2.17.0 + '@remix-run/serve': ^2.17.0 typescript: 5.5.4 + vite: ^5.1.0 || ^6.0.0 + wrangler: ^3.28.2 peerDependenciesMeta: '@remix-run/serve': optional: true typescript: optional: true + vite: + optional: true + wrangler: + optional: true - '@remix-run/eslint-config@2.1.0': - resolution: {integrity: sha512-yfeUnHpUG+XveujMi6QODKMGhs5CvKWCKzASU397BPXiPWbMv6r2acfODSWK64ZdBMu9hcLbOb42GBFydVQeHA==} + '@remix-run/eslint-config@2.17.4': + resolution: {integrity: sha512-Hhslms8Kl0fXHDS5UJWwyJ/1YQzJhRLjNZF+IfRLmCHI/zCvJP4dfy9yiT5BHnHb/m6MUz3l1L8EpPItg0dD5Q==} engines: {node: '>=18.0.0'} - deprecated: Will no longer be maintained in React Router v7 peerDependencies: eslint: ^8.0.0 react: ^18.0.0 @@ -9303,18 +9239,18 @@ packages: typescript: optional: true - '@remix-run/express@2.1.0': - resolution: {integrity: sha512-R5myPowQx6LYWY3+EqP42q19MOCT3+ZGwb2f0UKNs9a34R8U3nFpGWL7saXryC+To+EasujEScc8rTQw5Pftog==} + '@remix-run/express@2.17.4': + resolution: {integrity: sha512-4zZs0L7v2pvAq896zHRLNMhoOKIPXM9qnYdHLbz4mpZUMbNAgQacGazArIrUV3M4g0gRMY0dLrt5CqMNrlBeYg==} engines: {node: '>=18.0.0'} peerDependencies: - express: ^4.17.1 + express: ^4.20.0 typescript: 5.5.4 peerDependenciesMeta: typescript: optional: true - '@remix-run/node@2.1.0': - resolution: {integrity: sha512-TeSgjXnZUUlmw5FVpBVnXY7MLpracjdnwFNwoJE5NQkiUEFnGD/Yhvk4F2fOCkszqc2Z25KRclc5noweyiFu6Q==} + '@remix-run/node@2.17.4': + resolution: {integrity: sha512-9A29JaYiGHDEmaiQuD1IlO/TrQxnnkj98GpytihU+Nz6yTt6RwzzyMMqTAoasRd1dPD4OeSaSqbwkcim/eE76Q==} engines: {node: '>=18.0.0'} peerDependencies: typescript: 5.5.4 @@ -9322,8 +9258,8 @@ packages: typescript: optional: true - '@remix-run/react@2.1.0': - resolution: {integrity: sha512-DeYgfsvNxHqNn29sGA3XsZCciMKo2EFTQ9hHkuVPTsJXC4ipHr6Dja1j6UzZYPe/ZuKppiuTjueWCQlE2jOe1w==} + '@remix-run/react@2.17.4': + resolution: {integrity: sha512-MeXHacIBoohr9jzec5j/Rmk57xk34korkPDDb0OPHgkdvh20lO5fJoSAcnZfjTIOH+Vsq1ZRQlmvG5PRQ/64Sw==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.0.0 @@ -9333,21 +9269,17 @@ packages: typescript: optional: true - '@remix-run/router@1.10.0': - resolution: {integrity: sha512-Lm+fYpMfZoEucJ7cMxgt4dYt8jLfbpwRCzAjm9UgSLOkmlqo9gupxt6YX3DY0Fk155NT9l17d/ydi+964uS9Lw==} - engines: {node: '>=14.0.0'} - - '@remix-run/router@1.15.3': - resolution: {integrity: sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==} + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} - '@remix-run/serve@2.1.0': - resolution: {integrity: sha512-XHI+vPYz217qrg1QcV38TTPlEBTzMJzAt0SImPutyF0S2IBrZGZIFMEsspI0i0wNvdcdQz1IqmSx+mTghzW8eQ==} + '@remix-run/serve@2.17.4': + resolution: {integrity: sha512-c632agTDib70cytmxMVqSbBMlhFKawcg5048yZZK/qeP2AmUweM7OY6Ivgcmv/pgjLXYOu17UBKhtGU8T5y8cQ==} engines: {node: '>=18.0.0'} hasBin: true - '@remix-run/server-runtime@2.1.0': - resolution: {integrity: sha512-Uz69yF4Gu6F3VYQub3JgDo9godN8eDMeZclkadBTAWN7bYLonu0ChR/GlFxS35OLeF7BDgudxOSZob0nE1WHNg==} + '@remix-run/server-runtime@2.17.4': + resolution: {integrity: sha512-oCsFbPuISgh8KpPKsfBChzjcntvTz5L+ggq9VNYWX8RX3yA7OgQpKspRHOSxb05bw7m0Hx+L1KRHXjf3juKX8w==} engines: {node: '>=18.0.0'} peerDependencies: typescript: 5.5.4 @@ -9355,8 +9287,8 @@ packages: typescript: optional: true - '@remix-run/testing@2.1.0': - resolution: {integrity: sha512-eLPx4Bmjt243kyRpQTong1eFo6nkvSfCr65bb5PfoF172DKnsSSCYWAmBmB72VwtAPESHxBm3g6AUbhwphkU6A==} + '@remix-run/testing@2.17.4': + resolution: {integrity: sha512-x+fkFH5RDJzqQcx60gbabYg0lgwSywkuE7svzmlZIbqUbzwiHbvUgvDGD0P6gR4+ZY2Zynvsb0puglosK6gW2g==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.0.0 @@ -9374,8 +9306,8 @@ packages: '@remix-run/web-blob@3.1.0': resolution: {integrity: sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==} - '@remix-run/web-fetch@4.4.1': - resolution: {integrity: sha512-xMceEGn2kvfeWS91nHSOhEQHPGgjFnmDVpWFZrbWPVdiTByMZIn421/tdSF6Kd1RsNsY+5Iwt3JFEKZHAcMQHw==} + '@remix-run/web-fetch@4.4.2': + resolution: {integrity: sha512-jgKfzA713/4kAW/oZ4bC3MoLWyjModOVDjFPNseVqcJKSafgIscrYL9G50SurEYLswPuoU3HzSbO0jQCMYWHhA==} engines: {node: ^10.17 || >=12.3} '@remix-run/web-file@3.1.0': @@ -11696,10 +11628,6 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-includes@3.1.6: - resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} - engines: {node: '>= 0.4'} - array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -11720,10 +11648,6 @@ packages: resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} engines: {node: '>= 0.4'} - array.prototype.flatmap@1.3.1: - resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} - engines: {node: '>= 0.4'} - array.prototype.flatmap@1.3.2: resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} engines: {node: '>= 0.4'} @@ -12406,6 +12330,10 @@ packages: resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} engines: {node: '>= 0.8.0'} + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + compute-cosine-similarity@1.1.0: resolution: {integrity: sha512-FXhNx0ILLjGi9Z9+lglLzM12+0uoTnYkHm7GiadXDAr0HGVLm25OivUS1B/LPkbzzvlcXz/1EvWg9ZYyJSdhTw==} @@ -12449,10 +12377,6 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.2.0: - resolution: {integrity: sha512-R0BOPfLGTitaKhgKROKZQN6iyq2iDQcH1DOF8nJoaWapguX5bC2w+Q/I9NmmM5lfcvEarnLZr+cCvmEYYSXvYA==} - engines: {node: '>=6.6.0'} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -12469,6 +12393,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} @@ -13224,9 +13152,6 @@ packages: electron-to-chromium@1.5.252: resolution: {integrity: sha512-53uTpjtRgS7gjIxZ4qCgFdNO2q+wJt/Z8+xAvxbCqXPJrY6h7ighUkadQmNMXH96crtpa6gPFNP7BF4UBGDuaA==} - electron-to-chromium@1.5.98: - resolution: {integrity: sha512-bI/LbtRBxU2GzK7KK5xxFd2y9Lf9XguHooPYbcXWy6wUoT8NMnffsvRhPmSeUHLSDKAEtKuTaEtK4Ms15zkIEA==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -13265,10 +13190,6 @@ packages: resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.15.0: - resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -13321,10 +13242,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.0.0: - resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} - engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -13333,17 +13250,10 @@ packages: resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.0.3: - resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} - engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.0.0: - resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} - es-shim-unscopables@1.0.2: resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} @@ -13571,27 +13481,6 @@ packages: eslint: '*' eslint-plugin-import: '*' - eslint-module-utils@2.7.4: - resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - eslint-module-utils@2.8.1: resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} engines: {node: '>=4'} @@ -14251,10 +14140,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.2.4: - resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} - engines: {node: '>= 0.4'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -16161,10 +16046,6 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} - minizlib@3.0.1: - resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} - engines: {node: '>= 18'} - minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -16223,6 +16104,10 @@ packages: resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} engines: {node: '>= 0.8.0'} + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -16305,6 +16190,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -16474,9 +16363,6 @@ packages: node-releases@2.0.12: resolution: {integrity: sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -16602,10 +16488,6 @@ packages: resolution: {integrity: sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==} engines: {node: '>= 0.4'} - object.fromentries@2.0.6: - resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==} - engines: {node: '>= 0.4'} - object.fromentries@2.0.8: resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} @@ -16617,10 +16499,6 @@ packages: object.hasown@1.1.2: resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==} - object.values@1.1.6: - resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} - engines: {node: '>= 0.4'} - object.values@1.2.0: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} @@ -16658,6 +16536,10 @@ packages: resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -17826,15 +17708,15 @@ packages: react: '>= 16.3' react-dom: '>= 16.3' - react-router-dom@6.17.0: - resolution: {integrity: sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ==} + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' - react-router@6.17.0: - resolution: {integrity: sha512-YJR3OTJzi3zhqeJYADHANCGPUu9J+6fT5GLv82UWRGSxu6oJYCKVmxUcaBQuGm9udpWmPsvpme/CdHumqgsoaA==} + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' @@ -18394,11 +18276,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -19051,10 +18928,6 @@ packages: tailwindcss@4.0.17: resolution: {integrity: sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==} - tapable@2.2.1: - resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} - engines: {node: '>=6'} - tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -19062,12 +18935,15 @@ packages: tar-fs@2.1.3: resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} - tar-fs@3.0.9: - resolution: {integrity: sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} tar-fs@3.1.0: resolution: {integrity: sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==} + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -19482,6 +19358,9 @@ packages: cpu: [arm64] os: [linux] + turbo-stream@2.4.1: + resolution: {integrity: sha512-v8kOJXpG3WoTN/+at8vK7erSzo6nW6CIaeOvNOkHQVDajfz1ZVeSxCbc6tOH4hrGZW7VUCV0TOXd8CPzYnYkrw==} + turbo-windows-64@1.10.3: resolution: {integrity: sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw==} cpu: [x64] @@ -19611,6 +19490,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -19709,12 +19592,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - update-browserslist-db@1.1.2: - resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.1.4: resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} hasBin: true @@ -19834,6 +19711,14 @@ packages: typescript: optional: true + valibot@1.3.1: + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + peerDependencies: + typescript: 5.5.4 + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -20131,6 +20016,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@7.5.9: resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} engines: {node: '>=8.3.0'} @@ -20568,7 +20465,7 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@andrewbranch/untar.js@1.0.3': {} @@ -22507,11 +22404,6 @@ snapshots: '@aws/lambda-invoke-store@0.2.1': {} - '@babel/code-frame@7.22.13': - dependencies: - '@babel/highlight': 7.22.13 - chalk: 2.4.2 - '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -22522,17 +22414,17 @@ snapshots: '@babel/core@7.22.17': dependencies: '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.22.13 - '@babel/generator': 7.22.15 + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.24.7 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-module-transforms': 7.22.17(@babel/core@7.22.17) '@babel/helpers': 7.22.15 - '@babel/parser': 7.24.7 - '@babel/template': 7.22.15 + '@babel/parser': 7.27.5 + '@babel/template': 7.24.7 '@babel/traverse': 7.24.7 - '@babel/types': 7.24.0 + '@babel/types': 7.27.3 convert-source-map: 1.9.0 - debug: 4.4.1 + debug: 4.4.3(supports-color@10.0.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -22547,29 +22439,22 @@ snapshots: eslint-visitor-keys: 2.1.0 semver: 6.3.1 - '@babel/generator@7.22.15': - dependencies: - '@babel/types': 7.24.0 - '@jridgewell/gen-mapping': 0.3.2 - '@jridgewell/trace-mapping': 0.3.19 - jsesc: 2.5.2 - '@babel/generator@7.24.7': dependencies: '@babel/types': 7.27.3 '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 2.5.2 '@babel/helper-annotate-as-pure@7.22.5': dependencies: - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 '@babel/helper-compilation-targets@7.22.15': dependencies: '@babel/compat-data': 7.22.9 '@babel/helper-validator-option': 7.22.15 - browserslist: 4.24.4 + browserslist: 4.28.0 lru-cache: 5.1.1 semver: 6.3.1 @@ -22586,8 +22471,6 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 semver: 6.3.1 - '@babel/helper-environment-visitor@7.22.20': {} - '@babel/helper-environment-visitor@7.24.7': dependencies: '@babel/types': 7.27.3 @@ -22607,23 +22490,21 @@ snapshots: '@babel/helper-module-imports@7.22.15': dependencies: - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 '@babel/helper-module-transforms@7.22.17(@babel/core@7.22.17)': dependencies: '@babel/core': 7.22.17 - '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-module-imports': 7.22.15 '@babel/helper-simple-access': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.25.9 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/helper-validator-identifier': 7.27.1 '@babel/helper-optimise-call-expression@7.22.5': dependencies: '@babel/types': 7.27.3 - '@babel/helper-plugin-utils@7.22.5': {} - '@babel/helper-plugin-utils@7.24.0': {} '@babel/helper-replace-supers@7.22.20(@babel/core@7.22.17)': @@ -22635,48 +22516,34 @@ snapshots: '@babel/helper-simple-access@7.22.5': dependencies: - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 '@babel/helper-skip-transparent-expression-wrappers@7.22.5': dependencies: '@babel/types': 7.27.3 - '@babel/helper-split-export-declaration@7.22.6': - dependencies: - '@babel/types': 7.27.0 - '@babel/helper-split-export-declaration@7.24.7': dependencies: '@babel/types': 7.27.3 '@babel/helper-string-parser@7.24.7': {} - '@babel/helper-string-parser@7.25.9': {} - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.24.7': {} - '@babel/helper-validator-identifier@7.25.9': {} - '@babel/helper-validator-identifier@7.27.1': {} '@babel/helper-validator-option@7.22.15': {} '@babel/helpers@7.22.15': dependencies: - '@babel/template': 7.22.15 + '@babel/template': 7.24.7 '@babel/traverse': 7.24.7 - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 transitivePeerDependencies: - supports-color - '@babel/highlight@7.22.13': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - chalk: 2.4.2 - js-tokens: 4.0.0 - '@babel/highlight@7.24.7': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -22686,16 +22553,12 @@ snapshots: '@babel/parser@7.24.1': dependencies: - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 '@babel/parser@7.24.7': dependencies: '@babel/types': 7.24.7 - '@babel/parser@7.27.0': - dependencies: - '@babel/types': 7.27.0 - '@babel/parser@7.27.5': dependencies: '@babel/types': 7.27.3 @@ -22725,7 +22588,7 @@ snapshots: '@babel/plugin-transform-react-display-name@7.18.6(@babel/core@7.22.17)': dependencies: '@babel/core': 7.22.17 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.24.0 '@babel/plugin-transform-react-jsx-development@7.18.6(@babel/core@7.22.17)': dependencies: @@ -22739,7 +22602,7 @@ snapshots: '@babel/helper-module-imports': 7.22.15 '@babel/helper-plugin-utils': 7.24.0 '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.17) - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 '@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.22.17)': dependencies: @@ -22790,20 +22653,10 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@babel/runtime@7.27.0': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.27.4': {} '@babel/runtime@7.28.4': {} - '@babel/template@7.22.15': - dependencies: - '@babel/code-frame': 7.22.13 - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 - '@babel/template@7.24.7': dependencies: '@babel/code-frame': 7.24.7 @@ -22820,28 +22673,17 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.27.5 '@babel/types': 7.27.3 - debug: 4.4.1 + debug: 4.4.3(supports-color@10.0.0) globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/types@7.24.0': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - to-fast-properties: 2.0.0 - '@babel/types@7.24.7': dependencies: '@babel/helper-string-parser': 7.24.7 '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 - '@babel/types@7.27.0': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/types@7.27.3': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -22865,7 +22707,7 @@ snapshots: '@changesets/apply-release-plan@6.1.4': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/config': 2.3.1 '@changesets/get-version-range-type': 0.3.2 '@changesets/git': 2.0.0 @@ -22881,7 +22723,7 @@ snapshots: '@changesets/assemble-release-plan@5.2.4(patch_hash=a7a12643e8d89a00d5322f750c292e8567b5ee0fc9c613a238a1184c25533e4b)': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/errors': 0.1.4 '@changesets/get-dependents-graph': 1.3.6 '@changesets/types': 5.2.1 @@ -22959,7 +22801,7 @@ snapshots: '@changesets/get-release-plan@3.0.17': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/assemble-release-plan': 5.2.4(patch_hash=a7a12643e8d89a00d5322f750c292e8567b5ee0fc9c613a238a1184c25533e4b) '@changesets/config': 2.3.1 '@changesets/pre': 1.0.14 @@ -22971,7 +22813,7 @@ snapshots: '@changesets/git@2.0.0': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/errors': 0.1.4 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 @@ -22990,7 +22832,7 @@ snapshots: '@changesets/pre@1.0.14': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/errors': 0.1.4 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 @@ -22998,7 +22840,7 @@ snapshots: '@changesets/read@0.5.9': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/git': 2.0.0 '@changesets/logger': 0.0.5 '@changesets/parse': 0.3.16 @@ -23013,7 +22855,7 @@ snapshots: '@changesets/write@0.2.3': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/types': 5.2.1 fs-extra: 7.0.1 human-id: 1.0.2 @@ -23269,7 +23111,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -23875,7 +23717,7 @@ snapshots: dependencies: duplexify: 4.1.3 fastify-plugin: 5.0.1 - ws: 8.18.0(bufferutil@4.0.9) + ws: 8.18.3(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -24024,7 +23866,7 @@ snapshots: dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) ws: 8.18.3(bufferutil@4.0.9) @@ -24282,19 +24124,11 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@jridgewell/gen-mapping@0.3.2': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/resolve-uri@3.1.0': {} + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} @@ -24305,20 +24139,8 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.19': - dependencies: - '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -24469,7 +24291,7 @@ snapshots: '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -25820,17 +25642,17 @@ snapshots: '@radix-ui/number@1.0.0': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/number@1.0.1': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/number@1.1.0': {} '@radix-ui/primitive@1.0.0': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/primitive@1.0.1': dependencies: @@ -25936,7 +25758,7 @@ snapshots: '@radix-ui/react-collection@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) '@radix-ui/react-context': 1.0.0(react@18.2.0) '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -25946,7 +25768,7 @@ snapshots: '@radix-ui/react-collection@1.0.2(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-context': 1.0.0(react@18.3.1) '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0(react@18.3.1))(react@18.3.1) @@ -25956,7 +25778,7 @@ snapshots: '@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -25969,7 +25791,7 @@ snapshots: '@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) @@ -25994,12 +25816,12 @@ snapshots: '@radix-ui/react-compose-refs@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.3.1 '@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.69)(react@18.2.0)': @@ -26055,12 +25877,12 @@ snapshots: '@radix-ui/react-context@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-context@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.3.1 '@radix-ui/react-context@1.0.1(@types/react@18.2.69)(react@18.2.0)': @@ -26172,24 +25994,24 @@ snapshots: '@radix-ui/react-direction@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-direction@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.3.1 '@radix-ui/react-direction@1.0.1(@types/react@18.2.69)(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 optionalDependencies: '@types/react': 18.2.69 '@radix-ui/react-direction@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.3.1 optionalDependencies: '@types/react': 18.2.69 @@ -26208,7 +26030,7 @@ snapshots: '@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -26275,7 +26097,7 @@ snapshots: '@radix-ui/react-focus-guards@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.69)(react@18.2.0)': @@ -26301,7 +26123,7 @@ snapshots: '@radix-ui/react-focus-scope@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0) @@ -26350,13 +26172,13 @@ snapshots: '@radix-ui/react-id@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0) react: 18.2.0 '@radix-ui/react-id@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) react: 18.3.1 @@ -26447,7 +26269,7 @@ snapshots: '@radix-ui/react-popper@1.1.1(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@floating-ui/react-dom': 0.7.2(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-arrow': 1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) @@ -26503,7 +26325,7 @@ snapshots: '@radix-ui/react-portal@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -26560,7 +26382,7 @@ snapshots: '@radix-ui/react-presence@1.0.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) '@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0) react: 18.2.0 @@ -26568,7 +26390,7 @@ snapshots: '@radix-ui/react-presence@1.0.0(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) react: 18.3.1 @@ -26576,7 +26398,7 @@ snapshots: '@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.2.0) react: 18.2.0 @@ -26587,7 +26409,7 @@ snapshots: '@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 @@ -26598,7 +26420,7 @@ snapshots: '@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) react: 18.3.1 @@ -26629,14 +26451,14 @@ snapshots: '@radix-ui/react-primitive@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-slot': 1.0.1(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) '@radix-ui/react-primitive@1.0.2(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-slot': 1.0.1(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) @@ -26768,7 +26590,7 @@ snapshots: '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.2.0) @@ -26786,7 +26608,7 @@ snapshots: '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) @@ -26870,13 +26692,13 @@ snapshots: '@radix-ui/react-slot@1.0.1(react@18.2.0)': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) react: 18.2.0 '@radix-ui/react-slot@1.0.1(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) react: 18.3.1 @@ -27047,12 +26869,12 @@ snapshots: '@radix-ui/react-use-callback-ref@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.3.1 '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.69)(react@18.2.0)': @@ -27090,19 +26912,19 @@ snapshots: '@radix-ui/react-use-controllable-state@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0) react: 18.2.0 '@radix-ui/react-use-controllable-state@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) react: 18.3.1 '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.69)(react@18.2.0)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.2.0) react: 18.2.0 optionalDependencies: @@ -27110,7 +26932,7 @@ snapshots: '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 optionalDependencies: @@ -27118,7 +26940,7 @@ snapshots: '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.1)(react@18.3.1) react: 18.3.1 optionalDependencies: @@ -27171,31 +26993,31 @@ snapshots: '@radix-ui/react-use-layout-effect@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-use-layout-effect@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.3.1 '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.69)(react@18.2.0)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.2.0 optionalDependencies: '@types/react': 18.2.69 '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.3.1 optionalDependencies: '@types/react': 18.2.69 '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.28.4 react: 18.3.1 optionalDependencies: '@types/react': 18.3.1 @@ -27220,12 +27042,12 @@ snapshots: '@radix-ui/react-use-previous@1.0.0(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 '@radix-ui/react-use-previous@1.0.1(@types/react@18.2.69)(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 react: 18.2.0 optionalDependencies: '@types/react': 18.2.69 @@ -27252,7 +27074,7 @@ snapshots: '@radix-ui/react-use-size@1.0.1(@types/react@18.2.69)(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.2.0) react: 18.2.0 optionalDependencies: @@ -27260,7 +27082,7 @@ snapshots: '@radix-ui/react-use-size@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 optionalDependencies: @@ -27268,7 +27090,7 @@ snapshots: '@radix-ui/react-visually-hidden@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.26.7 + '@babel/runtime': 7.28.4 '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -29200,7 +29022,7 @@ snapshots: transitivePeerDependencies: - encoding - '@remix-run/dev@2.1.0(@remix-run/serve@2.1.0(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(encoding@0.1.13)(lightningcss@1.29.2)(terser@5.44.1)(typescript@5.5.4)': + '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/serve@2.17.4(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(lightningcss@1.29.2)(terser@5.44.1)(typescript@5.5.4)(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': dependencies: '@babel/core': 7.22.17 '@babel/generator': 7.24.7 @@ -29209,16 +29031,22 @@ snapshots: '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.17) '@babel/preset-typescript': 7.21.5(@babel/core@7.22.17) '@babel/traverse': 7.24.7 + '@babel/types': 7.27.3 '@mdx-js/mdx': 2.3.0 '@npmcli/package-json': 4.0.1 - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/node': 2.17.4(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/router': 1.23.2 + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) '@types/mdx': 2.0.5 '@vanilla-extract/integration': 6.2.1(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) arg: 5.0.2 cacache: 17.1.4 chalk: 4.1.2 chokidar: 3.6.0 + cross-spawn: 7.0.6 dotenv: 16.4.7 + es-module-lexer: 1.7.0 esbuild: 0.17.6 esbuild-plugins-node-modules-polyfill: 1.6.1(esbuild@0.17.6) execa: 5.1.1 @@ -29232,35 +29060,39 @@ snapshots: lodash: 4.17.23 lodash.debounce: 4.0.8 minimatch: 9.0.5 - node-fetch: 2.6.12(encoding@0.1.13) ora: 5.4.1 + pathe: 1.1.2 picocolors: 1.1.1 picomatch: 2.3.1 pidtree: 0.6.0 - postcss: 8.5.4 - postcss-discard-duplicates: 5.1.0(postcss@8.5.4) - postcss-load-config: 4.0.2(postcss@8.5.4) - postcss-modules: 6.0.0(postcss@8.5.4) + postcss: 8.5.6 + postcss-discard-duplicates: 5.1.0(postcss@8.5.6) + postcss-load-config: 4.0.2(postcss@8.5.6) + postcss-modules: 6.0.0(postcss@8.5.6) prettier: 2.8.8 pretty-ms: 7.0.1 react-refresh: 0.14.0 remark-frontmatter: 4.0.1 remark-mdx-frontmatter: 1.1.1 - semver: 7.7.2 - tar-fs: 2.1.3 + semver: 7.7.3 + set-cookie-parser: 2.6.0 + tar-fs: 2.1.4 tsconfig-paths: 4.2.0 - ws: 7.5.9(bufferutil@4.0.9) + valibot: 1.3.1(typescript@5.5.4) + vite-node: 3.1.4(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) + ws: 7.5.10(bufferutil@4.0.9) optionalDependencies: - '@remix-run/serve': 2.1.0(typescript@5.5.4) + '@remix-run/serve': 2.17.4(typescript@5.5.4) typescript: 5.5.4 + vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) transitivePeerDependencies: - '@types/node' - bluebird - bufferutil - - encoding - less - lightningcss - sass + - sass-embedded - stylus - sugarss - supports-color @@ -29268,7 +29100,7 @@ snapshots: - ts-node - utf-8-validate - '@remix-run/eslint-config@2.1.0(eslint@8.31.0)(react@18.2.0)(typescript@5.5.4)': + '@remix-run/eslint-config@2.17.4(eslint@8.31.0)(react@18.2.0)(typescript@5.5.4)': dependencies: '@babel/core': 7.22.17 '@babel/eslint-parser': 7.21.8(@babel/core@7.22.17)(eslint@8.31.0) @@ -29295,88 +29127,88 @@ snapshots: - jest - supports-color - '@remix-run/express@2.1.0(express@4.20.0)(typescript@5.5.4)': + '@remix-run/express@2.17.4(express@4.20.0)(typescript@5.5.4)': dependencies: - '@remix-run/node': 2.1.0(typescript@5.5.4) + '@remix-run/node': 2.17.4(typescript@5.5.4) express: 4.20.0 optionalDependencies: typescript: 5.5.4 - '@remix-run/node@2.1.0(typescript@5.5.4)': + '@remix-run/node@2.17.4(typescript@5.5.4)': dependencies: - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) - '@remix-run/web-fetch': 4.4.1 - '@remix-run/web-file': 3.1.0 - '@remix-run/web-stream': 1.1.0 + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) + '@remix-run/web-fetch': 4.4.2 '@web3-storage/multipart-parser': 1.0.0 - cookie-signature: 1.2.0 + cookie-signature: 1.2.2 source-map-support: 0.5.21 stream-slice: 0.1.2 + undici: 6.25.0 optionalDependencies: typescript: 5.5.4 - '@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4)': + '@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4)': dependencies: - '@remix-run/router': 1.10.0 - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/router': 1.23.2 + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-router-dom: 6.17.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-router: 6.30.3(react@18.2.0) + react-router-dom: 6.30.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + turbo-stream: 2.4.1 optionalDependencies: typescript: 5.5.4 - '@remix-run/router@1.10.0': {} + '@remix-run/router@1.23.2': {} - '@remix-run/router@1.15.3': {} - - '@remix-run/serve@2.1.0(typescript@5.5.4)': + '@remix-run/serve@2.17.4(typescript@5.5.4)': dependencies: - '@remix-run/express': 2.1.0(express@4.20.0)(typescript@5.5.4) - '@remix-run/node': 2.1.0(typescript@5.5.4) + '@remix-run/express': 2.17.4(express@4.20.0)(typescript@5.5.4) + '@remix-run/node': 2.17.4(typescript@5.5.4) chokidar: 3.6.0 - compression: 1.7.4 + compression: 1.8.1 express: 4.20.0 get-port: 5.1.1 - morgan: 1.10.0 + morgan: 1.10.1 source-map-support: 0.5.21 transitivePeerDependencies: - supports-color - typescript - '@remix-run/server-runtime@2.1.0(typescript@5.5.4)': + '@remix-run/server-runtime@2.17.4(typescript@5.5.4)': dependencies: - '@remix-run/router': 1.10.0 - '@types/cookie': 0.4.1 + '@remix-run/router': 1.23.2 + '@types/cookie': 0.6.0 '@web3-storage/multipart-parser': 1.0.0 - cookie: 0.4.2 + cookie: 0.7.2 set-cookie-parser: 2.6.0 source-map: 0.7.4 + turbo-stream: 2.4.1 optionalDependencies: typescript: 5.5.4 - '@remix-run/testing@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4)': + '@remix-run/testing@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4)': dependencies: - '@remix-run/node': 2.1.0(typescript@5.5.4) - '@remix-run/react': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.10.0 + '@remix-run/node': 2.17.4(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/router': 1.23.2 react: 18.2.0 - react-router-dom: 6.17.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-router-dom: 6.30.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - react-dom - '@remix-run/v1-meta@0.1.3(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))': + '@remix-run/v1-meta@0.1.3(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))': dependencies: - '@remix-run/react': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) '@remix-run/web-blob@3.1.0': dependencies: '@remix-run/web-stream': 1.1.0 web-encoding: 1.1.5 - '@remix-run/web-fetch@4.4.1': + '@remix-run/web-fetch@4.4.2': dependencies: '@remix-run/web-blob': 3.1.0 '@remix-run/web-file': 3.1.0 @@ -29619,15 +29451,15 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 18.2.0 - '@sentry/remix@9.46.0(patch_hash=146126b032581925294aaed63ab53ce3f5e0356a755f1763d7a9a76b9846943b)(@remix-run/node@2.1.0(typescript@5.5.4))(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(encoding@0.1.13)(react@18.2.0)': + '@sentry/remix@9.46.0(patch_hash=146126b032581925294aaed63ab53ce3f5e0356a755f1763d7a9a76b9846943b)(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(encoding@0.1.13)(react@18.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 - '@remix-run/node': 2.1.0(typescript@5.5.4) - '@remix-run/react': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.15.3 - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/node': 2.17.4(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/router': 1.23.2 + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) '@sentry/cli': 2.50.2(encoding@0.1.13) '@sentry/core': 9.46.0 '@sentry/node': 9.46.0 @@ -30901,7 +30733,7 @@ snapshots: '@testing-library/dom@8.19.1': dependencies: - '@babel/code-frame': 7.22.13 + '@babel/code-frame': 7.24.7 '@babel/runtime': 7.28.4 '@types/aria-query': 5.0.1 aria-query: 5.3.0 @@ -31489,7 +31321,7 @@ snapshots: '@types/webpack@5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.19.11)': dependencies: '@types/node': 20.14.14 - tapable: 2.2.1 + tapable: 2.3.0 webpack: 5.88.2(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.19.11) transitivePeerDependencies: - '@swc/core' @@ -31680,7 +31512,7 @@ snapshots: chalk: 4.1.2 css-what: 5.1.0 cssesc: 3.0.0 - csstype: 3.2.0 + csstype: 3.2.3 deep-object-diff: 1.1.9 deepmerge: 4.3.1 media-query-parser: 2.0.2 @@ -32051,10 +31883,6 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.12.1): - dependencies: - acorn: 8.12.1 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -32290,68 +32118,53 @@ snapshots: array-flatten@1.1.1: {} - array-includes@3.1.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.21.1 - get-intrinsic: 1.3.0 - is-string: 1.0.7 - array-includes@3.1.8: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - get-intrinsic: 1.2.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 is-string: 1.0.7 array-union@2.1.0: {} array.prototype.findlastindex@1.2.5: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.23.3 es-errors: 1.3.0 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 es-shim-unscopables: 1.0.2 array.prototype.flat@1.3.1: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 - es-shim-unscopables: 1.0.0 - - array.prototype.flat@1.3.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 es-abstract: 1.23.3 - es-shim-unscopables: 1.0.0 + es-shim-unscopables: 1.0.2 - array.prototype.flatmap@1.3.1: + array.prototype.flat@1.3.2: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 - es-shim-unscopables: 1.0.0 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 array.prototype.flatmap@1.3.2: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.23.3 - es-shim-unscopables: 1.0.0 + es-shim-unscopables: 1.0.2 array.prototype.tosorted@1.1.1: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 - es-shim-unscopables: 1.0.0 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 get-intrinsic: 1.3.0 arraybuffer.prototype.slice@1.0.3: @@ -32426,7 +32239,7 @@ snapshots: autoprefixer@10.4.14(postcss@8.4.35): dependencies: - browserslist: 4.24.4 + browserslist: 4.28.0 caniuse-lite: 1.0.30001754 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -32436,7 +32249,7 @@ snapshots: autoprefixer@9.8.8: dependencies: - browserslist: 4.24.4 + browserslist: 4.28.0 caniuse-lite: 1.0.30001754 normalize-range: 0.1.2 num2fraction: 1.2.2 @@ -32477,7 +32290,7 @@ snapshots: babel-walk@3.0.0: dependencies: - '@babel/types': 7.27.0 + '@babel/types': 7.27.3 bail@2.0.2: {} @@ -32646,9 +32459,9 @@ snapshots: browserslist@4.24.4: dependencies: caniuse-lite: 1.0.30001754 - electron-to-chromium: 1.5.98 - node-releases: 2.0.19 - update-browserslist-db: 1.1.2(browserslist@4.24.4) + electron-to-chromium: 1.5.252 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.24.4) browserslist@4.28.0: dependencies: @@ -33108,6 +32921,18 @@ snapshots: transitivePeerDependencies: - supports-color + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + compute-cosine-similarity@1.1.0: dependencies: compute-dot: 1.1.0 @@ -33152,8 +32977,6 @@ snapshots: cookie-signature@1.0.6: {} - cookie-signature@1.2.0: {} - cookie-signature@1.2.2: {} cookie@0.4.2: {} @@ -33162,6 +32985,8 @@ snapshots: cookie@0.7.1: {} + cookie@0.7.2: {} + cookie@1.0.2: {} cookiejar@2.1.4: {} @@ -33779,7 +33604,7 @@ snapshots: dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 - csstype: 3.2.0 + csstype: 3.2.3 dom-serializer@2.0.0: dependencies: @@ -33899,8 +33724,6 @@ snapshots: electron-to-chromium@1.5.252: {} - electron-to-chromium@1.5.98: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -33952,11 +33775,6 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.15.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -34032,7 +33850,7 @@ snapshots: es-define-property: 1.0.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 - es-set-tostringtag: 2.0.3 + es-set-tostringtag: 2.1.0 es-to-primitive: 1.2.1 function.prototype.name: 1.1.6 get-intrinsic: 1.3.0 @@ -34075,10 +33893,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-object-atoms@1.0.0: - dependencies: - es-errors: 1.3.0 - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -34089,12 +33903,6 @@ snapshots: has: 1.0.3 has-tostringtag: 1.0.2 - es-set-tostringtag@2.0.3: - dependencies: - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - es-set-tostringtag@2.1.0: dependencies: es-errors: 1.3.0 @@ -34102,10 +33910,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.0.0: - dependencies: - has: 1.0.3 - es-shim-unscopables@1.0.2: dependencies: hasown: 2.0.2 @@ -34442,10 +34246,10 @@ snapshots: eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.29.1)(eslint@8.31.0): dependencies: - debug: 4.4.1 - enhanced-resolve: 5.15.0 + debug: 4.4.3(supports-color@10.0.0) + enhanced-resolve: 5.18.3 eslint: 8.31.0 - eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) get-tsconfig: 4.7.6 globby: 13.2.2 @@ -34458,7 +34262,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.7.4(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -34515,7 +34319,7 @@ snapshots: eslint-plugin-jest-dom@4.0.3(eslint@8.31.0): dependencies: - '@babel/runtime': 7.24.5 + '@babel/runtime': 7.28.4 '@testing-library/dom': 8.19.1 eslint: 8.31.0 requireindex: 1.2.0 @@ -34532,10 +34336,10 @@ snapshots: eslint-plugin-jsx-a11y@6.7.1(eslint@8.31.0): dependencies: - '@babel/runtime': 7.24.5 + '@babel/runtime': 7.28.4 aria-query: 5.3.0 - array-includes: 3.1.6 - array.prototype.flatmap: 1.3.1 + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 ast-types-flow: 0.0.7 axe-core: 4.6.2 axobject-query: 3.2.1 @@ -34547,7 +34351,7 @@ snapshots: language-tags: 1.0.5 minimatch: 3.1.2 object.entries: 1.1.6 - object.fromentries: 2.0.6 + object.fromentries: 2.0.8 semver: 6.3.1 eslint-plugin-node@11.1.0(eslint@8.31.0): @@ -34566,8 +34370,8 @@ snapshots: eslint-plugin-react@7.32.2(eslint@8.31.0): dependencies: - array-includes: 3.1.6 - array.prototype.flatmap: 1.3.1 + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 array.prototype.tosorted: 1.1.1 doctrine: 2.1.0 eslint: 8.31.0 @@ -34575,9 +34379,9 @@ snapshots: jsx-ast-utils: 3.3.3 minimatch: 3.1.2 object.entries: 1.1.6 - object.fromentries: 2.0.6 + object.fromentries: 2.0.8 object.hasown: 1.1.2 - object.values: 1.1.6 + object.values: 1.2.0 prop-types: 15.8.1 resolve: 2.0.0-next.4 semver: 6.3.1 @@ -34674,8 +34478,8 @@ snapshots: espree@9.4.1: dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 3.4.2 espree@9.6.0: @@ -35305,7 +35109,7 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 + es-abstract: 1.23.3 functions-have-names: 1.2.3 function.prototype.name@1.1.6: @@ -35325,14 +35129,6 @@ snapshots: get-caller-file@2.0.5: {} - get-intrinsic@1.2.4: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - has-proto: 1.0.3 - has-symbols: 1.1.0 - hasown: 2.0.2 - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -35853,7 +35649,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.1 + debug: 4.4.3(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -35896,9 +35692,9 @@ snapshots: dependencies: postcss: 8.4.35 - icss-utils@5.1.0(postcss@8.5.4): + icss-utils@5.1.0(postcss@8.5.6): dependencies: - postcss: 8.5.4 + postcss: 8.5.6 ieee754@1.2.1: {} @@ -36229,8 +36025,8 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.1 + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@10.0.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -36418,7 +36214,7 @@ snapshots: jsx-ast-utils@3.3.3: dependencies: - array-includes: 3.1.6 + array-includes: 3.1.8 object.assign: 4.1.5 junk@4.0.1: {} @@ -36762,7 +36558,7 @@ snapshots: magic-string@0.30.17: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 magic-string@0.30.21: dependencies: @@ -36776,8 +36572,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 + '@babel/parser': 7.27.5 + '@babel/types': 7.27.3 source-map-js: 1.2.1 make-dir@4.0.0: @@ -37675,11 +37471,6 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - minizlib@3.0.1: - dependencies: - minipass: 7.1.2 - rimraf: 5.0.7 - minizlib@3.1.0: dependencies: minipass: 7.1.2 @@ -37752,6 +37543,16 @@ snapshots: transitivePeerDependencies: - supports-color + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + mri@1.2.0: {} mrmime@1.0.1: {} @@ -37815,6 +37616,8 @@ snapshots: negotiator@0.6.3: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -37996,8 +37799,6 @@ snapshots: node-releases@2.0.12: {} - node-releases@2.0.19: {} - node-releases@2.0.27: {} nodemailer@7.0.11: {} @@ -38130,43 +37931,31 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 - - object.fromentries@2.0.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.21.1 + es-abstract: 1.23.3 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.23.3 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.23.3 object.hasown@1.1.2: dependencies: define-properties: 1.2.1 - es-abstract: 1.21.1 - - object.values@1.1.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.21.1 + es-abstract: 1.23.3 object.values@1.2.0: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.8 define-properties: 1.2.1 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 obuf@1.1.2: {} @@ -38203,6 +37992,8 @@ snapshots: on-headers@1.0.2: {} + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -38754,9 +38545,9 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-discard-duplicates@5.1.0(postcss@8.5.4): + postcss-discard-duplicates@5.1.0(postcss@8.5.6): dependencies: - postcss: 8.5.4 + postcss: 8.5.6 postcss-functions@3.0.0: dependencies: @@ -38839,9 +38630,9 @@ snapshots: dependencies: postcss: 8.4.35 - postcss-modules-extract-imports@3.0.0(postcss@8.5.4): + postcss-modules-extract-imports@3.0.0(postcss@8.5.6): dependencies: - postcss: 8.5.4 + postcss: 8.5.6 postcss-modules-local-by-default@4.0.4(postcss@8.4.35): dependencies: @@ -38850,10 +38641,10 @@ snapshots: postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - postcss-modules-local-by-default@4.0.4(postcss@8.5.4): + postcss-modules-local-by-default@4.0.4(postcss@8.5.6): dependencies: - icss-utils: 5.1.0(postcss@8.5.4) - postcss: 8.5.4 + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 @@ -38862,9 +38653,9 @@ snapshots: postcss: 8.4.35 postcss-selector-parser: 6.1.2 - postcss-modules-scope@3.1.1(postcss@8.5.4): + postcss-modules-scope@3.1.1(postcss@8.5.6): dependencies: - postcss: 8.5.4 + postcss: 8.5.6 postcss-selector-parser: 6.1.2 postcss-modules-values@4.0.0(postcss@8.4.35): @@ -38872,21 +38663,21 @@ snapshots: icss-utils: 5.1.0(postcss@8.4.35) postcss: 8.4.35 - postcss-modules-values@4.0.0(postcss@8.5.4): + postcss-modules-values@4.0.0(postcss@8.5.6): dependencies: - icss-utils: 5.1.0(postcss@8.5.4) - postcss: 8.5.4 + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 - postcss-modules@6.0.0(postcss@8.5.4): + postcss-modules@6.0.0(postcss@8.5.6): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.4) + icss-utils: 5.1.0(postcss@8.5.6) lodash.camelcase: 4.3.0 - postcss: 8.5.4 - postcss-modules-extract-imports: 3.0.0(postcss@8.5.4) - postcss-modules-local-by-default: 4.0.4(postcss@8.5.4) - postcss-modules-scope: 3.1.1(postcss@8.5.4) - postcss-modules-values: 4.0.0(postcss@8.5.4) + postcss: 8.5.6 + postcss-modules-extract-imports: 3.0.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.0.4(postcss@8.5.6) + postcss-modules-scope: 3.1.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) string-hash: 1.1.3 postcss-nested@4.2.3: @@ -39658,16 +39449,16 @@ snapshots: react-dom: 18.2.0(react@18.2.0) react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - react-router-dom@6.17.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + react-router-dom@6.30.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@remix-run/router': 1.10.0 + '@remix-run/router': 1.23.2 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-router: 6.17.0(react@18.2.0) + react-router: 6.30.3(react@18.2.0) - react-router@6.17.0(react@18.2.0): + react-router@6.30.3(react@18.2.0): dependencies: - '@remix-run/router': 1.10.0 + '@remix-run/router': 1.23.2 react: 18.2.0 react-smooth@4.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -40050,55 +39841,55 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remix-auth-email-link@2.0.2(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))): + remix-auth-email-link@2.0.2(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))): dependencies: - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) crypto-js: 4.1.1 - remix-auth: 3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) + remix-auth: 3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)) - remix-auth-github@1.6.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))): + remix-auth-github@1.6.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))): dependencies: - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) - remix-auth: 3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) - remix-auth-oauth2: 1.11.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) + remix-auth: 3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)) + remix-auth-oauth2: 1.11.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))) transitivePeerDependencies: - supports-color - remix-auth-google@2.0.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))): + remix-auth-google@2.0.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))): dependencies: - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) - remix-auth: 3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) - remix-auth-oauth2: 1.11.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) + remix-auth: 3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)) + remix-auth-oauth2: 1.11.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))) transitivePeerDependencies: - supports-color - remix-auth-oauth2@1.11.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))): + remix-auth-oauth2@1.11.0(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))): dependencies: - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) debug: 4.4.3(supports-color@10.0.0) - remix-auth: 3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) + remix-auth: 3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)) transitivePeerDependencies: - supports-color - remix-auth@3.6.0(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)): + remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4)): dependencies: - '@remix-run/react': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) uuid: 8.3.2 - remix-typedjson@0.3.1(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(react@18.2.0): + remix-typedjson@0.3.1(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(react@18.2.0): dependencies: - '@remix-run/react': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) react: 18.2.0 - remix-utils@7.7.0(@remix-run/node@2.1.0(typescript@5.5.4))(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.15.3)(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76): + remix-utils@7.7.0(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.23.2)(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76): dependencies: type-fest: 4.33.0 optionalDependencies: - '@remix-run/node': 2.1.0(typescript@5.5.4) - '@remix-run/react': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.15.3 + '@remix-run/node': 2.17.4(typescript@5.5.4) + '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) + '@remix-run/router': 1.23.2 crypto-js: 4.2.0 intl-parse-accept-language: 1.0.0 react: 18.2.0 @@ -40382,8 +40173,6 @@ snapshots: semver@7.6.3: {} - semver@7.7.2: {} - semver@7.7.3: {} send@0.18.0: @@ -41000,11 +40789,11 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 + es-abstract: 1.23.3 get-intrinsic: 1.3.0 has-symbols: 1.1.0 - internal-slot: 1.0.4 - regexp.prototype.flags: 1.4.3 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.2 side-channel: 1.1.0 string.prototype.padend@3.1.4: @@ -41024,7 +40813,7 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 + es-abstract: 1.23.3 string.prototype.trimend@1.0.8: dependencies: @@ -41036,7 +40825,7 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.21.1 + es-abstract: 1.23.3 string.prototype.trimstart@1.0.8: dependencies: @@ -41343,8 +41132,6 @@ snapshots: tailwindcss@4.0.17: {} - tapable@2.2.1: {} - tapable@2.3.0: {} tar-fs@2.1.3: @@ -41354,7 +41141,14 @@ snapshots: pump: 3.0.2 tar-stream: 2.2.0 - tar-fs@3.0.9: + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.2 + tar-stream: 2.2.0 + + tar-fs@3.1.0: dependencies: pump: 3.0.2 tar-stream: 3.1.7 @@ -41365,7 +41159,7 @@ snapshots: - bare-abort-controller - bare-buffer - tar-fs@3.1.0: + tar-fs@3.1.1: dependencies: pump: 3.0.2 tar-stream: 3.1.7 @@ -41415,7 +41209,7 @@ snapshots: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 minipass: 7.1.2 - minizlib: 3.0.1 + minizlib: 3.1.0 mkdirp: 3.0.1 yallist: 5.0.0 @@ -41447,7 +41241,7 @@ snapshots: terser-webpack-plugin@5.3.7(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.19.11)(webpack@5.88.2): dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 @@ -41484,7 +41278,7 @@ snapshots: proper-lockfile: 4.1.2 properties-reader: 2.3.0 ssh-remote-port-forward: 1.0.4 - tar-fs: 3.0.9 + tar-fs: 3.1.1 tmp: 0.2.3 undici: 5.29.0 transitivePeerDependencies: @@ -41816,6 +41610,8 @@ snapshots: turbo-linux-arm64@1.10.3: optional: true + turbo-stream@2.4.1: {} + turbo-windows-64@1.10.3: optional: true @@ -41945,6 +41741,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@6.25.0: {} + unicode-emoji-modifier-base@1.0.0: {} unicorn-magic@0.1.0: {} @@ -42068,7 +41866,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.1.2(browserslist@4.24.4): + update-browserslist-db@1.1.4(browserslist@4.24.4): dependencies: browserslist: 4.24.4 escalade: 3.2.0 @@ -42201,6 +41999,10 @@ snapshots: optionalDependencies: typescript: 5.5.4 + valibot@1.3.1(typescript@5.5.4): + optionalDependencies: + typescript: 5.5.4 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.1.1 @@ -42500,7 +42302,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.11.5 acorn: 8.15.0 acorn-import-assertions: 1.9.0(acorn@8.15.0) - browserslist: 4.24.4 + browserslist: 4.28.0 chrome-trace-event: 1.0.3 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 @@ -42595,6 +42397,10 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.10(bufferutil@4.0.9): + optionalDependencies: + bufferutil: 4.0.9 + ws@7.5.9(bufferutil@4.0.9): optionalDependencies: bufferutil: 4.0.9 From 7c95207486270223c522e1e9a3e7b46e9b52c114 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 15 Apr 2026 16:38:32 +0100 Subject: [PATCH 003/279] fix(run-engine): Stop querying for associated run tags during dequeue (#3379) --- internal-packages/run-engine/src/engine/systems/dequeueSystem.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index a51d12c52ca..9476a081fee 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -438,7 +438,6 @@ export class DequeueSystem { }, include: { runtimeEnvironment: true, - tags: true, }, }); From 0c33de88f01fd48c50aafb108b129e757a4bd83a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 Apr 2026 11:38:45 +0100 Subject: [PATCH 004/279] chore: add Devin bot to vouch list and skip draft requirement (#3396) --- .github/VOUCHED.td | 2 ++ .github/workflows/vouch-check-pr.yml | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ce96548aa6f..c26c36153fa 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -11,6 +11,8 @@ myftija nicktrn samejr isshaddad +# Bots +devin-ai-integration[bot] # Outside contributors gautamsi capaj diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index 21597cf467a..2109b7d3289 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -27,7 +27,8 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.author_association != 'MEMBER' && github.event.pull_request.author_association != 'OWNER' && - github.event.pull_request.author_association != 'COLLABORATOR' + github.event.pull_request.author_association != 'COLLABORATOR' && + github.event.pull_request.user.login != 'devin-ai-integration[bot]' runs-on: ubuntu-latest steps: - name: Close non-draft PR From 93f2ca6bf49381f169fd7733a212e1ca9496c87a Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:46:53 +0100 Subject: [PATCH 005/279] feat(webapp): extend admin workers endpoint and unify admin api auth (#3390) Extends the admin worker groups endpoint with a GET loader and more fields on POST (type, hidden, workloadType, cloudProvider, location, staticIPs, enableFastPath), and pulls the PAT + admin check that was inlined or locally duplicated across every admin.api route into a shared helper in personalAccessToken.server.ts. The generic authenticateAdminRequest returns a discriminated result; requireAdminApiRequest is the thin Remix loader/action wrapper that throws. The neverthrow-style route (platform-notifications.ts) now composes the generic helper instead of duplicating the check. Verified locally against GET (listing) and POST (new fields, invalid enum, minimal backwards-compat). --- .server-changes/admin-workers-endpoint.md | 6 ++ ...nts.$environmentId.engine.repair-queues.ts | 23 +----- ...vironments.$environmentId.engine.report.ts | 23 +----- ...nments.$environmentId.schedules.recover.ts | 23 +----- ...dmin.api.v1.environments.$environmentId.ts | 44 +---------- .../app/routes/admin.api.v1.feature-flags.ts | 23 +----- apps/webapp/app/routes/admin.api.v1.gc.ts | 19 +---- .../admin.api.v1.llm-models.$modelId.ts | 20 +---- .../routes/admin.api.v1.llm-models.missing.ts | 19 +---- .../routes/admin.api.v1.llm-models.reload.ts | 13 +--- .../routes/admin.api.v1.llm-models.seed.ts | 12 +-- .../app/routes/admin.api.v1.llm-models.ts | 20 +---- ...min.api.v1.migrate-legacy-master-queues.ts | 24 +----- ...api.v1.orgs.$organizationId.concurrency.ts | 23 +----- ...gs.$organizationId.environments.staging.ts | 23 +----- ...i.v1.orgs.$organizationId.feature-flags.ts | 38 +--------- ...api.v1.orgs.$organizationId.runs.enable.ts | 23 +----- .../admin.api.v1.platform-notifications.ts | 25 ++---- ...i.v1.runs-replication.$batchId.backfill.ts | 24 +----- ...api.v1.runs-replication.$batchId.cancel.ts | 24 +----- .../admin.api.v1.runs-replication.backfill.ts | 23 +----- .../admin.api.v1.runs-replication.create.ts | 24 +----- .../admin.api.v1.runs-replication.start.ts | 24 +----- .../admin.api.v1.runs-replication.stop.ts | 24 +----- .../admin.api.v1.runs-replication.teardown.ts | 24 +----- .../app/routes/admin.api.v1.snapshot.ts | 19 +---- .../webapp/app/routes/admin.api.v1.workers.ts | 76 +++++++++++++------ .../services/personalAccessToken.server.ts | 55 +++++++++++++- .../worker/workerGroupService.server.ts | 27 ++++++- 29 files changed, 193 insertions(+), 552 deletions(-) create mode 100644 .server-changes/admin-workers-endpoint.md diff --git a/.server-changes/admin-workers-endpoint.md b/.server-changes/admin-workers-endpoint.md new file mode 100644 index 00000000000..34cd6ad70e3 --- /dev/null +++ b/.server-changes/admin-workers-endpoint.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Admin worker groups API: add GET loader and expose more fields on POST. diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts index 30d60197f99..a3fd61546e0 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts @@ -2,7 +2,7 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import pMap from "p-map"; import { z } from "zod"; import { $replica, prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { determineEngineVersion } from "~/v3/engineVersion.server"; import { engine } from "~/v3/runEngine.server"; @@ -16,26 +16,7 @@ const BodySchema = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const parsedParams = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts index 3ea95768991..e37553ea230 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts @@ -1,7 +1,7 @@ import { json, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica, prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { determineEngineVersion } from "~/v3/engineVersion.server"; import { engine } from "~/v3/runEngine.server"; @@ -16,26 +16,7 @@ const SearchParamsSchema = z.object({ }); export async function loader({ request, params }: LoaderFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const parsedParams = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.schedules.recover.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.schedules.recover.ts index a9ada56085c..33c1581a940 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.schedules.recover.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.schedules.recover.ts @@ -1,7 +1,7 @@ import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { scheduleEngine } from "~/v3/scheduleEngine.server"; const ParamsSchema = z.object({ @@ -9,26 +9,7 @@ const ParamsSchema = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const parsedParams = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts index f448c5b5aca..34ea14f9da5 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts @@ -1,7 +1,7 @@ import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { engine } from "~/v3/runEngine.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; @@ -15,26 +15,7 @@ const RequestBodySchema = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const parsedParams = ParamsSchema.parse(params); @@ -71,26 +52,7 @@ const SearchParamsSchema = z.object({ }); export async function loader({ request, params }: LoaderFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to get this endpoint" }, { status: 403 }); - } + await requireAdminApiRequest(request); const parsedParams = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts index b10c983b14d..92debd43a62 100644 --- a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts @@ -1,30 +1,11 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { makeSetMultipleFlags } from "~/v3/featureFlags.server"; import { validatePartialFeatureFlags } from "~/v3/featureFlags"; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findFirst({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { // Parse the request body diff --git a/apps/webapp/app/routes/admin.api.v1.gc.ts b/apps/webapp/app/routes/admin.api.v1.gc.ts index ea63a264acc..fbb5f4c9000 100644 --- a/apps/webapp/app/routes/admin.api.v1.gc.ts +++ b/apps/webapp/app/routes/admin.api.v1.gc.ts @@ -2,8 +2,7 @@ import { type DataFunctionArgs } from "@remix-run/node"; import { PerformanceObserver } from "node:perf_hooks"; import { runInNewContext } from "node:vm"; import v8 from "v8"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; async function waitTillGcFinishes() { let resolver: (value: PerformanceEntry) => void; @@ -36,21 +35,7 @@ async function waitTillGcFinishes() { } export async function loader({ request }: DataFunctionArgs) { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - throw new Response("You must be an admin to perform this action", { status: 403 }); - } - - const user = await prisma.user.findFirst({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user?.admin) { - throw new Response("You must be an admin to perform this action", { status: 403 }); - } + await requireAdminApiRequest(request); const entry = await waitTillGcFinishes(); diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts index 2556dc8267f..e473c2c7273 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts @@ -1,24 +1,10 @@ import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; - -async function requireAdmin(request: Request) { - const authResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authResult) { - throw json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: authResult.userId } }); - if (!user?.admin) { - throw json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } - - return user; -} +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; export async function loader({ request, params }: LoaderFunctionArgs) { - await requireAdmin(request); + await requireAdminApiRequest(request); const model = await prisma.llmModel.findUnique({ where: { id: params.modelId }, @@ -69,7 +55,7 @@ const UpdateModelSchema = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - await requireAdmin(request); + await requireAdminApiRequest(request); const modelId = params.modelId!; diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.missing.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.missing.ts index 5ca7077e1cc..9e1e7dcb432 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.missing.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.missing.ts @@ -1,24 +1,9 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { getMissingLlmModels } from "~/services/admin/missingLlmModels.server"; -async function requireAdmin(request: Request) { - const authResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authResult) { - throw json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: authResult.userId } }); - if (!user?.admin) { - throw json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } - - return user; -} - export async function loader({ request }: LoaderFunctionArgs) { - await requireAdmin(request); + await requireAdminApiRequest(request); const url = new URL(request.url); const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.reload.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.reload.ts index 747722b352a..26eee6d8434 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.reload.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.reload.ts @@ -1,18 +1,9 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; export async function action({ request }: ActionFunctionArgs) { - const authResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: authResult.userId } }); - if (!user?.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); if (!llmPricingRegistry) { return json({ error: "LLM cost tracking is disabled" }, { status: 400 }); diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.seed.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.seed.ts index 32e780d9fb9..ef85d458733 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.seed.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.seed.ts @@ -1,19 +1,11 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { seedLlmPricing, syncLlmCatalog } from "@internal/llm-model-catalog"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; export async function action({ request }: ActionFunctionArgs) { - const authResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: authResult.userId } }); - if (!user?.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const url = new URL(request.url); const action = url.searchParams.get("action") ?? "seed"; diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.ts index 4e3cc39f47a..7c8a3d8caa0 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.ts @@ -1,25 +1,11 @@ import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; -async function requireAdmin(request: Request) { - const authResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authResult) { - throw json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ where: { id: authResult.userId } }); - if (!user?.admin) { - throw json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } - - return user; -} - export async function loader({ request }: LoaderFunctionArgs) { - await requireAdmin(request); + await requireAdminApiRequest(request); const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? "1"); @@ -75,7 +61,7 @@ const CreateModelSchema = z.object({ }); export async function action({ request }: ActionFunctionArgs) { - await requireAdmin(request); + await requireAdminApiRequest(request); if (request.method !== "POST") { return json({ error: "Method not allowed" }, { status: 405 }); diff --git a/apps/webapp/app/routes/admin.api.v1.migrate-legacy-master-queues.ts b/apps/webapp/app/routes/admin.api.v1.migrate-legacy-master-queues.ts index b960287c92a..ad3575fa390 100644 --- a/apps/webapp/app/routes/admin.api.v1.migrate-legacy-master-queues.ts +++ b/apps/webapp/app/routes/admin.api.v1.migrate-legacy-master-queues.ts @@ -1,29 +1,9 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { engine } from "~/v3/runEngine.server"; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { await engine.migrateLegacyMasterQueues(); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts index d6eee08f37f..f5346a69075 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts @@ -1,7 +1,7 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { marqs } from "~/v3/marqs/index.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; @@ -17,26 +17,7 @@ const RequestBodySchema = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const { organizationId } = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts index 6a8628f7526..f3c215bfd44 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts @@ -8,7 +8,7 @@ import { import { z } from "zod"; import { prisma } from "~/db.server"; import { createEnvironment } from "~/models/organization.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; const ParamsSchema = z.object({ @@ -19,26 +19,7 @@ const ParamsSchema = z.object({ * It will create a staging environment for all the projects where there isn't one already */ export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const { organizationId } = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts index bb0671355bd..513616470a0 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts @@ -1,43 +1,15 @@ import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { validatePartialFeatureFlags } from "~/v3/featureFlags"; const ParamsSchema = z.object({ organizationId: z.string(), }); -async function authenticateAdmin(request: Request) { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return { error: json({ error: "Invalid or Missing API key" }, { status: 401 }) }; - } - - const user = await prisma.user.findFirst({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return { error: json({ error: "Invalid or Missing API key" }, { status: 401 }) }; - } - - if (!user.admin) { - return { error: json({ error: "You must be an admin to perform this action" }, { status: 403 }) }; - } - - return { user }; -} - export async function loader({ request, params }: LoaderFunctionArgs) { - const authResult = await authenticateAdmin(request); - - if ("error" in authResult) { - return authResult.error; - } + await requireAdminApiRequest(request); const { organizationId } = ParamsSchema.parse(params); @@ -70,11 +42,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } export async function action({ request, params }: ActionFunctionArgs) { - const authResult = await authenticateAdmin(request); - - if ("error" in authResult) { - return authResult.error; - } + await requireAdminApiRequest(request); const { organizationId } = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts index 6b1cf2d9939..d60754f0461 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts @@ -8,7 +8,7 @@ import { import { z } from "zod"; import { prisma } from "~/db.server"; import { createEnvironment } from "~/models/organization.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; @@ -24,26 +24,7 @@ const BodySchema = z.object({ * It will enabled/disable runs */ export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const { organizationId } = ParamsSchema.parse(params); const body = BodySchema.safeParse(await request.json()); diff --git a/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts b/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts index 093104bcb05..3798d9fa734 100644 --- a/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts +++ b/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts @@ -1,7 +1,6 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { err, ok, type Result } from "neverthrow"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { authenticateAdminRequest } from "~/services/personalAccessToken.server"; import { createPlatformNotification, type CreatePlatformNotificationInput, @@ -11,24 +10,10 @@ type AdminUser = { id: string; admin: boolean }; type AuthError = { status: number; message: string }; async function authenticateAdmin(request: Request): Promise> { - const authResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authResult) { - return err({ status: 401, message: "Invalid or Missing API key" }); - } - - const user = await prisma.user.findUnique({ - where: { id: authResult.userId }, - select: { id: true, admin: true }, - }); - - if (!user?.admin) { - return err({ - status: user ? 403 : 401, - message: user ? "You must be an admin to perform this action" : "Invalid or Missing API key", - }); - } - - return ok(user); + const result = await authenticateAdminRequest(request); + return result.ok + ? ok({ id: result.user.id, admin: result.user.admin }) + : err({ status: result.status, message: result.message }); } export async function action({ request }: ActionFunctionArgs) { diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts index 105fcaa408f..18427795315 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts @@ -1,7 +1,6 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { adminWorker } from "~/v3/services/adminWorker.server"; const Body = z.object({ @@ -19,26 +18,7 @@ const DEFAULT_BATCH_SIZE = 500; const DEFAULT_DELAY_INTERVAL_MS = 1000; export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const { batchId } = Params.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts index 8dfcf9fb85b..b80bfba6b54 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts @@ -1,7 +1,6 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { adminWorker } from "~/v3/services/adminWorker.server"; const Params = z.object({ @@ -9,26 +8,7 @@ const Params = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); const { batchId } = Params.parse(params); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts index 0897c30c21d..c4d17ba875d 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts @@ -3,7 +3,7 @@ import { type TaskRun } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; import { FINAL_RUN_STATUSES } from "~/v3/taskStatus"; @@ -14,26 +14,7 @@ const Body = z.object({ const MAX_BATCH_SIZE = 50; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { const body = await request.json(); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.create.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.create.ts index 483c2d219a1..9cac56b65ce 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.create.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.create.ts @@ -1,6 +1,5 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { z } from "zod"; import { ClickHouse } from "@internal/clickhouse"; import { env } from "~/env.server"; @@ -29,26 +28,7 @@ const CreateRunReplicationServiceParams = z.object({ type CreateRunReplicationServiceParams = z.infer; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { const globalService = getRunsReplicationGlobal(); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.start.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.start.ts index a700c4d4f11..e0c603f5320 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.start.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.start.ts @@ -1,30 +1,10 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server"; import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { const globalService = getRunsReplicationGlobal(); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.stop.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.stop.ts index 1dc53833d86..410a9aeaaba 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.stop.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.stop.ts @@ -1,30 +1,10 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server"; import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { const globalService = getRunsReplicationGlobal(); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.teardown.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.teardown.ts index f4a1223dfcf..8bcf760e72f 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.teardown.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.teardown.ts @@ -1,6 +1,5 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { getRunsReplicationGlobal, unregisterRunsReplicationGlobal, @@ -8,26 +7,7 @@ import { import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } + await requireAdminApiRequest(request); try { const globalService = getRunsReplicationGlobal(); diff --git a/apps/webapp/app/routes/admin.api.v1.snapshot.ts b/apps/webapp/app/routes/admin.api.v1.snapshot.ts index 3b345978f5e..daba88f2a42 100644 --- a/apps/webapp/app/routes/admin.api.v1.snapshot.ts +++ b/apps/webapp/app/routes/admin.api.v1.snapshot.ts @@ -4,8 +4,7 @@ import os from "os"; import path from "path"; import { PassThrough } from "stream"; import v8 from "v8"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; // Format date as yyyy-MM-dd HH_mm_ss_SSS function formatDate(date: Date) { @@ -25,21 +24,7 @@ function formatDate(date: Date) { } export async function loader({ request }: DataFunctionArgs) { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - throw new Response("You must be an admin to perform this action", { status: 403 }); - } - - const user = await prisma.user.findFirst({ - where: { - id: authenticationResult.userId, - }, - }); - - if (!user?.admin) { - throw new Response("You must be an admin to perform this action", { status: 403 }); - } + await requireAdminApiRequest(request); const tempDir = os.tmpdir(); const filepath = path.join( diff --git a/apps/webapp/app/routes/admin.api.v1.workers.ts b/apps/webapp/app/routes/admin.api.v1.workers.ts index b215d8ce223..caa36e5217b 100644 --- a/apps/webapp/app/routes/admin.api.v1.workers.ts +++ b/apps/webapp/app/routes/admin.api.v1.workers.ts @@ -1,9 +1,13 @@ -import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + json, +} from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; -import { type Project } from "@trigger.dev/database"; +import { type Project, WorkerInstanceGroupType, WorkloadType } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.server"; const RequestBodySchema = z.object({ @@ -12,34 +16,44 @@ const RequestBodySchema = z.object({ projectId: z.string().optional(), makeDefaultForProject: z.boolean().default(false), removeDefaultFromProject: z.boolean().default(false), + type: z.nativeEnum(WorkerInstanceGroupType).optional(), + hidden: z.boolean().optional(), + workloadType: z.nativeEnum(WorkloadType).optional(), + cloudProvider: z.string().optional(), + location: z.string().optional(), + staticIPs: z.string().optional(), + enableFastPath: z.boolean().optional(), }); -export async function action({ request }: ActionFunctionArgs) { - // Next authenticate the request - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } +export async function loader({ request }: LoaderFunctionArgs) { + await requireAdminApiRequest(request); - const user = await prisma.user.findFirst({ - where: { - id: authenticationResult.userId, - }, + const workerGroups = await prisma.workerInstanceGroup.findMany({ + orderBy: { createdAt: "asc" }, }); - if (!user) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } + return json({ workerGroups }); +} - if (!user.admin) { - return json({ error: "You must be an admin to perform this action" }, { status: 403 }); - } +export async function action({ request }: ActionFunctionArgs) { + await requireAdminApiRequest(request); try { const rawBody = await request.json(); - const { name, description, projectId, makeDefaultForProject, removeDefaultFromProject } = - RequestBodySchema.parse(rawBody ?? {}); + const { + name, + description, + projectId, + makeDefaultForProject, + removeDefaultFromProject, + type, + hidden, + workloadType, + cloudProvider, + location, + staticIPs, + enableFastPath, + } = RequestBodySchema.parse(rawBody ?? {}); if (removeDefaultFromProject) { if (!projectId) { @@ -74,7 +88,17 @@ export async function action({ request }: ActionFunctionArgs) { }); if (!existingWorkerGroup) { - const { workerGroup, token } = await createWorkerGroup(name, description); + const { workerGroup, token } = await createWorkerGroup({ + name, + description, + type, + hidden, + workloadType, + cloudProvider, + location, + staticIPs, + enableFastPath, + }); if (!makeDefaultForProject) { return json({ @@ -150,9 +174,11 @@ export async function action({ request }: ActionFunctionArgs) { } } -async function createWorkerGroup(name: string | undefined, description: string | undefined) { +async function createWorkerGroup( + options: Parameters[0] +) { const service = new WorkerGroupService(); - return await service.createWorkerGroup({ name, description }); + return await service.createWorkerGroup(options); } async function removeDefaultWorkerGroupFromProject(projectId: string) { diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index ebe8bc31ff5..cceb576c9d8 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -1,4 +1,4 @@ -import { type PersonalAccessToken } from "@trigger.dev/database"; +import { type PersonalAccessToken, type User } from "@trigger.dev/database"; import { customAlphabet, nanoid } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; @@ -118,6 +118,59 @@ export async function authenticateApiRequestWithPersonalAccessToken( return authenticatePersonalAccessToken(token); } +export type AdminAuthenticationResult = + | { ok: true; user: User } + | { ok: false; status: 401 | 403; message: string }; + +/** + * Authenticates a request via personal access token and checks the user is + * an admin. Returns a discriminated result so callers can shape the failure + * (throw a Response, wrap in neverthrow, return JSON, etc.) to fit their + * context. See `requireAdminApiRequest` for the Remix loader/action wrapper. + */ +export async function authenticateAdminRequest( + request: Request +): Promise { + const authResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authResult) { + return { ok: false, status: 401, message: "Invalid or Missing API key" }; + } + + const user = await prisma.user.findFirst({ + where: { id: authResult.userId }, + }); + + if (!user) { + return { ok: false, status: 401, message: "Invalid or Missing API key" }; + } + + if (!user.admin) { + return { ok: false, status: 403, message: "You must be an admin to perform this action" }; + } + + return { ok: true, user }; +} + +/** + * Remix loader/action wrapper around `authenticateAdminRequest` that throws + * a Response on failure so routes can `await` without handling the error + * branch. Uses `new Response` directly to avoid coupling this module to + * `@remix-run/server-runtime`. + */ +export async function requireAdminApiRequest(request: Request): Promise { + const result = await authenticateAdminRequest(request); + + if (!result.ok) { + throw new Response(JSON.stringify({ error: result.message }), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); + } + + return result.user; +} + function getPersonalAccessTokenFromRequest(request: Request) { const rawAuthorization = request.headers.get("Authorization"); diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index 6a2c19cf243..fc280e81652 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -1,4 +1,4 @@ -import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/database"; +import { WorkerInstanceGroup, WorkerInstanceGroupType, WorkloadType } from "@trigger.dev/database"; import { WithRunEngine } from "../baseService.server"; import { WorkerGroupTokenService } from "./workerGroupTokenService.server"; import { logger } from "~/services/logger.server"; @@ -14,11 +14,25 @@ export class WorkerGroupService extends WithRunEngine { organizationId, name, description, + type, + hidden, + workloadType, + cloudProvider, + location, + staticIPs, + enableFastPath, }: { projectId?: string; organizationId?: string; name?: string; description?: string; + type?: WorkerInstanceGroupType; + hidden?: boolean; + workloadType?: WorkloadType; + cloudProvider?: string; + location?: string; + staticIPs?: string; + enableFastPath?: boolean; }) { if (!name) { name = await this.generateWorkerName({ projectId }); @@ -30,15 +44,24 @@ export class WorkerGroupService extends WithRunEngine { }); const token = await tokenService.createToken(); + const resolvedType = + type ?? (projectId ? WorkerInstanceGroupType.UNMANAGED : WorkerInstanceGroupType.MANAGED); + const workerGroup = await this._prisma.workerInstanceGroup.create({ data: { projectId, organizationId, - type: projectId ? WorkerInstanceGroupType.UNMANAGED : WorkerInstanceGroupType.MANAGED, + type: resolvedType, masterQueue: this.generateMasterQueueName({ projectId, name }), tokenId: token.id, description, name, + hidden, + workloadType, + cloudProvider, + location, + staticIPs, + enableFastPath, }, }); From f5b4d34c4bd3bed85ad950c0594745f5b7b48244 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 Apr 2026 14:33:20 +0100 Subject: [PATCH 006/279] chore: allow Devin bot in claude-code-action workflows (#3401) --- .github/workflows/claude-md-audit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml index a80bbca0f52..ddba0180401 100644 --- a/.github/workflows/claude-md-audit.yml +++ b/.github/workflows/claude-md-audit.yml @@ -35,6 +35,7 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} use_sticky_comment: true + allowed_bots: "devin-ai-integration[bot]" claude_args: | --max-turns 15 From 02d2334c8aa67e7d09f7a5ec14620e3a67dca589 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 Apr 2026 15:15:10 +0100 Subject: [PATCH 007/279] fix(webapp): fix Redis connection leak in realtime streams and broken abort signal propagation (#3399) Pool Redis connections for non-blocking ops (ingestData, appendPart, getLastChunkIndex) using a shared singleton instead of new Redis() per request. Use redis.disconnect() for immediate teardown in streamResponse cleanup. Add 15s inactivity timeout fallback. Fix broken request.signal in Remix/Express by wiring Express res.on('close') to an AbortController via httpAsyncStorage. All SSE/streaming routes now use getRequestAbortSignal() which fires reliably on client disconnect, bypassing the Node.js undici GC bug (nodejs/node#55428) that severs the signal chain. --- .../realtime-redis-connection-leak.md | 10 ++++ apps/webapp/CLAUDE.md | 11 +++++ .../v3/TasksStreamPresenter.server.ts | 7 ++- .../realtime.v1.streams.$runId.$streamId.ts | 3 +- ...ltime.v1.streams.$runId.input.$streamId.ts | 3 +- .../route.tsx | 3 +- ...env.$envParam.test.ai-generate-payload.tsx | 3 +- .../app/services/httpAsyncStorage.server.ts | 13 +++++ .../realtime/redisRealtimeStreams.server.ts | 48 +++++++++---------- apps/webapp/app/utils/sse.server.ts | 9 ++-- apps/webapp/app/utils/sse.ts | 7 ++- apps/webapp/remix.config.js | 1 + apps/webapp/server.ts | 4 +- 13 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 .server-changes/realtime-redis-connection-leak.md diff --git a/.server-changes/realtime-redis-connection-leak.md b/.server-changes/realtime-redis-connection-leak.md new file mode 100644 index 00000000000..e27b200174e --- /dev/null +++ b/.server-changes/realtime-redis-connection-leak.md @@ -0,0 +1,10 @@ +--- +area: webapp +type: fix +--- + +Fix Redis connection leak in realtime streams and broken abort signal propagation. + +**Redis connections**: Non-blocking methods (ingestData, appendPart, getLastChunkIndex) now share a single Redis connection instead of creating one per request. streamResponse still uses dedicated connections (required for XREAD BLOCK) but now tears them down immediately via disconnect() instead of graceful quit(), with a 15s inactivity fallback. + +**Abort signal**: request.signal is broken in Remix/Express due to a Node.js undici GC bug (nodejs/node#55428) that severs the signal chain when Remix clones the Request internally. Added getRequestAbortSignal() wired to Express res.on("close") via httpAsyncStorage, which fires reliably on client disconnect. All SSE/streaming routes updated to use it. diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index dff3ca4eb85..a4de6ab57b7 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -59,6 +59,17 @@ Use the `chrome-devtools` MCP server to visually verify local dashboard changes. Routes use Remix flat-file convention with dot-separated segments: `api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger` +## Abort Signals + +**Never use `request.signal`** for detecting client disconnects. It is broken due to a Node.js bug ([nodejs/node#55428](https://github.com/nodejs/node/issues/55428)) where the AbortSignal chain is severed when Remix internally clones the Request object. Instead, use `getRequestAbortSignal()` from `app/services/httpAsyncStorage.server.ts`, which is wired directly to Express `res.on("close")` and fires reliably. + +```typescript +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; + +// In route handlers, SSE streams, or any server-side code: +const signal = getRequestAbortSignal(); +``` + ## Environment Variables Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.** diff --git a/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts b/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts index d690b3d083f..17a5bda620a 100644 --- a/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts @@ -1,6 +1,7 @@ import { type TaskRunAttempt } from "@trigger.dev/database"; import { eventStream } from "remix-utils/sse/server"; import { type PrismaClient, prisma } from "~/db.server"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { logger } from "~/services/logger.server"; import { projectPubSub } from "~/v3/services/projectPubSub.server"; @@ -63,7 +64,9 @@ export class TasksStreamPresenter { const subscriber = await projectPubSub.subscribe(`project:${project.id}:*`); - return eventStream(request.signal, (send, close) => { + const signal = getRequestAbortSignal(); + + return eventStream(signal, (send, close) => { const safeSend = (args: { event?: string; data: string }) => { try { send(args); @@ -95,7 +98,7 @@ export class TasksStreamPresenter { }); pinger = setInterval(() => { - if (request.signal.aborted) { + if (signal.aborted) { return close(); } diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts index 822c10a8101..aabd83bc9bb 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -1,6 +1,7 @@ import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; @@ -129,7 +130,7 @@ export const loader = createLoaderApiRoute( run.realtimeStreamsVersion ); - return realtimeStream.streamResponse(request, run.friendlyId, params.streamId, request.signal, { + return realtimeStream.streamResponse(request, run.friendlyId, params.streamId, getRequestAbortSignal(), { lastEventId, timeoutInSeconds, }); diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts index 98c348a023a..b16b1ca7922 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts @@ -1,6 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { getInputStreamWaitpoint, deleteInputStreamWaitpoint, @@ -162,7 +163,7 @@ const loader = createLoaderApiRoute( request, run.friendlyId, `$trigger.input:${params.streamId}`, - request.signal, + getRequestAbortSignal(), { lastEventId, timeoutInSeconds, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx index b5763bb4e9c..1295adb7842 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx @@ -21,6 +21,7 @@ import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; @@ -89,7 +90,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { run.realtimeStreamsVersion ); - return realtimeStream.streamResponse(request, run.friendlyId, streamKey, request.signal, { + return realtimeStream.streamResponse(request, run.friendlyId, streamKey, getRequestAbortSignal(), { lastEventId, }); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx index 2cfbe6a10b8..a4a5b8900b6 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { env } from "~/env.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { inflate } from "node:zlib"; @@ -92,7 +93,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const result = streamText({ model: openai(env.AI_RUN_FILTER_MODEL ?? "gpt-5-mini"), temperature: 1, - abortSignal: request.signal, + abortSignal: getRequestAbortSignal(), system: systemPrompt, prompt, tools: { diff --git a/apps/webapp/app/services/httpAsyncStorage.server.ts b/apps/webapp/app/services/httpAsyncStorage.server.ts index 7b709e4bf19..24b5c23f871 100644 --- a/apps/webapp/app/services/httpAsyncStorage.server.ts +++ b/apps/webapp/app/services/httpAsyncStorage.server.ts @@ -5,6 +5,7 @@ export type HttpLocalStorage = { path: string; host: string; method: string; + abortController: AbortController; }; const httpLocalStorage = new AsyncLocalStorage(); @@ -18,3 +19,15 @@ export function runWithHttpContext(context: HttpLocalStorage, fn: () => T): T export function getHttpContext(): HttpLocalStorage | undefined { return httpLocalStorage.getStore(); } + +// Fallback signal that is never aborted, safe for tests and non-Express contexts. +const neverAbortedSignal = new AbortController().signal; + +/** + * Returns an AbortSignal wired to the Express response's "close" event. + * This bypasses the broken request.signal chain in @remix-run/express + * (caused by Node.js undici GC bug nodejs/node#55428). + */ +export function getRequestAbortSignal(): AbortSignal { + return httpLocalStorage.getStore()?.abortController.signal ?? neverAbortedSignal; +} diff --git a/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts b/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts index e742f770a99..99ad10c8ee4 100644 --- a/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts @@ -7,7 +7,7 @@ export type RealtimeStreamsOptions = { redis: RedisOptions | undefined; logger?: Logger; logLevel?: LogLevel; - inactivityTimeoutMs?: number; // Close stream after this many ms of no new data (default: 60000) + inactivityTimeoutMs?: number; // Close stream after this many ms of no new data (default: 15000) }; // Legacy constant for backward compatibility (no longer written, but still recognized when reading) @@ -23,10 +23,23 @@ type StreamChunk = export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { private logger: Logger; private inactivityTimeoutMs: number; + // Shared connection for short-lived non-blocking operations (XADD, XREVRANGE, EXPIRE). + // Lazily created on first use so we don't open a connection if only streamResponse is called. + private _sharedRedis: Redis | undefined; constructor(private options: RealtimeStreamsOptions) { this.logger = options.logger ?? new Logger("RedisRealtimeStreams", options.logLevel ?? "info"); - this.inactivityTimeoutMs = options.inactivityTimeoutMs ?? 60000; // Default: 60 seconds + this.inactivityTimeoutMs = options.inactivityTimeoutMs ?? 15000; // Default: 15 seconds + } + + private get sharedRedis(): Redis { + if (!this._sharedRedis) { + this._sharedRedis = new Redis({ + ...this.options.redis, + connectionName: "realtime:shared", + }); + } + return this._sharedRedis; } async initializeStream( @@ -43,7 +56,7 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { signal: AbortSignal, options?: StreamResponseOptions ): Promise { - const redis = new Redis(this.options.redis ?? {}); + const redis = new Redis({ ...this.options.redis, connectionName: "realtime:streamResponse" }); const streamKey = `stream:${runId}:${streamId}`; let isCleanedUp = false; @@ -269,7 +282,10 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { async function cleanup() { if (isCleanedUp) return; isCleanedUp = true; - await redis.quit().catch(console.error); + // disconnect() tears down the TCP socket immediately, which causes any + // pending XREAD BLOCK to reject right away instead of waiting for the + // block timeout to elapse. quit() would queue behind the blocking command. + redis.disconnect(); } signal.addEventListener("abort", cleanup, { once: true }); @@ -290,22 +306,12 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { clientId: string, resumeFromChunk?: number ): Promise { - const redis = new Redis(this.options.redis ?? {}); + const redis = this.sharedRedis; const streamKey = `stream:${runId}:${streamId}`; const startChunk = resumeFromChunk ?? 0; // Start counting from the resume point, not from 0 let currentChunkIndex = startChunk; - const self = this; - - async function cleanup() { - try { - await redis.quit(); - } catch (error) { - self.logger.error("[RedisRealtimeStreams][ingestData] Error in cleanup:", { error }); - } - } - try { const textStream = stream.pipeThrough(new TextDecoderStream()); const reader = textStream.getReader(); @@ -361,13 +367,11 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { this.logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error }); return new Response(null, { status: 500 }); - } finally { - await cleanup(); } } async appendPart(part: string, partId: string, runId: string, streamId: string): Promise { - const redis = new Redis(this.options.redis ?? {}); + const redis = this.sharedRedis; const streamKey = `stream:${runId}:${streamId}`; await redis.xadd( @@ -386,12 +390,10 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { // Set TTL for cleanup when stream is done await redis.expire(streamKey, env.REALTIME_STREAM_TTL); - - await redis.quit(); } async getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise { - const redis = new Redis(this.options.redis ?? {}); + const redis = this.sharedRedis; const streamKey = `stream:${runId}:${streamId}`; try { @@ -460,10 +462,6 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { }); // Return -1 to indicate we don't know what the server has return -1; - } finally { - await redis.quit().catch((err) => { - this.logger.error("[RedisRealtimeStreams][getLastChunkIndex] Error in cleanup:", { err }); - }); } } diff --git a/apps/webapp/app/utils/sse.server.ts b/apps/webapp/app/utils/sse.server.ts index 56e7b191af7..c8ecce4a859 100644 --- a/apps/webapp/app/utils/sse.server.ts +++ b/apps/webapp/app/utils/sse.server.ts @@ -1,5 +1,6 @@ import { eventStream } from "remix-utils/sse/server"; import { env } from "~/env.server"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { logger } from "~/services/logger.server"; type SseProps = { @@ -22,6 +23,8 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: return new Response("SSE disabled", { status: 200 }); } + const signal = getRequestAbortSignal(); + let pinger: NodeJS.Timeout | undefined = undefined; let updater: NodeJS.Timeout | undefined = undefined; let timeout: NodeJS.Timeout | undefined = undefined; @@ -32,7 +35,7 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: clearTimeout(timeout); }; - return eventStream(request.signal, (send, close) => { + return eventStream(signal, (send, close) => { const safeSend = (args: { event?: string; data: string }) => { try { send(args); @@ -60,7 +63,7 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: }; pinger = setInterval(() => { - if (request.signal.aborted) { + if (signal.aborted) { return abort(); } @@ -68,7 +71,7 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: }, pingInterval); updater = setInterval(() => { - if (request.signal.aborted) { + if (signal.aborted) { return abort(); } diff --git a/apps/webapp/app/utils/sse.ts b/apps/webapp/app/utils/sse.ts index 8f396c092e9..f48cc9e31f9 100644 --- a/apps/webapp/app/utils/sse.ts +++ b/apps/webapp/app/utils/sse.ts @@ -2,6 +2,7 @@ import { type LoaderFunctionArgs } from "@remix-run/node"; import { type Params } from "@remix-run/router"; import { eventStream } from "remix-utils/sse/server"; import { setInterval } from "timers/promises"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; export type SendFunction = Parameters[1]>[0]; @@ -89,15 +90,17 @@ export function createSSELoader(options: SSEOptions) { throw new Response("Internal Server Error", { status: 500 }); }); + const requestAbortSignal = getRequestAbortSignal(); + const combinedSignal = AbortSignal.any([ - request.signal, + requestAbortSignal, timeoutSignal, internalController.signal, ]); log("Start"); - request.signal.addEventListener( + requestAbortSignal.addEventListener( "abort", () => { log(`request signal aborted`); diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js index ae2f18cd72e..a4ad1bd228e 100644 --- a/apps/webapp/remix.config.js +++ b/apps/webapp/remix.config.js @@ -30,6 +30,7 @@ module.exports = { "redlock", "parse-duration", "uncrypto", + "std-env", ], browserNodeBuiltinsPolyfill: { modules: { diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index b2cc9387332..e266c6985c8 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -145,9 +145,11 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { app.use((req, res, next) => { // Generate a unique request ID for each request const requestId = nanoid(); + const abortController = new AbortController(); + res.on("close", () => abortController.abort()); runWithHttpContext( - { requestId, path: req.url, host: req.hostname, method: req.method }, + { requestId, path: req.url, host: req.hostname, method: req.method, abortController }, next ); }); From 94abe97132cc18d3705665eeab19da7ff1b402ba Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 Apr 2026 15:15:23 +0100 Subject: [PATCH 008/279] fix(webapp): prevent dashboard crash when span accessory text is not a string (#3400) --- .server-changes/span-accessory-text-guard.md | 6 +++ .../app/components/runs/v3/SpanTitle.tsx | 42 +++++++++++-------- 2 files changed, 30 insertions(+), 18 deletions(-) create mode 100644 .server-changes/span-accessory-text-guard.md diff --git a/.server-changes/span-accessory-text-guard.md b/.server-changes/span-accessory-text-guard.md new file mode 100644 index 00000000000..ab668efd17a --- /dev/null +++ b/.server-changes/span-accessory-text-guard.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Prevent dashboard crash (React error #31) when span accessory item text is not a string. Filters out malformed accessory items in SpanCodePathAccessory instead of passing objects to React as children. diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index 4c25fc7b9ae..0b9273cd481 100644 --- a/apps/webapp/app/components/runs/v3/SpanTitle.tsx +++ b/apps/webapp/app/components/runs/v3/SpanTitle.tsx @@ -55,20 +55,24 @@ function SpanAccessory({ case "pills": { return ( - {accessory.items.map((item, index) => ( - - ))} + {accessory.items + .filter((item) => typeof item.text === "string") + .map((item, index) => ( + + ))} ); } default: { return ( - {accessory.items.map((item, index) => ( - - {item.text} - - ))} + {accessory.items + .filter((item) => typeof item.text === "string") + .map((item, index) => ( + + {item.text} + + ))} ); } @@ -104,16 +108,18 @@ export function SpanCodePathAccessory({ className )} > - {accessory.items.map((item, index) => ( - - {item.text} - {index < accessory.items.length - 1 && ( - - - - )} - - ))} + {accessory.items + .filter((item) => typeof item.text === "string") + .map((item, index, filtered) => ( + + {item.text} + {index < filtered.length - 1 && ( + + + + )} + + ))} ); } From 79b6053e1327dc845827779ba216bd5255212010 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 Apr 2026 15:22:19 +0100 Subject: [PATCH 009/279] feat(server): add TaskIdentifier registry to replace expensive distinct query (#3368) Replace the expensive DISTINCT query for task filter dropdowns with a dedicated TaskIdentifier registry table backed by Redis. Environments migrate automatically on their next deploy, with a transparent fallback to the legacy query for unmigrated environments. Also fixes duplicate dropdown entries when a task changes trigger source, and adds active/archived grouping for removed tasks. Moves BackgroundWorkerTask reads in the trigger hot path to the read replica. --- .server-changes/task-identifier-registry.md | 6 + .../app/components/logs/LogsTaskFilter.tsx | 50 +++- .../app/components/runs/v3/RunFilters.tsx | 53 +++- apps/webapp/app/models/task.server.ts | 3 + .../v3/ErrorsListPresenter.server.ts | 4 +- .../presenters/v3/LogsListPresenter.server.ts | 11 +- .../v3/NextRunListPresenter.server.ts | 10 +- .../v3/ScheduleListPresenter.server.ts | 15 +- .../route.tsx | 11 +- .../route.tsx | 10 +- ...jectParam.env.$envParam.runs.ai-filter.tsx | 5 +- .../app/runEngine/concerns/queues.server.ts | 17 +- .../runEngine/services/batchTrigger.server.ts | 1 + .../runEngine/services/createBatch.server.ts | 2 +- .../services/triggerFailedTask.server.ts | 10 +- .../services/taskIdentifierCache.server.ts | 115 ++++++++ .../services/taskIdentifierRegistry.server.ts | 156 ++++++++++ .../webapp/app/v3/runEngineHandlers.server.ts | 1 + .../changeCurrentDeployment.server.ts | 24 +- .../services/createBackgroundWorker.server.ts | 18 ++ ...eateDeploymentBackgroundWorkerV3.server.ts | 16 +- .../app/v3/services/triggerTask.server.ts | 2 +- .../engine/taskIdentifierRegistry.test.ts | 271 ++++++++++++++++++ .../migration.sql | 2 + .../migration.sql | 40 +++ .../database/prisma/schema.prisma | 29 ++ 26 files changed, 804 insertions(+), 78 deletions(-) create mode 100644 .server-changes/task-identifier-registry.md create mode 100644 apps/webapp/app/services/taskIdentifierCache.server.ts create mode 100644 apps/webapp/app/services/taskIdentifierRegistry.server.ts create mode 100644 apps/webapp/test/engine/taskIdentifierRegistry.test.ts create mode 100644 internal-packages/database/prisma/migrations/20260413000000_add_bwt_covering_index/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260413000001_add_task_identifier_table/migration.sql diff --git a/.server-changes/task-identifier-registry.md b/.server-changes/task-identifier-registry.md new file mode 100644 index 00000000000..327e188de21 --- /dev/null +++ b/.server-changes/task-identifier-registry.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Replace the expensive DISTINCT query for task filter dropdowns with a dedicated TaskIdentifier registry table backed by Redis. Environments migrate automatically on their next deploy, with a transparent fallback to the legacy query for unmigrated environments. Also fixes duplicate dropdown entries when a task changes trigger source, and adds active/archived grouping for removed tasks. Moves BackgroundWorkerTask reads in the trigger hot path to the read replica. diff --git a/apps/webapp/app/components/logs/LogsTaskFilter.tsx b/apps/webapp/app/components/logs/LogsTaskFilter.tsx index fa64eff7bd3..3e2afdcf798 100644 --- a/apps/webapp/app/components/logs/LogsTaskFilter.tsx +++ b/apps/webapp/app/components/logs/LogsTaskFilter.tsx @@ -4,6 +4,8 @@ import { useMemo } from "react"; import * as Ariakit from "@ariakit/react"; import { ComboBox, + SelectGroup, + SelectGroupLabel, SelectItem, SelectList, SelectPopover, @@ -21,6 +23,7 @@ const shortcut = { key: "t" }; type TaskOption = { slug: string; triggerSource: TaskTriggerSource; + isInLatestDeployment: boolean; }; interface LogsTaskFilterProps { @@ -126,17 +129,42 @@ function TasksDropdown({ > - {filtered.map((item, index) => ( - - } - > - {item.slug} - - ))} + {filtered + .filter((item) => item.isInLatestDeployment) + .map((item) => ( + + } + > + {item.slug} + + ))} + {filtered.some((item) => !item.isInLatestDeployment) && ( + + Archived + {filtered + .filter((item) => !item.isInLatestDeployment) + .map((item) => ( + + + + } + > + {item.slug} + + ))} + + )} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index dc3657b42a9..83ebaa0d51b 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -36,6 +36,8 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { ComboBox, SelectButtonItem, + SelectGroup, + SelectGroupLabel, SelectItem, SelectList, SelectPopover, @@ -322,7 +324,7 @@ export function getRunFiltersFromSearchParams( } type RunFiltersProps = { - possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[]; + possibleTasks: { slug: string; triggerSource: TaskTriggerSource; isInLatestDeployment: boolean }[]; bulkActions: { id: string; type: BulkActionType; @@ -627,7 +629,7 @@ function TasksDropdown({ clearSearchValue: () => void; searchValue: string; onClose?: () => void; - possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[]; + possibleTasks: { slug: string; triggerSource: TaskTriggerSource; isInLatestDeployment: boolean }[]; }) { const { values, replace } = useSearchParams(); @@ -658,17 +660,42 @@ function TasksDropdown({ > - {filtered.map((item, index) => ( - - } - > - - - ))} + {filtered + .filter((item) => item.isInLatestDeployment) + .map((item) => ( + + } + > + + + ))} + {filtered.some((item) => !item.isInLatestDeployment) && ( + + Archived + {filtered + .filter((item) => !item.isInLatestDeployment) + .map((item) => ( + + + + } + > + + + ))} + + )} diff --git a/apps/webapp/app/models/task.server.ts b/apps/webapp/app/models/task.server.ts index b696bac6039..aab3b3bcfc1 100644 --- a/apps/webapp/app/models/task.server.ts +++ b/apps/webapp/app/models/task.server.ts @@ -1,6 +1,9 @@ import type { TaskTriggerSource } from "@trigger.dev/database"; import { PrismaClientOrTransaction, sqlDatabaseSchema } from "~/db.server"; +export { getTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; +export type { TaskIdentifierEntry } from "~/services/taskIdentifierCache.server"; + /** * * @param prisma An efficient query to get all task identifiers for a project. diff --git a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts index 13da4ff91f8..ea6e522dbd5 100644 --- a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts @@ -13,7 +13,7 @@ import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger. import { type Direction } from "~/components/ListPagination"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; -import { getAllTaskIdentifiers } from "~/models/task.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BasePresenter } from "~/presenters/v3/basePresenter.server"; @@ -170,7 +170,7 @@ export class ErrorsListPresenter extends BasePresenter { (search !== undefined && search !== "") || (statuses !== undefined && statuses.length > 0); - const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); + const possibleTasksAsync = getTaskIdentifiers(environmentId); // Pre-filter by status: since status lives in Postgres (ErrorGroupState) and the error // list comes from ClickHouse, we resolve inclusion/exclusion sets upfront so that diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 8a3bf692b5b..517c586e4e7 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -7,7 +7,7 @@ import parseDuration from "parse-duration"; import { type Direction } from "~/components/ListPagination"; import { timeFilterFromTo, timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; -import { getAllTaskIdentifiers } from "~/models/task.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils"; import { BasePresenter } from "~/presenters/v3/basePresenter.server"; @@ -176,7 +176,7 @@ export class LogsListPresenter extends BasePresenter { (search !== undefined && search !== "") || !time.isDefault; - const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); + const possibleTasksAsync = getTaskIdentifiers(environmentId); const bulkActionsAsync = this.replica.bulkActionGroup.findMany({ select: { @@ -386,12 +386,7 @@ export class LogsListPresenter extends BasePresenter { next: nextCursor, previous: undefined, // For now, only support forward pagination }, - possibleTasks: possibleTasks - .map((task) => ({ - slug: task.slug, - triggerSource: task.triggerSource, - })) - .sort((a, b) => a.slug.localeCompare(b.slug)), + possibleTasks, bulkActions: bulkActions.map((bulkAction) => ({ id: bulkAction.friendlyId, type: bulkAction.type, diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index f22c7ccf340..de111abd279 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -8,7 +8,7 @@ import { import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; -import { getAllTaskIdentifiers } from "~/models/task.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -105,7 +105,7 @@ export class NextRunListPresenter { !time.isDefault; //get all possible tasks - const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); + const possibleTasksAsync = getTaskIdentifiers(environmentId); //get possible bulk actions const bulkActionsAsync = this.replica.bulkActionGroup.findMany({ @@ -256,11 +256,7 @@ export class NextRunListPresenter { next: pagination.nextCursor ?? undefined, previous: pagination.previousCursor ?? undefined, }, - possibleTasks: possibleTasks - .map((task) => ({ slug: task.slug, triggerSource: task.triggerSource })) - .sort((a, b) => { - return a.slug.localeCompare(b.slug); - }), + possibleTasks, bulkActions: bulkActions.map((bulkAction) => ({ id: bulkAction.friendlyId, type: bulkAction.type, diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 053414dcfc7..22c151d720b 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -1,6 +1,7 @@ import { type RuntimeEnvironmentType, type ScheduleType } from "@trigger.dev/database"; import { type ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { getLimit } from "~/services/platform.v3.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -123,14 +124,10 @@ export class ScheduleListPresenter extends BasePresenter { } //get all possible scheduled tasks - const possibleTasks = await this._replica.backgroundWorkerTask.findMany({ - where: { - workerId: latestWorker.id, - projectId: project.id, - runtimeEnvironmentId: environmentId, - triggerSource: "SCHEDULED", - }, - }); + const allIdentifiers = await getTaskIdentifiers(environmentId); + const possibleTasks = allIdentifiers + .filter((t) => t.triggerSource === "SCHEDULED" && t.isInLatestDeployment) + .map((t) => ({ slug: t.slug })); //do this here to protect against SQL injection search = search && search !== "" ? `%${search}%` : undefined; @@ -285,7 +282,7 @@ export class ScheduleListPresenter extends BasePresenter { totalPages: Math.ceil(totalCount / pageSize), totalCount: totalCount, schedules, - possibleTasks: possibleTasks.map((task) => task.slug).sort((a, b) => a.localeCompare(b)), + possibleTasks: possibleTasks.map((task) => task.slug), hasFilters, limits: { used: schedulesCount, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx index 57b6b71db6f..cd358b7e67d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx @@ -17,14 +17,13 @@ import { TitleWidget } from "~/components/metrics/TitleWidget"; import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; -import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { getAllTaskIdentifiers } from "~/models/task.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { type BuiltInDashboardFilter, type LayoutItem, @@ -70,7 +69,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { organizationId: project.organizationId, key: dashboardKey, }), - getAllTaskIdentifiers($replica, environment.id), + getTaskIdentifiers(environment.id), ]); const filters = dashboard.filters ?? ["tasks", "queues"]; @@ -114,9 +113,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ ...dashboard, filters, - possibleTasks: possibleTasks - .map((task) => ({ slug: task.slug, triggerSource: task.triggerSource })) - .sort((a, b) => a.slug.localeCompare(b.slug)), + possibleTasks, possibleModels, possiblePrompts, possibleOperations, @@ -201,7 +198,7 @@ export function MetricDashboard({ /** Which filters to show. Defaults to ["tasks", "queues"]. */ filters?: BuiltInDashboardFilter[]; /** Possible tasks for filtering */ - possibleTasks?: { slug: string; triggerSource: TaskTriggerSource }[]; + possibleTasks?: { slug: string; triggerSource: TaskTriggerSource; isInLatestDeployment: boolean }[]; /** Possible models for filtering */ possibleModels?: ModelOption[]; /** Possible prompt slugs for filtering */ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx index 051ea7a8a28..418078760cd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx @@ -35,7 +35,7 @@ import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; import { useToast } from "~/components/primitives/Toast"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor"; -import { $replica, prisma } from "~/db.server"; +import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { useDashboardEditor } from "~/hooks/useDashboardEditor"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -44,7 +44,7 @@ import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { getAllTaskIdentifiers } from "~/models/task.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server"; import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server"; import { requireUser, requireUserId } from "~/services/session.server"; @@ -93,7 +93,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { queryPresenter.call({ organizationId: project.organizationId, }), - getAllTaskIdentifiers($replica, environment.id), + getTaskIdentifiers(environment.id), ]); // Admins and impersonating users can use EXPLAIN @@ -109,9 +109,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { queryHistory: history, isAdmin, maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS, - possibleTasks: possibleTasks - .map((task) => ({ slug: task.slug, triggerSource: task.triggerSource })) - .sort((a, b) => a.slug.localeCompare(b.slug)), + possibleTasks, widgetCount, }); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx index 9ae306c98a6..ff289d241be 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx @@ -2,11 +2,10 @@ import { openai } from "@ai-sdk/openai"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { z } from "zod"; -import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { getAllTaskIdentifiers } from "~/models/task.server"; +import { getTaskIdentifiers } from "~/models/task.server"; import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { RunTagListPresenter } from "~/presenters/v3/RunTagListPresenter.server"; import { VersionListPresenter } from "~/presenters/v3/VersionListPresenter.server"; @@ -126,7 +125,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const queryTasks: QueryTasks = { query: async () => { - const tasks = await getAllTaskIdentifiers($replica, environment.id); + const tasks = await getTaskIdentifiers(environment.id); return { tasks, }; diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index eb00bf1c586..136c3da3b9c 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -62,10 +62,15 @@ function extractQueueName(queue: { name?: unknown } | undefined): string | undef } export class DefaultQueueManager implements QueueManager { + private readonly replicaPrisma: PrismaClientOrTransaction; + constructor( private readonly prisma: PrismaClientOrTransaction, - private readonly engine: RunEngine - ) { } + private readonly engine: RunEngine, + replicaPrisma?: PrismaClientOrTransaction + ) { + this.replicaPrisma = replicaPrisma ?? prisma; + } async resolveQueueProperties( request: TriggerTaskRequest, @@ -103,7 +108,7 @@ export class DefaultQueueManager implements QueueManager { // Only fetch task for TTL if caller didn't provide a per-trigger TTL if (request.body.options?.ttl === undefined) { - const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({ + const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({ where: { workerId: lockedBackgroundWorker.id, runtimeEnvironmentId: request.environment.id, @@ -116,7 +121,7 @@ export class DefaultQueueManager implements QueueManager { } } else { // No queue override - fetch task with queue to get both default queue and TTL - const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({ + const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({ where: { workerId: lockedBackgroundWorker.id, runtimeEnvironmentId: request.environment.id, @@ -217,7 +222,7 @@ export class DefaultQueueManager implements QueueManager { // When queue is overridden, we only need TTL from the task (no queue join needed) if (overriddenQueueName) { - const task = await this.prisma.backgroundWorkerTask.findFirst({ + const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({ where: { workerId: worker.id, runtimeEnvironmentId: environment.id, @@ -229,7 +234,7 @@ export class DefaultQueueManager implements QueueManager { return { queueName: overriddenQueueName, taskTtl: task?.ttl }; } - const task = await this.prisma.backgroundWorkerTask.findFirst({ + const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({ where: { workerId: worker.id, runtimeEnvironmentId: environment.id, diff --git a/apps/webapp/app/runEngine/services/batchTrigger.server.ts b/apps/webapp/app/runEngine/services/batchTrigger.server.ts index 4e504163fec..3df2dfb00f9 100644 --- a/apps/webapp/app/runEngine/services/batchTrigger.server.ts +++ b/apps/webapp/app/runEngine/services/batchTrigger.server.ts @@ -531,6 +531,7 @@ export class RunEngineBatchTriggerService extends WithRunEngine { const triggerFailedTaskService = new TriggerFailedTaskService({ prisma: this._prisma, engine: this._engine, + replicaPrisma: this._replica, }); for (const item of itemsToProcess) { diff --git a/apps/webapp/app/runEngine/services/createBatch.server.ts b/apps/webapp/app/runEngine/services/createBatch.server.ts index 309a7700f1a..0653e1ef1c2 100644 --- a/apps/webapp/app/runEngine/services/createBatch.server.ts +++ b/apps/webapp/app/runEngine/services/createBatch.server.ts @@ -40,7 +40,7 @@ export class CreateBatchService extends WithRunEngine { constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) { super({ prisma: _prisma }); - this.queueConcern = new DefaultQueueManager(this._prisma, this._engine); + this.queueConcern = new DefaultQueueManager(this._prisma, this._engine, this._replica); this.validator = new DefaultTriggerTaskValidator(); } diff --git a/apps/webapp/app/runEngine/services/triggerFailedTask.server.ts b/apps/webapp/app/runEngine/services/triggerFailedTask.server.ts index cdcfa63ff0b..0b59a523a6e 100644 --- a/apps/webapp/app/runEngine/services/triggerFailedTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerFailedTask.server.ts @@ -51,10 +51,16 @@ export type TriggerFailedTaskRequest = { */ export class TriggerFailedTaskService { private readonly prisma: PrismaClientOrTransaction; + private readonly replicaPrisma: PrismaClientOrTransaction; private readonly engine: RunEngine; - constructor(opts: { prisma: PrismaClientOrTransaction; engine: RunEngine }) { + constructor(opts: { + prisma: PrismaClientOrTransaction; + engine: RunEngine; + replicaPrisma?: PrismaClientOrTransaction; + }) { this.prisma = opts.prisma; + this.replicaPrisma = opts.replicaPrisma ?? opts.prisma; this.engine = opts.engine; } @@ -91,7 +97,7 @@ export class TriggerFailedTaskService { let queueName: string | undefined; let lockedQueueId: string | undefined; try { - const queueConcern = new DefaultQueueManager(this.prisma, this.engine); + const queueConcern = new DefaultQueueManager(this.prisma, this.engine, this.replicaPrisma); const bodyOptions = request.options as TriggerTaskRequest["body"]["options"]; const triggerRequest: TriggerTaskRequest = { taskId: request.taskId, diff --git a/apps/webapp/app/services/taskIdentifierCache.server.ts b/apps/webapp/app/services/taskIdentifierCache.server.ts new file mode 100644 index 00000000000..9d243b5f740 --- /dev/null +++ b/apps/webapp/app/services/taskIdentifierCache.server.ts @@ -0,0 +1,115 @@ +import { Redis } from "ioredis"; +import type { TaskTriggerSource } from "@trigger.dev/database"; +import { env } from "~/env.server"; +import { singleton } from "~/utils/singleton"; +import { logger } from "./logger.server"; + +const KEY_PREFIX = "tids:"; + +type CachedTaskIdentifier = { + s: string; + ts: TaskTriggerSource; + live: boolean; +}; + +export type TaskIdentifierEntry = { + slug: string; + triggerSource: TaskTriggerSource; + isInLatestDeployment: boolean; +}; + +function buildKey(environmentId: string): string { + return `${KEY_PREFIX}${environmentId}`; +} + +function encode(entry: TaskIdentifierEntry): string { + return JSON.stringify({ + s: entry.slug, + ts: entry.triggerSource, + live: entry.isInLatestDeployment, + } satisfies CachedTaskIdentifier); +} + +function decode(raw: string): TaskIdentifierEntry { + const parsed = JSON.parse(raw) as CachedTaskIdentifier; + return { + slug: parsed.s, + triggerSource: parsed.ts, + isInLatestDeployment: parsed.live, + }; +} + +function initializeRedis(): Redis | undefined { + const host = env.CACHE_REDIS_HOST; + if (!host) { + return undefined; + } + + return new Redis({ + connectionName: "taskIdentifierCache", + host, + port: env.CACHE_REDIS_PORT, + username: env.CACHE_REDIS_USERNAME, + password: env.CACHE_REDIS_PASSWORD, + keyPrefix: "tr:", + enableAutoPipelining: true, + ...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }); +} + +const redis = singleton("taskIdentifierCache", initializeRedis); + +export async function populateTaskIdentifierCache( + environmentId: string, + identifiers: TaskIdentifierEntry[] +): Promise { + if (!redis) return; + + try { + const key = buildKey(environmentId); + const pipeline = redis.pipeline(); + pipeline.del(key); + if (identifiers.length > 0) { + pipeline.sadd(key, ...identifiers.map(encode)); + } + await pipeline.exec(); + } catch (error) { + logger.error("Failed to populate task identifier cache", { + environmentId, + error, + }); + } +} + +export async function invalidateTaskIdentifierCache(environmentId: string): Promise { + if (!redis) return; + + try { + const key = buildKey(environmentId); + await redis.del(key); + } catch (error) { + logger.error("Failed to invalidate task identifier cache", { + environmentId, + error, + }); + } +} + +export async function getTaskIdentifiersFromCache( + environmentId: string +): Promise { + if (!redis) return null; + + try { + const key = buildKey(environmentId); + const members = await redis.smembers(key); + if (members.length === 0) return null; + return members.map(decode); + } catch (error) { + logger.error("Failed to get task identifiers from cache", { + environmentId, + error, + }); + return null; + } +} diff --git a/apps/webapp/app/services/taskIdentifierRegistry.server.ts b/apps/webapp/app/services/taskIdentifierRegistry.server.ts new file mode 100644 index 00000000000..83d531d53e9 --- /dev/null +++ b/apps/webapp/app/services/taskIdentifierRegistry.server.ts @@ -0,0 +1,156 @@ +import { type PrismaClient, type PrismaClientOrTransaction, TaskTriggerSource } from "@trigger.dev/database"; +import { $replica, prisma } from "~/db.server"; +import { getAllTaskIdentifiers } from "~/models/task.server"; +import { logger } from "./logger.server"; +import { + getTaskIdentifiersFromCache, + populateTaskIdentifierCache, + type TaskIdentifierEntry, +} from "./taskIdentifierCache.server"; + +function toTriggerSource(source: string | undefined): TaskTriggerSource { + if (source === "SCHEDULED" || source === "schedule") return "SCHEDULED"; + return "STANDARD"; +} + +export async function syncTaskIdentifiers( + environmentId: string, + projectId: string, + workerId: string, + tasks: { id: string; triggerSource?: string }[], + db: PrismaClient = prisma +): Promise { + const slugs = tasks.map((t) => t.id); + const now = new Date(); + + // Group slugs by resolved triggerSource for bulk updates + const slugsBySource = new Map(); + for (const task of tasks) { + const source = toTriggerSource(task.triggerSource); + const existing = slugsBySource.get(source); + if (existing) { + existing.push(task.id); + } else { + slugsBySource.set(source, [task.id]); + } + } + + // Batch: insert new rows, update existing rows per source group, archive removed tasks + await db.$transaction([ + // Insert any new task identifiers (skips rows that already exist) + db.taskIdentifier.createMany({ + data: tasks.map((task) => ({ + runtimeEnvironmentId: environmentId, + projectId, + slug: task.id, + currentTriggerSource: toTriggerSource(task.triggerSource), + currentWorkerId: workerId, + })), + skipDuplicates: true, + }), + // Update existing rows — one updateMany per distinct triggerSource value + ...Array.from(slugsBySource.entries()).map(([source, taskSlugs]) => + db.taskIdentifier.updateMany({ + where: { + runtimeEnvironmentId: environmentId, + slug: { in: taskSlugs }, + }, + data: { + currentTriggerSource: source, + currentWorkerId: workerId, + lastSeenAt: now, + isInLatestDeployment: true, + }, + }) + ), + // Archive tasks no longer in this deploy + db.taskIdentifier.updateMany({ + where: { + runtimeEnvironmentId: environmentId, + slug: { notIn: slugs }, + isInLatestDeployment: true, + }, + data: { isInLatestDeployment: false }, + }), + ]); + + const allIdentifiers = await db.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: environmentId }, + select: { + slug: true, + currentTriggerSource: true, + isInLatestDeployment: true, + }, + }); + + populateTaskIdentifierCache( + environmentId, + allIdentifiers.map((t) => ({ + slug: t.slug, + triggerSource: t.currentTriggerSource, + isInLatestDeployment: t.isInLatestDeployment, + })) + ).catch((error) => { + logger.error("Failed to populate task identifier cache after sync", { environmentId, error }); + }); +} + +function sortEntries(entries: TaskIdentifierEntry[]): TaskIdentifierEntry[] { + return entries.sort((a, b) => { + if (a.isInLatestDeployment !== b.isInLatestDeployment) + return a.isInLatestDeployment ? -1 : 1; + return a.slug.localeCompare(b.slug); + }); +} + +export async function getTaskIdentifiers( + environmentId: string, + db: PrismaClientOrTransaction = $replica +): Promise { + const cached = await getTaskIdentifiersFromCache(environmentId); + if (cached) return sortEntries(cached); + + const dbRows = await db.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: environmentId }, + select: { + slug: true, + currentTriggerSource: true, + isInLatestDeployment: true, + }, + }); + + if (dbRows.length > 0) { + const entries: TaskIdentifierEntry[] = dbRows.map((t) => ({ + slug: t.slug, + triggerSource: t.currentTriggerSource, + isInLatestDeployment: t.isInLatestDeployment, + })); + + populateTaskIdentifierCache(environmentId, entries).catch((error) => { + logger.error("Failed to populate task identifier cache after DB read", { + environmentId, + error, + }); + }); + + return sortEntries(entries); + } + + const legacyRows = await getAllTaskIdentifiers(db, environmentId); + const entries: TaskIdentifierEntry[] = legacyRows.map((t) => ({ + slug: t.slug, + triggerSource: t.triggerSource, + isInLatestDeployment: true, + })); + + if (entries.length > 0) { + populateTaskIdentifierCache(environmentId, entries).catch((error) => { + logger.error("Failed to populate task identifier cache after legacy fallback", { + environmentId, + error, + }); + }); + } + + return sortEntries(entries); +} diff --git a/apps/webapp/app/v3/runEngineHandlers.server.ts b/apps/webapp/app/v3/runEngineHandlers.server.ts index e728160f00f..9e69d4ba0b4 100644 --- a/apps/webapp/app/v3/runEngineHandlers.server.ts +++ b/apps/webapp/app/v3/runEngineHandlers.server.ts @@ -677,6 +677,7 @@ export function setupBatchQueueCallbacks() { const triggerFailedTaskService = new TriggerFailedTaskService({ prisma, engine, + replicaPrisma: $replica, }); // Check for pre-marked error items (e.g. oversized payloads) diff --git a/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts b/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts index 00360df946c..ee788397a08 100644 --- a/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts +++ b/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts @@ -1,8 +1,11 @@ +import { tryCatch } from "@trigger.dev/core/v3"; +import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; import { WorkerDeployment } from "@trigger.dev/database"; +import { logger } from "~/services/logger.server"; +import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy"; import { compareDeploymentVersions } from "../utils/deploymentVersions"; -import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; export type ChangeCurrentDeploymentDirection = "promote" | "rollback"; @@ -96,6 +99,25 @@ export class ChangeCurrentDeploymentService extends BaseService { }, }); + const [syncError] = await tryCatch( + (async () => { + const tasks = await this._prisma.backgroundWorkerTask.findMany({ + where: { workerId: deployment.workerId! }, + select: { slug: true, triggerSource: true }, + }); + await syncTaskIdentifiers( + deployment.environmentId, + deployment.projectId, + deployment.workerId!, + tasks.map((t) => ({ id: t.slug, triggerSource: t.triggerSource })) + ); + })() + ); + + if (syncError) { + logger.error("Error syncing task identifiers on deployment change", { error: syncError }); + } + await ExecuteTasksWaitingForDeployService.enqueue(deployment.workerId); } } diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 1b51ec04aee..c8381327249 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -13,6 +13,7 @@ import { $transaction, Prisma, PrismaClientOrTransaction } from "~/db.server"; import { sanitizeQueueName } from "~/models/taskQueue.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { removeQueueConcurrencyLimits, @@ -158,6 +159,23 @@ export class CreateBackgroundWorkerService extends BaseService { throw new ServiceValidationError("Error syncing declarative schedules"); } + const [syncIdentifiersError] = await tryCatch( + syncTaskIdentifiers( + environment.id, + project.id, + backgroundWorker.id, + body.metadata.tasks.map((t) => ({ id: t.id, triggerSource: t.triggerSource })) + ) + ); + + if (syncIdentifiersError) { + logger.error("Error syncing task identifiers", { + error: syncIdentifiersError, + backgroundWorker, + environment, + }); + } + const [updateConcurrencyLimitsError] = await tryCatch( updateEnvConcurrencyLimits(environment) ); diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts index e093f2c2006..9743cffcdbe 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts @@ -1,7 +1,8 @@ -import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; +import { CreateBackgroundWorkerRequestBody, tryCatch } from "@trigger.dev/core/v3"; import type { BackgroundWorker, Prisma } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; import { socketIo } from "../handleSocketIo.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; @@ -130,6 +131,19 @@ export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService { }, }); + const [syncIdError] = await tryCatch( + syncTaskIdentifiers( + environment.id, + environment.projectId, + backgroundWorker.id, + body.metadata.tasks.map((t) => ({ id: t.id, triggerSource: t.triggerSource })) + ) + ); + + if (syncIdError) { + logger.error("Error syncing task identifiers", { error: syncIdError }); + } + try { //send a notification that a new worker has been created await projectPubSub.publish( diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 000633fb73f..96712c36cc4 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -99,7 +99,7 @@ export class TriggerTaskService extends WithRunEngine { const service = new RunEngineTriggerTaskService({ prisma: this._prisma, engine: this._engine, - queueConcern: new DefaultQueueManager(this._prisma, this._engine), + queueConcern: new DefaultQueueManager(this._prisma, this._engine, this._replica), validator: new DefaultTriggerTaskValidator(), payloadProcessor: new DefaultPayloadProcessor(), idempotencyKeyConcern: new IdempotencyKeyConcern( diff --git a/apps/webapp/test/engine/taskIdentifierRegistry.test.ts b/apps/webapp/test/engine/taskIdentifierRegistry.test.ts new file mode 100644 index 00000000000..af33640463f --- /dev/null +++ b/apps/webapp/test/engine/taskIdentifierRegistry.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, +})); + +vi.mock("~/services/taskIdentifierCache.server", () => ({ + getTaskIdentifiersFromCache: vi.fn().mockResolvedValue(null), + populateTaskIdentifierCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("~/services/logger.server", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); + +vi.mock("~/models/task.server", () => ({ + getAllTaskIdentifiers: vi.fn().mockResolvedValue([]), +})); + +import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests"; +import { postgresTest } from "@internal/testcontainers"; +import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClient } from "@trigger.dev/database"; +import { + syncTaskIdentifiers, + getTaskIdentifiers, +} from "../../app/services/taskIdentifierRegistry.server"; +import type { AuthenticatedEnvironment } from "@internal/run-engine/tests"; + +vi.setConfig({ testTimeout: 30_000 }); + +async function createWorker(prisma: PrismaClient, env: AuthenticatedEnvironment) { + return prisma.backgroundWorker.create({ + data: { + friendlyId: generateFriendlyId("worker"), + contentHash: `hash-${Date.now()}-${Math.random()}`, + projectId: env.project.id, + runtimeEnvironmentId: env.id, + version: `${Date.now()}`, + metadata: {}, + engine: "V2", + }, + }); +} + +describe("TaskIdentifierRegistry", () => { + postgresTest("should create task identifiers on first sync", async ({ prisma }) => { + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const worker = await createWorker(prisma, env); + + await syncTaskIdentifiers( + env.id, + env.project.id, + worker.id, + [ + { id: "task-a", triggerSource: "STANDARD" }, + { id: "task-b", triggerSource: "SCHEDULED" }, + ], + prisma + ); + + const rows = await prisma.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: env.id }, + orderBy: { slug: "asc" }, + }); + + expect(rows).toHaveLength(2); + expect(rows[0].slug).toBe("task-a"); + expect(rows[0].currentTriggerSource).toBe("STANDARD"); + expect(rows[0].isInLatestDeployment).toBe(true); + expect(rows[1].slug).toBe("task-b"); + expect(rows[1].currentTriggerSource).toBe("SCHEDULED"); + expect(rows[1].isInLatestDeployment).toBe(true); + }); + + postgresTest("should update triggerSource on re-deploy", async ({ prisma }) => { + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const worker1 = await createWorker(prisma, env); + + // First deploy: STANDARD + await syncTaskIdentifiers( + env.id, + env.project.id, + worker1.id, + [{ id: "my-task", triggerSource: "STANDARD" }], + prisma + ); + + const worker2 = await createWorker(prisma, env); + + // Second deploy: change to SCHEDULED + await syncTaskIdentifiers( + env.id, + env.project.id, + worker2.id, + [{ id: "my-task", triggerSource: "SCHEDULED" }], + prisma + ); + + const rows = await prisma.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: env.id }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0].slug).toBe("my-task"); + expect(rows[0].currentTriggerSource).toBe("SCHEDULED"); + expect(rows[0].currentWorkerId).toBe(worker2.id); + }); + + postgresTest("should archive tasks removed in a deploy", async ({ prisma }) => { + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const worker1 = await createWorker(prisma, env); + + // Deploy with both tasks + await syncTaskIdentifiers( + env.id, + env.project.id, + worker1.id, + [ + { id: "task-a", triggerSource: "STANDARD" }, + { id: "task-b", triggerSource: "STANDARD" }, + ], + prisma + ); + + const worker2 = await createWorker(prisma, env); + + // Deploy with only task-a (task-b removed) + await syncTaskIdentifiers( + env.id, + env.project.id, + worker2.id, + [{ id: "task-a", triggerSource: "STANDARD" }], + prisma + ); + + const rows = await prisma.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: env.id }, + orderBy: { slug: "asc" }, + }); + + expect(rows).toHaveLength(2); + expect(rows[0].slug).toBe("task-a"); + expect(rows[0].isInLatestDeployment).toBe(true); + expect(rows[1].slug).toBe("task-b"); + expect(rows[1].isInLatestDeployment).toBe(false); + }); + + postgresTest("should resurrect archived tasks on re-deploy", async ({ prisma }) => { + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const worker1 = await createWorker(prisma, env); + + // Deploy with both + await syncTaskIdentifiers( + env.id, + env.project.id, + worker1.id, + [ + { id: "task-a", triggerSource: "STANDARD" }, + { id: "task-b", triggerSource: "STANDARD" }, + ], + prisma + ); + + const worker2 = await createWorker(prisma, env); + + // Deploy without task-b + await syncTaskIdentifiers( + env.id, + env.project.id, + worker2.id, + [{ id: "task-a", triggerSource: "STANDARD" }], + prisma + ); + + const worker3 = await createWorker(prisma, env); + + // Deploy with task-b again + await syncTaskIdentifiers( + env.id, + env.project.id, + worker3.id, + [ + { id: "task-a", triggerSource: "STANDARD" }, + { id: "task-b", triggerSource: "STANDARD" }, + ], + prisma + ); + + const rows = await prisma.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: env.id }, + orderBy: { slug: "asc" }, + }); + + expect(rows).toHaveLength(2); + expect(rows[0].isInLatestDeployment).toBe(true); + expect(rows[1].isInLatestDeployment).toBe(true); + }); + + postgresTest( + "should return identifiers sorted active-first from DB when cache misses", + async ({ prisma }) => { + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const worker1 = await createWorker(prisma, env); + + await syncTaskIdentifiers( + env.id, + env.project.id, + worker1.id, + [ + { id: "active-task", triggerSource: "STANDARD" }, + { id: "archived-task", triggerSource: "STANDARD" }, + ], + prisma + ); + + const worker2 = await createWorker(prisma, env); + + // Archive one task + await syncTaskIdentifiers( + env.id, + env.project.id, + worker2.id, + [{ id: "active-task", triggerSource: "STANDARD" }], + prisma + ); + + // Read with cache miss (mocked to return null) + const result = await getTaskIdentifiers(env.id, prisma); + + expect(result).toHaveLength(2); + // Active first + expect(result[0].slug).toBe("active-task"); + expect(result[0].isInLatestDeployment).toBe(true); + // Archived second + expect(result[1].slug).toBe("archived-task"); + expect(result[1].isInLatestDeployment).toBe(false); + } + ); + + postgresTest("should handle multiple triggerSource groups in one deploy", async ({ prisma }) => { + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const worker = await createWorker(prisma, env); + + // Note: AGENT enum value is missing from migrations (no ALTER TYPE migration exists), + // so we test with STANDARD + SCHEDULED only. AGENT works in prod because the enum + // was added via prisma db push or manual ALTER. + await syncTaskIdentifiers( + env.id, + env.project.id, + worker.id, + [ + { id: "standard-task", triggerSource: "STANDARD" }, + { id: "scheduled-task", triggerSource: "SCHEDULED" }, + ], + prisma + ); + + const rows = await prisma.taskIdentifier.findMany({ + where: { runtimeEnvironmentId: env.id }, + orderBy: { slug: "asc" }, + }); + + expect(rows).toHaveLength(2); + expect(rows[0].slug).toBe("scheduled-task"); + expect(rows[0].currentTriggerSource).toBe("SCHEDULED"); + expect(rows[1].slug).toBe("standard-task"); + expect(rows[1].currentTriggerSource).toBe("STANDARD"); + }); +}); diff --git a/internal-packages/database/prisma/migrations/20260413000000_add_bwt_covering_index/migration.sql b/internal-packages/database/prisma/migrations/20260413000000_add_bwt_covering_index/migration.sql new file mode 100644 index 00000000000..6e95c900b34 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260413000000_add_bwt_covering_index/migration.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "BackgroundWorkerTask_runtimeEnvironmentId_slug_triggerSource_idx" + ON "BackgroundWorkerTask"("runtimeEnvironmentId", slug, "triggerSource"); diff --git a/internal-packages/database/prisma/migrations/20260413000001_add_task_identifier_table/migration.sql b/internal-packages/database/prisma/migrations/20260413000001_add_task_identifier_table/migration.sql new file mode 100644 index 00000000000..488b3291d30 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260413000001_add_task_identifier_table/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "TaskIdentifier" ( + "id" TEXT NOT NULL, + "runtimeEnvironmentId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "currentTriggerSource" "TaskTriggerSource" NOT NULL DEFAULT 'STANDARD', + "currentWorkerId" TEXT, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "isInLatestDeployment" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "TaskIdentifier_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TaskIdentifier_runtimeEnvironmentId_slug_key" + ON "TaskIdentifier"("runtimeEnvironmentId", "slug"); + +-- CreateIndex +CREATE INDEX "TaskIdentifier_runtimeEnvironmentId_isInLatestDeployment_idx" + ON "TaskIdentifier"("runtimeEnvironmentId", "isInLatestDeployment"); + +-- AddForeignKey +ALTER TABLE "TaskIdentifier" + ADD CONSTRAINT "TaskIdentifier_runtimeEnvironmentId_fkey" + FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskIdentifier" + ADD CONSTRAINT "TaskIdentifier_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskIdentifier" + ADD CONSTRAINT "TaskIdentifier_currentWorkerId_fkey" + FOREIGN KEY ("currentWorkerId") REFERENCES "BackgroundWorker"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index fa570fb7a11..de545599737 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -354,6 +354,7 @@ model RuntimeEnvironment { customerQueries CustomerQuery[] prompts Prompt[] errorGroupStates ErrorGroupState[] + taskIdentifiers TaskIdentifier[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -429,6 +430,7 @@ model Project { prompts Prompt[] platformNotifications PlatformNotification[] errorGroupStates ErrorGroupState[] + taskIdentifiers TaskIdentifier[] } enum ProjectVersion { @@ -517,6 +519,8 @@ model BackgroundWorker { supportsLazyAttempts Boolean @default(false) + taskIdentifiers TaskIdentifier[] + @@unique([projectId, runtimeEnvironmentId, version]) @@index([runtimeEnvironmentId]) // Get the latest worker for a given environment @@ -659,6 +663,7 @@ model BackgroundWorkerTask { // Quick lookup of task identifiers @@index([projectId, slug]) @@index([runtimeEnvironmentId, projectId]) + @@index([runtimeEnvironmentId, slug, triggerSource]) } enum TaskTriggerSource { @@ -2919,3 +2924,27 @@ model ErrorGroupState { @@unique([environmentId, taskIdentifier, errorFingerprint]) @@index([environmentId, status]) } + +model TaskIdentifier { + id String @id @default(cuid()) + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + slug String + + currentTriggerSource TaskTriggerSource @default(STANDARD) + + currentWorker BackgroundWorker? @relation(fields: [currentWorkerId], references: [id], onDelete: SetNull, onUpdate: Cascade) + currentWorkerId String? + + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + isInLatestDeployment Boolean @default(true) + + @@unique([runtimeEnvironmentId, slug]) + @@index([runtimeEnvironmentId, isInLatestDeployment]) +} \ No newline at end of file From 67d2025f3340bb89e9fe08eda8666977c80a5040 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 Apr 2026 15:37:27 +0100 Subject: [PATCH 010/279] feat(webapp): add 60s/60s SWR cache to getEntitlement (#3388) Wraps getEntitlement in platform.v3.server.ts with the existing platformCache (LRU memory + Redis) under a new `entitlement` namespace. Eliminates a synchronous billing-service HTTP round trip on every trigger. Cache config: 60s fresh / 60s stale SWR. Cache key is the organization id. Errors are caught inside the loader and return the existing permissive { hasAccess: true } fallback, which is also cached to prevent thundering-herd on billing outages. Trade-off: plan upgrade/downgrade is now visible after up to ~120s worst-case (60s fresh + 60s stale revalidation). Acceptable since the existing limits and usage namespaces use 5min/10min, and the defensive hasAccess: true fallback already exists. --- .server-changes/getEntitlement-swr-cache.md | 6 +++ .../webapp/app/services/platform.v3.server.ts | 41 ++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 .server-changes/getEntitlement-swr-cache.md diff --git a/.server-changes/getEntitlement-swr-cache.md b/.server-changes/getEntitlement-swr-cache.md new file mode 100644 index 00000000000..1c9c887a33d --- /dev/null +++ b/.server-changes/getEntitlement-swr-cache.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Add 60s fresh / 60s stale SWR cache to `getEntitlement` in `platform.v3.server.ts`. Eliminates a synchronous billing-service HTTP round trip on every trigger. Reuses the existing `platformCache` (LRU memory + Redis) pattern already used for `limits` and `usage`. Cache key is `${orgId}`. Errors return a permissive `{ hasAccess: true }` fallback (existing behavior) and are also cached to prevent thundering-herd on billing outages. diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 3a981396003..9f8037a151e 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -71,6 +71,11 @@ function initializePlatformCache() { fresh: 60_000 * 5, // 5 minutes stale: 60_000 * 10, // 10 minutes }), + entitlement: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, // serve without revalidation for 60s + stale: 120_000, // total TTL — fresh 0-60s, stale-revalidate 60-120s + }), }); return cache; @@ -368,6 +373,7 @@ export async function setPlan( if (result.accepted) { // Invalidate billing cache since plan changed opts?.invalidateBillingCache?.(organization.id); + platformCache.entitlement.remove(organization.id).catch(() => {}); return redirect(newProjectPath(organization, "You're on the Free plan.")); } else { return redirectWithErrorMessage( @@ -384,11 +390,13 @@ export async function setPlan( case "updated_subscription": { // Invalidate billing cache since subscription changed opts?.invalidateBillingCache?.(organization.id); + platformCache.entitlement.remove(organization.id).catch(() => {}); return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully."); } case "canceled_subscription": { // Invalidate billing cache since subscription was canceled opts?.invalidateBillingCache?.(organization.id); + platformCache.entitlement.remove(organization.id).catch(() => {}); return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); } } @@ -531,21 +539,34 @@ export async function getEntitlement( ): Promise { if (!client) return undefined; - try { - const result = await client.getEntitlement(organizationId); - if (!result.success) { - logger.error("Error getting entitlement - no success", { error: result.error }); - return { - hasAccess: true as const, - }; + // Errors must be caught inside the loader — @unkey/cache passes the loader + // promise to waitUntil() with no .catch(), so an unhandled rejection during + // background SWR revalidation would crash the process. Returning undefined + // on error tells SWR not to commit a fail-open value to the cache, which + // prevents transient billing errors from overwriting a legitimate + // hasAccess: false entry. The fail-open default is applied *outside* the + // SWR call so it never becomes a cached access decision. + const result = await platformCache.entitlement.swr(organizationId, async () => { + try { + const response = await client.getEntitlement(organizationId); + if (!response.success) { + logger.error("Error getting entitlement - no success", { error: response.error }); + return undefined; + } + return response; + } catch (e) { + logger.error("Error getting entitlement - caught error", { error: e }); + return undefined; } - return result; - } catch (e) { - logger.error("Error getting entitlement - caught error", { error: e }); + }); + + if (result.err || result.val === undefined) { return { hasAccess: true as const, }; } + + return result.val; } export async function projectCreated( From ff290dfe2f6de3f54c524d058a91fa93f7901640 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:43:16 +0100 Subject: [PATCH 011/279] perf(run-engine): merge dequeue snapshot creation into taskRun.update transaction [TRI-8450] (#3395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Nests the `TaskRunExecutionSnapshot` creation inside the `taskRun.update()` Prisma call in the dequeue flow, reducing **2 DB commits → 1** per dequeue operation. This is the highest-volume of the five unmerged flows identified in TRI-8450 (~9,200 commits/sec on the engine service). **Pattern**: Follows the same nested-write approach already used in the completion path (`runAttemptSystem.ts:735`) and trigger path (`engine/index.ts:674`). **Changes**: - `dequeueSystem.ts`: Moved snapshot creation into `executionSnapshots: { create: {...} }` within the existing `taskRun.update()`. Pre-generates the snapshot ID via `generateInternalId()` (plain cuid, matching what Prisma's `@default(cuid())` produces) so the event emission, heartbeat enqueue, and return value can all be constructed from data already in scope — **no extra DB read needed** after the merged write. `SnapshotId.toFriendlyId()` is used only for the return value's `friendlyId` field, matching the original `createExecutionSnapshot` behavior. - `executionSnapshotSystem.ts`: Added public `enqueueHeartbeatIfNeeded()` method that exposes the heartbeat scheduling logic (previously only available internally via `createExecutionSnapshot`). This is needed because `PENDING_EXECUTING` requires a heartbeat, unlike the `FINISHED` status in the completion reference pattern. This method is reusable by future merge targets (retry-immediate, checkpoint, cancel, requeue). **Net DB change per dequeue**: eliminates 1 write transaction (the separate `TaskRunExecutionSnapshot.create`). No extra reads added — the snapshot ID is pre-generated and the `executionSnapshotCreated` event payload is constructed inline from values already available in the closure. ## Review & Testing Checklist for Human - [ ] **Verify manually-constructed event payload matches DB state**: The `executionSnapshotCreated` event is now built inline (not read back from DB). Confirm the field values (`runStatus: "PENDING"`, `attemptNumber`, `checkpointId`, `workerId`, `runnerId`, `completedWaitpointIds`) match what Prisma actually writes. A mismatch here would be silent — event consumers would get stale/wrong data. - [ ] **Verify `attemptNumber` source is equivalent**: Old code used `lockedTaskRun.attemptNumber` (post-update result). New code uses `result.run.attemptNumber` (pre-update). The `taskRun.update()` data payload does NOT include `attemptNumber`, so they should be identical — but confirm this assumption holds for all dequeue scenarios (e.g. retried runs). - [ ] **Verify `isValid` defaults to `true` in schema**: The old `createExecutionSnapshot` explicitly set `isValid: error ? false : true`. The nested create omits `isValid` (no error in the dequeue happy path). Confirm the Prisma schema default for `TaskRunExecutionSnapshot.isValid` is `true`. - [ ] **Verify `runStatus: "PENDING"` hardcoding matches the mapping**: The old code passed `lockedTaskRun.status` ("DEQUEUED") to `createExecutionSnapshot`, which mapped it to "PENDING" via `run.status === "DEQUEUED" ? "PENDING" : run.status`. The new code hardcodes `"PENDING"` directly. This is correct but brittle if `status` ever changes from "DEQUEUED" to something else upstream. - [ ] **Spot-check `completedWaitpoints` connect + order logic**: The nested create replicates the connect/order logic from `createExecutionSnapshot` (lines 387-393). Verify the `snapshot.completedWaitpoints` type provides `id` and `index` fields compatible with this usage. - [ ] **Verify `checkpoint` in return value**: The return now uses `snapshot.checkpoint` (from the *previous* snapshot) instead of reading the newly-created snapshot's checkpoint relation. Since `checkpointId` is passed through unchanged, they should be identical — but worth a sanity check. **Recommended test plan**: deploy to staging, run the `sample_pg_activity.py` sampler for a 5-minute window, and verify the COMMIT count drop on the engine service + proportional `IO:XactSync` reduction. ### Notes - This only covers the **dequeue** flow (flow #1 from TRI-8450). The remaining four flows (retry-immediate, checkpoint, requeue, cancel) are separate follow-ups. - The new `enqueueHeartbeatIfNeeded` method is deliberately designed for reuse by those follow-up PRs. - CI note: the `priority.test.ts` failure in shard 7 is a flaky ordering assertion unrelated to this change (it compares `friendlyId` values in dequeue order). The `audit` check is also pre-existing/unrelated. Link to Devin session: https://app.devin.ai/sessions/034fe0e7224f49278a2de260203e1377 Requested by: @ericallam --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Eric Allam --- ...merge-dequeue-snapshot-into-transaction.md | 6 ++ .../src/engine/systems/dequeueSystem.ts | 98 +++++++++++++------ .../engine/systems/executionSnapshotSystem.ts | 21 ++++ 3 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 .server-changes/merge-dequeue-snapshot-into-transaction.md diff --git a/.server-changes/merge-dequeue-snapshot-into-transaction.md b/.server-changes/merge-dequeue-snapshot-into-transaction.md new file mode 100644 index 00000000000..62c9a0ec6ca --- /dev/null +++ b/.server-changes/merge-dequeue-snapshot-into-transaction.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Merge execution snapshot creation into the dequeue taskRun.update transaction, reducing 2 DB commits to 1 per dequeue operation diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 9476a081fee..15d79e76baa 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -3,7 +3,7 @@ import { startSpan } from "@internal/tracing"; import { assertExhaustive, tryCatch } from "@trigger.dev/core"; import { DequeuedMessage, RetryOptions, RunAnnotations } from "@trigger.dev/core/v3"; import { placementTag } from "@trigger.dev/core/v3/serverOnly"; -import { getMaxDuration } from "@trigger.dev/core/v3/isomorphic"; +import { generateInternalId, getMaxDuration, SnapshotId } from "@trigger.dev/core/v3/isomorphic"; import { BackgroundWorker, BackgroundWorkerTask, @@ -416,6 +416,9 @@ export class DequeueSystem { ? undefined : result.task.retryConfig; + // Pre-generate snapshot ID so we can construct the result without an extra read + const snapshotId = generateInternalId(); + const lockedTaskRun = await prisma.taskRun.update({ where: { id: runId, @@ -435,6 +438,33 @@ export class DequeueSystem { cliVersion: result.worker.cliVersion, maxDurationInSeconds, maxAttempts: maxAttempts ?? undefined, + executionSnapshots: { + create: { + id: snapshotId, + engine: "V2", + executionStatus: "PENDING_EXECUTING", + description: "Run was dequeued for execution", + // Map DEQUEUED -> PENDING for backwards compatibility with older runners + runStatus: "PENDING", + attemptNumber: result.run.attemptNumber ?? undefined, + previousSnapshotId: snapshot.id, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, + checkpointId: snapshot.checkpointId ?? undefined, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: { + connect: snapshot.completedWaitpoints.map((w) => ({ id: w.id })), + }, + completedWaitpointOrder: snapshot.completedWaitpoints + .filter((c) => c.index !== undefined) + .sort((a, b) => a.index! - b.index!) + .map((w) => w.id), + workerId, + runnerId, + }, + }, }, include: { runtimeEnvironment: true, @@ -516,44 +546,50 @@ export class DequeueSystem { hasPrivateLink = billingResult.val.hasPrivateLink; } - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( - prisma, - { - run: { - id: runId, - status: lockedTaskRun.status, - attemptNumber: lockedTaskRun.attemptNumber, - }, - snapshot: { - executionStatus: "PENDING_EXECUTING", - description: "Run was dequeued for execution", - }, - previousSnapshotId: snapshot.id, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - projectId: snapshot.projectId, - organizationId: snapshot.organizationId, - checkpointId: snapshot.checkpointId ?? undefined, - batchId: snapshot.batchId ?? undefined, - completedWaitpoints: snapshot.completedWaitpoints, - workerId, - runnerId, - } - ); + // Snapshot was created as part of the taskRun.update above (single transaction). + // Construct the snapshot info from data we already have and handle side effects + // (heartbeat + event) manually — no extra DB read needed. + const snapshotCreatedAt = new Date(); + + this.$.eventBus.emit("executionSnapshotCreated", { + time: snapshotCreatedAt, + run: { + id: runId, + }, + snapshot: { + id: snapshotId, + executionStatus: "PENDING_EXECUTING", + description: "Run was dequeued for execution", + runStatus: "PENDING", + attemptNumber: result.run.attemptNumber ?? null, + checkpointId: snapshot.checkpointId ?? null, + workerId: workerId ?? null, + runnerId: runnerId ?? null, + isValid: true, + error: null, + completedWaitpointIds: snapshot.completedWaitpoints.map((wp) => wp.id), + }, + }); + + await this.executionSnapshotSystem.enqueueHeartbeatIfNeeded({ + id: snapshotId, + runId, + executionStatus: "PENDING_EXECUTING", + }); return { version: "1" as const, dequeuedAt: new Date(), workerQueueLength: message.workerQueueLength, snapshot: { - id: newSnapshot.id, - friendlyId: newSnapshot.friendlyId, - executionStatus: newSnapshot.executionStatus, - description: newSnapshot.description, - createdAt: newSnapshot.createdAt, + id: snapshotId, + friendlyId: SnapshotId.toFriendlyId(snapshotId), + executionStatus: "PENDING_EXECUTING" as const, + description: "Run was dequeued for execution", + createdAt: snapshotCreatedAt, }, image: result.deployment?.imageReference ?? undefined, - checkpoint: newSnapshot.checkpoint ?? undefined, + checkpoint: snapshot.checkpoint ?? undefined, completedWaitpoints: snapshot.completedWaitpoints, backgroundWorker: { id: result.worker.id, diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts index a224e5a86b0..d615c066b85 100644 --- a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -518,6 +518,27 @@ export class ExecutionSnapshotSystem { return executionResultFromSnapshot(latestSnapshot); } + /** + * Enqueues a heartbeat job for a snapshot if the execution status requires one. + * Use this after nesting a snapshot create inside a taskRun.update() to replicate + * the heartbeat side effect that createExecutionSnapshot normally handles. + */ + public async enqueueHeartbeatIfNeeded(snapshot: { + id: string; + runId: string; + executionStatus: TaskRunExecutionStatus; + }) { + const intervalMs = this.#getHeartbeatIntervalMs(snapshot.executionStatus); + if (intervalMs !== null) { + await this.$.worker.enqueue({ + id: `heartbeatSnapshot.${snapshot.runId}`, + job: "heartbeatSnapshot", + payload: { snapshotId: snapshot.id, runId: snapshot.runId }, + availableAt: new Date(Date.now() + intervalMs), + }); + } + } + #getHeartbeatIntervalMs(status: TaskRunExecutionStatus): number | null { switch (status) { case "PENDING_EXECUTING": { From 9636e435679642d177ff098ad67d806784678c64 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 17 Apr 2026 09:34:13 +0100 Subject: [PATCH 012/279] fix(webapp): reduce error-level log noise for handled/benign cases (#3403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to cut error volume from logs that represent handled conditions, not real errors (combined ~1600/hr in prod): 1. api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts The route throws `json(..., { status: 404 })` when a waitpoint isn't found, but the generic catch block caught that Response, logged it as an error (with an empty {} body because Error fields are non-enumerable), and rethrew as a 500 — so clients saw a 500 instead of the intended 404, and every stale-waitpoint request produced a Sentry event. Fix: re-throw Response objects unchanged so the correct status propagates and we don't log user 404s as errors. Also serialize remaining Error instances explicitly (name/message/stack) so the logs are actionable when we do hit a real error. 2. v3/marqs/sharedQueueConsumer.server.ts:603 "Task run has invalid status for execution. Going to ack" — the message itself says we're handling it gracefully. Benign race between dequeue and completion/cancellation. Demote to warn. --- ...waitpoints.tokens.$waitpointFriendlyId.complete.ts | 11 ++++++++++- .../webapp/app/v3/marqs/sharedQueueConsumer.server.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts index 916bfd19864..133b6bc55fb 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts @@ -72,7 +72,16 @@ const { action, loader } = createActionApiRoute( { status: 200 } ); } catch (error) { - logger.error("Failed to complete waitpoint token", { error }); + // Re-throw Response objects (intentional HTTP responses like the 404 above) so the + // client gets the correct status code instead of a 500, and we don't log them as errors. + if (error instanceof Response) throw error; + + logger.error("Failed to complete waitpoint token", { + error: + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : error, + }); throw json({ error: "Failed to complete waitpoint token" }, { status: 500 }); } } diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 0d6327f0640..20ee9daf7da 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -600,7 +600,7 @@ export class SharedQueueConsumer { (!retryingFromCheckpoint && !EXECUTABLE_RUN_STATUSES.withoutCheckpoint.includes(existingTaskRun.status)) ) { - logger.error("Task run has invalid status for execution. Going to ack", { + logger.warn("Task run has invalid status for execution. Going to ack", { queueMessage: message.data, messageId: message.messageId, taskRun: existingTaskRun.id, From 45ba398c80c220d260940b53a38cc662992326b0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 17 Apr 2026 10:12:54 +0100 Subject: [PATCH 013/279] Error page graph: for a time bucket don't fill zeros for a version with no errors (#3402) This caused performance issues with large numbers of versions, and bad UX when hovering the graph (showing irrelevant versions) --- .../webapp/app/presenters/v3/ErrorGroupPresenter.server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index 5e9df362e4c..d2f6bbfcbe3 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -242,10 +242,15 @@ export class ErrorGroupPresenter extends BasePresenter { const sortedVersions = sortVersionsDescending([...versionSet]); + // Build the data for the graph + // For each time bucket, if a value exists for a version set the value (don't add zeros) const data = buckets.map((epoch) => { const point: Record = { date: new Date(epoch * 1000) }; for (const version of sortedVersions) { - point[version] = byBucketVersion.get(`${epoch}:${version}`) ?? 0; + const versionValue = byBucketVersion.get(`${epoch}:${version}`); + if (versionValue) { + point[version] = versionValue; + } } return point; }); From 69acdc2b32b0dd6e5fb20feda7cb5e5c53573ff0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 17 Apr 2026 13:25:01 +0100 Subject: [PATCH 014/279] fix(core): truncate large error stacks and messages to prevent OOM (#3405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Large error stacks and messages can OOM the worker process when serialized into OTel spans or `TaskRunError` objects. This was reported when throwing an error with a massive `.stack` property from a chat agent hook. This adds frame-based stack truncation (similar to Sentry's approach) plus message length limits, applied consistently across all error serialization paths. ### What changed **`packages/core/src/v3/errors.ts`** - `truncateStack()` — parses `error.stack` into message lines + frame lines, caps at 50 frames (keep top 5 closest to throw + bottom 45 entry points, with "... N frames omitted ..." in between). Individual lines capped at 1024 chars. - `truncateMessage()` — caps error messages at 1000 chars - Applied in `parseError()` and `sanitizeError()` **`packages/core/src/v3/otel/utils.ts`** - `sanitizeSpanError()` now uses `truncateStack` and `truncateMessage` from `errors.ts` instead of duplicating truncation logic - Non-Error values (strings, JSON) capped at 5000 chars **`packages/core/src/v3/tracer.ts`** - `startActiveSpan` catch block now delegates to `recordSpanException()` instead of calling `span.recordException()` directly ### Limits | What | Limit | Rationale | |------|-------|-----------| | Stack frames | 50 | Matches Sentry's `STACKTRACE_FRAME_LIMIT` | | Top frames kept | 5 | Closest to throw site | | Bottom frames kept | 45 | Entry points / framework frames | | Per-line length | 1024 | Matches Sentry, prevents regex DoS | | Message length | 1000 | Bounded but generous | | Generic string (non-Error) | 5000 | Fallback for JSON/string errors in spans | ## Test plan - [x] 17 unit tests in `packages/core/test/errors.test.ts` - [x] E2E: threw a 300-frame / 5000-char-message error in the ai-chat reference app, verified truncated stack and message in span via `get_span_details` - [x] Verified the run survived the error (no OOM, continued waiting for next message) --- .changeset/truncate-error-stacks.md | 5 + packages/core/src/v3/errors.ts | 91 ++++++- packages/core/src/v3/otel/utils.ts | 35 ++- packages/core/src/v3/tracer.ts | 6 +- packages/core/test/errors.test.ts | 240 ++++++++++++++++++ .../core/test/recordSpanException.test.ts | 88 +++++++ 6 files changed, 444 insertions(+), 21 deletions(-) create mode 100644 .changeset/truncate-error-stacks.md create mode 100644 packages/core/test/errors.test.ts create mode 100644 packages/core/test/recordSpanException.test.ts diff --git a/.changeset/truncate-error-stacks.md b/.changeset/truncate-error-stacks.md new file mode 100644 index 00000000000..b39eb3ae031 --- /dev/null +++ b/.changeset/truncate-error-stacks.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Truncate large error stacks and messages to prevent OOM crashes. Stack traces are capped at 50 frames (keeping top 5 + bottom 45 with an omission notice), individual stack lines at 1024 chars, and error messages at 1000 chars. Applied in parseError, sanitizeError, and OTel span recording. diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index 87fff767d7b..802f53c5441 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -154,13 +154,65 @@ export function isCompleteTaskWithOutput(error: unknown): error is CompleteTaskW return error instanceof Error && error.name === "CompleteTaskWithOutput"; } +const MAX_STACK_FRAMES = 50; +const KEEP_TOP_FRAMES = 5; +const MAX_STACK_LINE_LENGTH = 1024; +const MAX_MESSAGE_LENGTH = 1_000; + +/** Truncate a stack trace to at most MAX_STACK_FRAMES frames, keeping + * the top (closest to throw) and bottom (entry points) frames. + * Individual lines (including message lines) are capped at MAX_STACK_LINE_LENGTH + * to prevent OOM from huge error messages embedded in the stack. */ +export function truncateStack(stack: string | undefined): string { + if (!stack) return ""; + + const lines = stack.split("\n"); + + // First line(s) before the first frame are the error message + const messageLines: string[] = []; + const frameLines: string[] = []; + + for (const line of lines) { + const safe = + line.length > MAX_STACK_LINE_LENGTH + ? line.slice(0, MAX_STACK_LINE_LENGTH) + "...[truncated]" + : line; + if (frameLines.length === 0 && !line.trimStart().startsWith("at ")) { + messageLines.push(safe); + } else { + frameLines.push(safe); + } + } + + if (frameLines.length <= MAX_STACK_FRAMES) { + return [...messageLines, ...frameLines].join("\n"); + } + + const keepBottom = MAX_STACK_FRAMES - KEEP_TOP_FRAMES; + const omitted = frameLines.length - MAX_STACK_FRAMES; + + return [ + ...messageLines, + ...frameLines.slice(0, KEEP_TOP_FRAMES), + ` ... ${omitted} frames omitted ...`, + ...frameLines.slice(-keepBottom), + ].join("\n"); +} + +export function truncateMessage(message: string | undefined): string { + if (!message) return ""; + return message.length > MAX_MESSAGE_LENGTH + ? message.slice(0, MAX_MESSAGE_LENGTH) + "...[truncated]" + : message; +} + export function parseError(error: unknown): TaskRunError { if (isInternalError(error)) { return { type: "INTERNAL_ERROR", code: error.code, - message: error.message, - stackTrace: error.stack ?? "", + message: truncateMessage(error.message), + stackTrace: truncateStack(error.stack), }; } @@ -168,8 +220,8 @@ export function parseError(error: unknown): TaskRunError { return { type: "BUILT_IN_ERROR", name: error.name, - message: error.message, - stackTrace: error.stack ?? "", + message: truncateMessage(error.message), + stackTrace: truncateStack(error.stack), }; } @@ -248,35 +300,52 @@ export function createJsonErrorObject(error: TaskRunError): SerializedError { } } -// Removes any null characters from the error message +// Removes null characters and truncates oversized fields to prevent OOM export function sanitizeError(error: TaskRunError): TaskRunError { switch (error.type) { case "BUILT_IN_ERROR": { return { type: "BUILT_IN_ERROR", - message: error.message?.replace(/\0/g, ""), + message: truncateMessage(error.message?.replace(/\0/g, "")), name: error.name?.replace(/\0/g, ""), - stackTrace: error.stackTrace?.replace(/\0/g, ""), + stackTrace: truncateStack(error.stackTrace?.replace(/\0/g, "")), }; } case "STRING_ERROR": { return { type: "STRING_ERROR", - raw: error.raw.replace(/\0/g, ""), + raw: truncateMessage(error.raw.replace(/\0/g, "")), }; } case "CUSTOM_ERROR": { + // CUSTOM_ERROR.raw holds JSON.stringify(error) which is later parsed by + // JSON.parse in createErrorTaskError. Naive truncation would cut mid-token + // and produce invalid JSON — wrap the preview in a valid JSON envelope. + const clean = error.raw.replace(/\0/g, ""); + const safeRaw = + clean.length > MAX_MESSAGE_LENGTH + ? JSON.stringify({ truncated: true, preview: clean.slice(0, MAX_MESSAGE_LENGTH) }) + : clean; return { type: "CUSTOM_ERROR", - raw: error.raw.replace(/\0/g, ""), + raw: safeRaw, }; } case "INTERNAL_ERROR": { + // message and stackTrace are optional for INTERNAL_ERROR — preserve + // `undefined` so the `error.message ?? "Internal error (CODE)"` fallback + // in createErrorTaskError still kicks in (empty string is not nullish). return { type: "INTERNAL_ERROR", code: error.code, - message: error.message?.replace(/\0/g, ""), - stackTrace: error.stackTrace?.replace(/\0/g, ""), + message: + error.message != null + ? truncateMessage(error.message.replace(/\0/g, "")) + : undefined, + stackTrace: + error.stackTrace != null + ? truncateStack(error.stackTrace.replace(/\0/g, "")) + : undefined, }; } } diff --git a/packages/core/src/v3/otel/utils.ts b/packages/core/src/v3/otel/utils.ts index 8ce83549e7a..7c9e83068cb 100644 --- a/packages/core/src/v3/otel/utils.ts +++ b/packages/core/src/v3/otel/utils.ts @@ -1,22 +1,47 @@ import { type Span, SpanStatusCode, context, propagation } from "@opentelemetry/api"; +import { truncateStack, truncateMessage } from "../errors.js"; + +const MAX_GENERIC_LENGTH = 5_000; + +function truncateGeneric(value: string): string { + return value.length > MAX_GENERIC_LENGTH + ? value.slice(0, MAX_GENERIC_LENGTH) + "...[truncated]" + : value; +} + +function serializeFallback(error: unknown): string { + // JSON.stringify can throw (circular refs, BigInt) or return undefined + // (symbol, undefined, function). Fall back to String() in both cases so we + // never mask the original error being recorded. + try { + const json = JSON.stringify(error); + if (json != null) return json; + } catch { + // fall through + } + try { + return String(error); + } catch { + return "[unserializable error]"; + } +} export function recordSpanException(span: Span, error: unknown) { if (error instanceof Error) { span.recordException(sanitizeSpanError(error)); } else if (typeof error === "string") { - span.recordException(error.replace(/\0/g, "")); + span.recordException(truncateGeneric(error.replace(/\0/g, ""))); } else { - span.recordException(JSON.stringify(error).replace(/\0/g, "")); + span.recordException(truncateGeneric(serializeFallback(error).replace(/\0/g, ""))); } span.setStatus({ code: SpanStatusCode.ERROR }); } function sanitizeSpanError(error: Error) { - // Create a new error object with the same name, message and stack trace - const sanitizedError = new Error(error.message.replace(/\0/g, "")); + const sanitizedError = new Error(truncateMessage(error.message.replace(/\0/g, ""))); sanitizedError.name = error.name.replace(/\0/g, ""); - sanitizedError.stack = error.stack?.replace(/\0/g, ""); + sanitizedError.stack = truncateStack(error.stack?.replace(/\0/g, "")) || undefined; return sanitizedError; } diff --git a/packages/core/src/v3/tracer.ts b/packages/core/src/v3/tracer.ts index 5b213917592..c14e1e07bff 100644 --- a/packages/core/src/v3/tracer.ts +++ b/packages/core/src/v3/tracer.ts @@ -145,11 +145,7 @@ export class TriggerTracer { } if (!spanEnded) { - if (typeof e === "string" || e instanceof Error) { - span.recordException(e); - } - - span.setStatus({ code: SpanStatusCode.ERROR }); + recordSpanException(span, e); } throw e; diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts new file mode 100644 index 00000000000..dee6509d3a2 --- /dev/null +++ b/packages/core/test/errors.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from "vitest"; +import { truncateStack, truncateMessage, parseError, sanitizeError } from "../src/v3/errors.js"; + +// Helper: build a fake stack with N frames +function buildStack(messageLines: string[], frameCount: number): string { + const frames = Array.from( + { length: frameCount }, + (_, i) => ` at functionName${i} (/path/to/file${i}.ts:${i + 1}:${i + 10})` + ); + return [...messageLines, ...frames].join("\n"); +} + +describe("truncateStack", () => { + it("returns empty string for undefined", () => { + expect(truncateStack(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(truncateStack("")).toBe(""); + }); + + it("preserves a short stack unchanged", () => { + const stack = buildStack(["Error: something broke"], 10); + expect(truncateStack(stack)).toBe(stack); + }); + + it("preserves exactly 50 frames", () => { + const stack = buildStack(["Error: at the limit"], 50); + const result = truncateStack(stack); + expect(result).toBe(stack); + expect(result.split("\n").filter((l) => l.trimStart().startsWith("at ")).length).toBe(50); + }); + + it("truncates to 50 frames when exceeding the limit", () => { + const stack = buildStack(["Error: too many frames"], 200); + const result = truncateStack(stack); + const lines = result.split("\n"); + + // Message line + 5 top + 1 omitted notice + 45 bottom = 52 lines + expect(lines[0]).toBe("Error: too many frames"); + expect(lines).toContain(" ... 150 frames omitted ..."); + + const frameLines = lines.filter((l) => l.trimStart().startsWith("at ")); + expect(frameLines.length).toBe(50); + + // First kept frame is frame 0 (top of stack) + expect(frameLines[0]).toContain("functionName0"); + // Last kept frame is the last original frame + expect(frameLines[frameLines.length - 1]).toContain("functionName199"); + }); + + it("preserves multi-line error messages before frames", () => { + const stack = buildStack(["TypeError: cannot read property", " caused by: something"], 60); + const result = truncateStack(stack); + const lines = result.split("\n"); + + expect(lines[0]).toBe("TypeError: cannot read property"); + expect(lines[1]).toBe(" caused by: something"); + expect(lines).toContain(" ... 10 frames omitted ..."); + }); + + it("truncates individual lines longer than 1024 chars", () => { + const longFrame = ` at someFn (${"x".repeat(2000)}:1:1)`; + const stack = ["Error: long line", longFrame].join("\n"); + const result = truncateStack(stack); + const frameLine = result.split("\n")[1]!; + + expect(frameLine.length).toBeLessThan(1100); + expect(frameLine).toContain("...[truncated]"); + }); +}); + +describe("truncateMessage", () => { + it("returns empty string for undefined", () => { + expect(truncateMessage(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(truncateMessage("")).toBe(""); + }); + + it("preserves a short message", () => { + expect(truncateMessage("hello")).toBe("hello"); + }); + + it("truncates messages over 1000 chars", () => { + const long = "x".repeat(5000); + const result = truncateMessage(long); + expect(result.length).toBeLessThan(1100); + expect(result).toContain("...[truncated]"); + }); + + it("preserves a message at exactly 1000 chars", () => { + const exact = "x".repeat(1000); + expect(truncateMessage(exact)).toBe(exact); + }); +}); + +describe("parseError truncation", () => { + it("truncates large stack traces in Error objects", () => { + const error = new Error("boom"); + error.stack = buildStack(["Error: boom"], 200); + const parsed = parseError(error); + + expect(parsed.type).toBe("BUILT_IN_ERROR"); + if (parsed.type === "BUILT_IN_ERROR") { + const frameLines = parsed.stackTrace.split("\n").filter((l) => l.trimStart().startsWith("at ")); + expect(frameLines.length).toBe(50); + expect(parsed.stackTrace).toContain("frames omitted"); + } + }); + + it("truncates large error messages", () => { + const error = new Error("x".repeat(5000)); + const parsed = parseError(error); + + if (parsed.type === "BUILT_IN_ERROR") { + expect(parsed.message.length).toBeLessThan(1100); + expect(parsed.message).toContain("...[truncated]"); + } + }); +}); + +describe("sanitizeError truncation", () => { + it("truncates stack traces during sanitization", () => { + const result = sanitizeError({ + type: "BUILT_IN_ERROR", + name: "Error", + message: "boom", + stackTrace: buildStack(["Error: boom"], 200), + }); + + if (result.type === "BUILT_IN_ERROR") { + const frameLines = result.stackTrace.split("\n").filter((l) => l.trimStart().startsWith("at ")); + expect(frameLines.length).toBe(50); + } + }); + + it("strips null bytes and truncates", () => { + const result = sanitizeError({ + type: "BUILT_IN_ERROR", + name: "Error\0", + message: "hello\0world", + stackTrace: "Error: hello\0world\n at fn (/path.ts:1:1)", + }); + + if (result.type === "BUILT_IN_ERROR") { + expect(result.name).toBe("Error"); + expect(result.message).toBe("helloworld"); + expect(result.stackTrace).not.toContain("\0"); + } + }); + + it("truncates STRING_ERROR raw field", () => { + const result = sanitizeError({ + type: "STRING_ERROR", + raw: "x".repeat(5000), + }); + + if (result.type === "STRING_ERROR") { + expect(result.raw.length).toBeLessThan(1100); + expect(result.raw).toContain("...[truncated]"); + } + }); + + it("preserves small CUSTOM_ERROR raw as valid JSON", () => { + const originalJson = JSON.stringify({ foo: "bar", nested: { baz: 1 } }); + const result = sanitizeError({ + type: "CUSTOM_ERROR", + raw: originalJson, + }); + + if (result.type === "CUSTOM_ERROR") { + // Small JSON should pass through unchanged and remain parseable + expect(result.raw).toBe(originalJson); + expect(() => JSON.parse(result.raw)).not.toThrow(); + } + }); + + it("wraps oversized CUSTOM_ERROR raw in a valid JSON envelope", () => { + const hugeJson = JSON.stringify({ data: "x".repeat(5000) }); + const result = sanitizeError({ + type: "CUSTOM_ERROR", + raw: hugeJson, + }); + + if (result.type === "CUSTOM_ERROR") { + // Must remain valid JSON (critical: createErrorTaskError calls JSON.parse on this) + expect(() => JSON.parse(result.raw)).not.toThrow(); + const parsed = JSON.parse(result.raw); + expect(parsed.truncated).toBe(true); + expect(typeof parsed.preview).toBe("string"); + expect(parsed.preview.length).toBeLessThanOrEqual(1000); + } + }); +}); + +describe("sanitizeError INTERNAL_ERROR optional fields", () => { + it("preserves undefined message (does not convert to empty string)", () => { + const result = sanitizeError({ + type: "INTERNAL_ERROR", + code: "SOME_INTERNAL_CODE" as any, + // message and stackTrace intentionally undefined + }); + + if (result.type === "INTERNAL_ERROR") { + // Must stay undefined so `error.message ?? fallback` works downstream + expect(result.message).toBeUndefined(); + expect(result.stackTrace).toBeUndefined(); + } + }); + + it("truncates INTERNAL_ERROR message when present", () => { + const result = sanitizeError({ + type: "INTERNAL_ERROR", + code: "SOME_INTERNAL_CODE" as any, + message: "x".repeat(5000), + }); + + if (result.type === "INTERNAL_ERROR") { + expect(result.message).toBeDefined(); + expect(result.message!.length).toBeLessThan(1100); + expect(result.message).toContain("...[truncated]"); + } + }); +}); + +describe("truncateStack message line bounding", () => { + it("truncates huge error messages embedded in the stack", () => { + // V8 format: "Error: \n at ..." + // A huge message on the first line must still be bounded. + const hugeMessage = "x".repeat(100_000); + const stack = `Error: ${hugeMessage}\n at fn (/path.ts:1:1)`; + const result = truncateStack(stack); + + // Total output should be bounded (not 100KB+) + expect(result.length).toBeLessThan(5_000); + expect(result).toContain("...[truncated]"); + }); +}); diff --git a/packages/core/test/recordSpanException.test.ts b/packages/core/test/recordSpanException.test.ts new file mode 100644 index 00000000000..6b2d194ce25 --- /dev/null +++ b/packages/core/test/recordSpanException.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import { recordSpanException } from "../src/v3/otel/utils.js"; +import type { Span } from "@opentelemetry/api"; + +function createMockSpan() { + return { + recordException: vi.fn(), + setStatus: vi.fn(), + } as unknown as Span & { recordException: ReturnType; setStatus: ReturnType }; +} + +describe("recordSpanException", () => { + it("records Error instances with truncated message and stack", () => { + const span = createMockSpan(); + const error = new Error("x".repeat(5_000)); + recordSpanException(span, error); + + expect(span.recordException).toHaveBeenCalledTimes(1); + const recorded = (span.recordException as any).mock.calls[0][0] as Error; + expect(recorded).toBeInstanceOf(Error); + expect(recorded.message.length).toBeLessThan(1100); + }); + + it("records string errors with truncation", () => { + const span = createMockSpan(); + recordSpanException(span, "x".repeat(10_000)); + + const recorded = (span.recordException as any).mock.calls[0][0] as string; + expect(typeof recorded).toBe("string"); + expect(recorded.length).toBeLessThan(5_100); + expect(recorded).toContain("...[truncated]"); + }); + + it("does not throw on circular references", () => { + const span = createMockSpan(); + const circular: any = { foo: "bar" }; + circular.self = circular; + + expect(() => recordSpanException(span, circular)).not.toThrow(); + expect(span.recordException).toHaveBeenCalledTimes(1); + expect(span.setStatus).toHaveBeenCalledTimes(1); + }); + + it("does not throw on BigInt values", () => { + const span = createMockSpan(); + const error = { count: BigInt(123) }; + + expect(() => recordSpanException(span, error)).not.toThrow(); + expect(span.recordException).toHaveBeenCalledTimes(1); + }); + + it("handles symbol values (JSON.stringify returns undefined)", () => { + const span = createMockSpan(); + const sym = Symbol("test"); + + expect(() => recordSpanException(span, sym)).not.toThrow(); + expect(span.recordException).toHaveBeenCalledTimes(1); + const recorded = (span.recordException as any).mock.calls[0][0] as string; + expect(typeof recorded).toBe("string"); + expect(recorded).toContain("Symbol"); + }); + + it("handles function values (JSON.stringify returns undefined)", () => { + const span = createMockSpan(); + const fn = () => "test"; + + expect(() => recordSpanException(span, fn)).not.toThrow(); + expect(span.recordException).toHaveBeenCalledTimes(1); + }); + + it("handles undefined (JSON.stringify returns undefined)", () => { + const span = createMockSpan(); + + expect(() => recordSpanException(span, undefined)).not.toThrow(); + expect(span.recordException).toHaveBeenCalledTimes(1); + const recorded = (span.recordException as any).mock.calls[0][0] as string; + expect(typeof recorded).toBe("string"); + }); + + it("always calls setStatus ERROR", () => { + const span = createMockSpan(); + recordSpanException(span, new Error("test")); + recordSpanException(span, "string"); + recordSpanException(span, { obj: true }); + + expect(span.setStatus).toHaveBeenCalledTimes(3); + }); +}); From 581db83f646bf86720ab81590adbaff2ab5d47c1 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:40:05 +0100 Subject: [PATCH 015/279] feat(webapp): highlight microVM regions on the regions page (#3407) Adds a `MicroVM` badge next to the region name on the regions page. Uses the existing `small` badge variant for visual consistency with the `Default` badge already on this page. --- .server-changes/highlight-microvm-regions.md | 6 ++++++ apps/webapp/app/presenters/v3/RegionsPresenter.server.ts | 6 ++++++ .../route.tsx | 7 ++++++- 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .server-changes/highlight-microvm-regions.md diff --git a/.server-changes/highlight-microvm-regions.md b/.server-changes/highlight-microvm-regions.md new file mode 100644 index 00000000000..0d5139f93fb --- /dev/null +++ b/.server-changes/highlight-microvm-regions.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Show a `MicroVM` badge next to the region name on the regions page. diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 55bd30e33be..2feb29a9968 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,3 +1,4 @@ +import { type WorkloadType } from "@trigger.dev/database"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; import { FEATURE_FLAG } from "~/v3/featureFlags"; @@ -15,6 +16,7 @@ export type Region = { staticIPs?: string | null; isDefault: boolean; isHidden: boolean; + workloadType: WorkloadType; }; export class RegionsPresenter extends BasePresenter { @@ -76,6 +78,7 @@ export class RegionsPresenter extends BasePresenter { location: true, staticIPs: true, hidden: true, + workloadType: true, }, where: isAdmin ? undefined @@ -99,6 +102,7 @@ export class RegionsPresenter extends BasePresenter { staticIPs: region.staticIPs ?? undefined, isDefault: region.id === defaultWorkerInstanceGroupId, isHidden: region.hidden, + workloadType: region.workloadType, })); if (project.defaultWorkerGroupId) { @@ -111,6 +115,7 @@ export class RegionsPresenter extends BasePresenter { location: true, staticIPs: true, hidden: true, + workloadType: true, }, where: { id: project.defaultWorkerGroupId }, }); @@ -131,6 +136,7 @@ export class RegionsPresenter extends BasePresenter { staticIPs: defaultWorkerGroup.staticIPs ?? undefined, isDefault: true, isHidden: defaultWorkerGroup.hidden, + workloadType: defaultWorkerGroup.workloadType, }); } } 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 26daa24df34..2d754309a3d 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 @@ -204,7 +204,12 @@ export default function Page() { return ( - + + + {region.workloadType === "MICROVM" && ( + MicroVM + )} + {region.cloudProvider ? ( From 9a988ab8850915a6f91d19141640474795357f74 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:49:30 +0100 Subject: [PATCH 016/279] chore(webapp): clarify admin feature flags are global (#3408) global flags are global. --- .server-changes/admin-global-flags-warning.md | 6 ++++++ apps/webapp/app/routes/admin.feature-flags.tsx | 8 +++++--- apps/webapp/app/routes/admin.tsx | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .server-changes/admin-global-flags-warning.md diff --git a/.server-changes/admin-global-flags-warning.md b/.server-changes/admin-global-flags-warning.md new file mode 100644 index 00000000000..f0af91b143b --- /dev/null +++ b/.server-changes/admin-global-flags-warning.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Make it clear in the admin that feature flags are global and should rarely be changed. diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 8e91bdb0731..4066e6a4d9b 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -235,10 +235,12 @@ export default function AdminFeatureFlagsRoute() { return (
-

- Global defaults for all organizations. Org-level overrides take precedence. When not set, + + These are global feature flags that affect every organization on this instance. Changing + values here is a dangerous operation and should rarely be done - prefer org-level + overrides where possible. Org-level overrides take precedence; when a flag isn't set, each consumer uses its own default. -

+
Date: Fri, 17 Apr 2026 11:05:57 -0400 Subject: [PATCH 017/279] feat: Increase default project limit per org from 10 to 25 (#3409) --- .server-changes/increase-default-project-limit.md | 6 ++++++ .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .server-changes/increase-default-project-limit.md create mode 100644 internal-packages/database/prisma/migrations/20260417080903_increase_default_maximum_project_count/migration.sql diff --git a/.server-changes/increase-default-project-limit.md b/.server-changes/increase-default-project-limit.md new file mode 100644 index 00000000000..f24ba53ca0b --- /dev/null +++ b/.server-changes/increase-default-project-limit.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Increase default maximum project count per organization from 10 to 25 diff --git a/internal-packages/database/prisma/migrations/20260417080903_increase_default_maximum_project_count/migration.sql b/internal-packages/database/prisma/migrations/20260417080903_increase_default_maximum_project_count/migration.sql new file mode 100644 index 00000000000..e233291942e --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260417080903_increase_default_maximum_project_count/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Organization" ALTER COLUMN "maximumProjectCount" SET DEFAULT 25; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index de545599737..1462752d8dd 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -218,7 +218,7 @@ model Organization { featureFlags Json? - maximumProjectCount Int @default(10) + maximumProjectCount Int @default(25) projects Project[] members OrgMember[] From 6e6deb41e19f0491784cf40a6da2aeb2ac66a3e6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 19 Apr 2026 19:35:04 +0100 Subject: [PATCH 018/279] Admin endpoint to set concurrency burst factor (#3412) Example cURL call using an admin user PAT (replace with a real one): ```sh curl -X PUT https://cloud.trigger.dev/admin/api/v1/environments//burst-factor \ -H "Authorization: Bearer tr_pat_1234" \ -H "Content-Type: application/json" \ -d '{"burstFactor": 1.5}' ``` --- ...nvironments.$environmentId.burst-factor.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/webapp/app/routes/admin.api.v1.environments.$environmentId.burst-factor.ts diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.burst-factor.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.burst-factor.ts new file mode 100644 index 00000000000..fa197fc1694 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.burst-factor.ts @@ -0,0 +1,30 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; +import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; + +const ParamsSchema = z.object({ + environmentId: z.string(), +}); + +const RequestBodySchema = z.object({ + burstFactor: z.number().positive(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + await requireAdminApiRequest(request); + + const { environmentId } = ParamsSchema.parse(params); + const body = RequestBodySchema.parse(await request.json()); + + const environment = await prisma.runtimeEnvironment.update({ + where: { id: environmentId }, + data: { concurrencyLimitBurstFactor: body.burstFactor }, + include: { organization: true, project: true }, + }); + + await updateEnvConcurrencyLimits(environment); + + return json({ success: true }); +} From 881288c6150b1111fd257569a823a531338ab9ac Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 20 Apr 2026 15:26:57 +0100 Subject: [PATCH 019/279] feat(webapp): deprecate v3 CLI deploys server-side (#3415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Summary Adds a server-side gate that detects deploy attempts from v3 CLI versions (i.e. `trigger.dev@3.x`) at the `POST /api/v1/deployments` entry point and, when enabled, rejects them with a clear upgrade message. v4 CLI deploys are completely unaffected. The last 3.x CLI release was `3.3.7`, which we can't update. This approach short-circuits the deploy before any DB writes, image-ref generation, S2 stream creation, or queue enqueue — no side effects in either mode. ## How v3 vs v4 are distinguished I pulled the published CLI tarballs for `trigger.dev@3.3.7`, `4.0.0`, `4.0.1`, `4.0.5`, `4.1.0`, `4.2.0`, and the current `4.4.4` in the repo. The cleanest, most reliable signal is the request body to `POST /api/v1/deployments`: | Field on initialize | v3.3.7 CLI | v4.x CLI | |---|---|---| | `type` | **never sent** | always sent — `"MANAGED"` (run_engine_v2) or `"V1"` | | `isNativeBuild` / `gitMeta` / `triggeredVia` / `runtime` | not sent | sent | | `registryHost` / `namespace` | sent (v3-only; stripped by current Zod schema) | not sent | Every v4 call site I inspected sets `type: features.run_engine_v2 ? "MANAGED" : "V1"` unconditionally. `payload.type` is `undefined` if and only if the client is a 3.x CLI. ## Behavior - Detection always runs and emits `logger.warn("Detected deploy from deprecated v3 CLI", { environmentId, projectId, organizationId, enforced })`, which lets us watch how many v3 deploys are still happening before enforcement is flipped. - Enforcement is gated behind `DEPRECATE_V3_CLI_DEPLOYS_ENABLED` (default `"0"`, off). When `"1"`, the server returns `400` with: > The trigger.dev CLI v3 is no longer supported for deployments. Please upgrade your project to v4: https://trigger.dev/docs/migrating-from-v3 The v3 CLI surfaces this verbatim as `Failed to start deployment: ` because `zodfetch` throws `ApiError` for non-retryable 4xx (400/422) and `deploy.js` in 3.3.7 prints `error.message`. ## Out of scope (intentionally) - `api.v1.deployments.$deploymentId.finalize.ts` / `FinalizeDeploymentService` / `createDeploymentBackgroundWorkerV3.server.ts` are V1-engine paths, not the v3 CLI gate. Leaving them alone per review. - Container-side `createDeploymentBackgroundWorker` call in `managed-index-controller.ts` is still used by v4's in-image indexer. Not touched. - v3 `trigger dev` flow (different code path) — separate deprecation if/when needed. ## Testing - Ran `pnpm run typecheck --filter webapp` locally — passes. - Verified v4 tarballs (4.0.0, 4.0.1, 4.0.5, 4.1.0, 4.2.0, 4.4.4) all include `type:` in the `initializeDeployment` call site, so none will be accidentally blocked. - Verified v3.3.7 tarball's `initializeDeployment` payload has no `type` field. Rollout plan after merge: 1. Deploy with `DEPRECATE_V3_CLI_DEPLOYS_ENABLED` unset → watch `Detected deploy from deprecated v3 CLI` log volume. 2. When comfortable, set `DEPRECATE_V3_CLI_DEPLOYS_ENABLED=1` to enforce. --- ## Changelog Detect v3 CLI deploys on `/api/v1/deployments` and, when `DEPRECATE_V3_CLI_DEPLOYS_ENABLED=1`, reject them with an upgrade message pointing at https://trigger.dev/docs/migrating-from-v3. v4 CLI deploys are unaffected. Link to Devin session: https://app.devin.ai/sessions/b242c11bd86e4099aeec8b59bab62143 Requested by: @ericallam --- .server-changes/deprecate-v3-cli-deploys.md | 6 +++++ apps/webapp/app/env.server.ts | 6 +++++ .../services/initializeDeployment.server.ts | 22 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 .server-changes/deprecate-v3-cli-deploys.md diff --git a/.server-changes/deprecate-v3-cli-deploys.md b/.server-changes/deprecate-v3-cli-deploys.md new file mode 100644 index 00000000000..72040b4c5ed --- /dev/null +++ b/.server-changes/deprecate-v3-cli-deploys.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: breaking +--- + +Add server-side deprecation gate for deploys from v3 CLI versions (gated by `DEPRECATE_V3_CLI_DEPLOYS_ENABLED`). v4 CLI deploys are unaffected. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 8d72b2e51b2..b839af6eb5d 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -348,6 +348,12 @@ const EnvironmentSchema = z .int() .default(60 * 1000 * 15), // 15 minutes + // When enabled, reject deploys made by v3 CLI versions (i.e. payloads that + // omit the `type` field). v4 CLI versions always send `type` ("MANAGED" or "V1"), + // so they are unaffected. Defaults to off so detection can run in + // log-only mode before enforcement. + DEPRECATE_V3_CLI_DEPLOYS_ENABLED: z.string().default("0"), + OBJECT_STORE_BASE_URL: z.string().optional(), OBJECT_STORE_BUCKET: z.string().optional(), OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 987925aa709..9ecc25f9941 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -59,6 +59,28 @@ export class InitializeDeploymentService extends BaseService { }; } + // v4 CLI versions always send `payload.type` ("MANAGED" or "V1"). v3 CLI + // versions never do, so the absence of `type` is a reliable signal that + // the request came from a 3.x CLI. Detection always runs (so we can + // observe how many deploys are still using v3), enforcement is gated + // behind DEPRECATE_V3_CLI_DEPLOYS_ENABLED so it can be rolled out safely. + if (!payload.type) { + const enforced = env.DEPRECATE_V3_CLI_DEPLOYS_ENABLED === "1"; + + logger.warn("Detected deploy from deprecated v3 CLI", { + environmentId: environment.id, + projectId: environment.projectId, + organizationId: environment.project.organizationId, + enforced, + }); + + if (enforced) { + throw new ServiceValidationError( + "The trigger.dev CLI v3 is no longer supported for deployments. Please upgrade your project to v4: https://trigger.dev/docs/migrating-from-v3" + ); + } + } + if (payload.type === "UNMANAGED") { throw new ServiceValidationError("UNMANAGED deployments are not supported"); } From be6b4907908027447bf6e94fb8f262597663fa23 Mon Sep 17 00:00:00 2001 From: DKP <8297864+D-K-P@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:06:01 +0100 Subject: [PATCH 020/279] docs: skills page update (#3418) --- docs/skills.mdx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/skills.mdx b/docs/skills.mdx index f12c5c36eb6..1a446e9e180 100644 --- a/docs/skills.mdx +++ b/docs/skills.mdx @@ -2,7 +2,6 @@ title: "Skills" description: "Install Trigger.dev skills to teach any AI coding assistant best practices for writing tasks, agents, and workflows." sidebarTitle: "Skills" -tag: "new" --- ## What are agent skills? @@ -10,7 +9,9 @@ tag: "new" Skills are portable instruction sets that teach AI coding assistants how to use Trigger.dev effectively. Unlike vendor-specific config files (`.cursor/rules`, `CLAUDE.md`), skills use an open standard that works across all major AI assistants. For example, Cursor users and Claude Code users can get the same knowledge from a single install. - Skills are one of three AI tools we provide. You can also install [Agent Rules](/mcp-agent-rules) for client-specific rule sets or the [MCP Server](/mcp-introduction) for live project interaction. See the [comparison table](/building-with-ai#skills-vs-agent-rules-vs-mcp) for details. + Skills are one of three AI tools we provide. You can also install [Agent Rules](/mcp-agent-rules) + for client-specific rule sets or the [MCP Server](/mcp-introduction) for live project interaction. + See the [comparison table](/building-with-ai#skills-vs-agent-rules-vs-mcp) for details. Skills are installed as directories containing a `SKILL.md` file. Each `SKILL.md` includes YAML frontmatter (name, description) and markdown instructions with patterns, examples, and best practices that AI assistants automatically discover and follow. @@ -27,7 +28,6 @@ npx skills add triggerdotdev/skills The result: your AI assistant understands Trigger.dev's specific patterns for exports, schema validation, error handling, retries, and more. - ## Available skills Install all skills at once, or pick the ones relevant to your current work: @@ -42,19 +42,21 @@ npx skills add triggerdotdev/skills --skill trigger-agents npx skills add triggerdotdev/skills --skill trigger-config npx skills add triggerdotdev/skills --skill trigger-realtime npx skills add triggerdotdev/skills --skill trigger-setup +npx skills add triggerdotdev/skills --skill trigger-cost-savings + ``` -| Skill | Use for | Covers | -|-------|---------|--------| -| `trigger-setup` | First time setup, new projects | SDK install, `npx trigger init`, project structure | -| `trigger-tasks` | Writing background tasks, async workflows, scheduled tasks | Triggering, waits, queues, retries, cron, metadata | -| `trigger-agents` | LLM workflows, orchestration, multi-step AI agents | Prompt chaining, routing, parallelization, human-in-the-loop | -| `trigger-realtime` | Live updates, progress indicators, streaming | React hooks, progress bars, streaming AI responses | -| `trigger-config` | Project setup, build configuration | `trigger.config.ts`, extensions (Prisma, FFmpeg, Playwright) | +| Skill | Use for | Covers | +| ---------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- | +| `trigger-setup` | First time setup, new projects | SDK install, `npx trigger init`, project structure | +| `trigger-tasks` | Writing background tasks, async workflows, scheduled tasks | Triggering, waits, queues, retries, cron, metadata | +| `trigger-agents` | LLM workflows, orchestration, multi-step AI agents | Prompt chaining, routing, parallelization, human-in-the-loop | +| `trigger-realtime` | Live updates, progress indicators, streaming | React hooks, progress bars, streaming AI responses | +| `trigger-config` | Project setup, build configuration | `trigger.config.ts`, extensions (Prisma, FFmpeg, Playwright) | +| `trigger-cost-savings` | Cost savings, performance optimization, resource management | Cost optimization, performance optimization, resource management | Not sure which skill to install? Install `trigger-tasks`; it covers the most common patterns for writing Trigger.dev tasks. - ## Supported AI assistants Skills work with any AI coding assistant that supports the [Agent Skills standard](https://agentskills.io), including: @@ -84,4 +86,4 @@ Skills work with any AI coding assistant that supports the [Agent Skills standar Browse the full Agent Skills ecosystem. - \ No newline at end of file + From de3b9a158b2840fd53eeca61e1fab20fba84e752 Mon Sep 17 00:00:00 2001 From: Iss <74388823+isshaddad@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:19:33 -0400 Subject: [PATCH 021/279] docs: document secret env vars and Vercel sync behavior (#3419) --- docs/deploy-environment-variables.mdx | 9 +++++++++ docs/vercel-integration.mdx | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/docs/deploy-environment-variables.mdx b/docs/deploy-environment-variables.mdx index a4c3b75daf4..529c621dec4 100644 --- a/docs/deploy-environment-variables.mdx +++ b/docs/deploy-environment-variables.mdx @@ -30,6 +30,15 @@ We deploy your tasks and scale them up and down when they are triggered. So any locally. +### Secret environment variables + +When creating an environment variable, you can mark it as a **Secret**. Secret values are hidden in the dashboard and cannot be viewed after creation. + + + Marking a variable as a Secret is irreversible and can only be done when creating the variable. To + change this setting, you must delete the variable and create a new one. + + ### Editing environment variables You can edit an environment variable's values. You cannot edit the key name, you must delete and create a new one. diff --git a/docs/vercel-integration.mdx b/docs/vercel-integration.mdx index a465f5aa39b..3c2cc1b9665 100644 --- a/docs/vercel-integration.mdx +++ b/docs/vercel-integration.mdx @@ -104,6 +104,12 @@ The following variables are excluded from the Vercel → Trigger.dev sync: You can control sync behavior per-variable from your project's Vercel settings. Deselecting a variable prevents its value from being updated during future syncs. + + Environment variables are pulled from Vercel before each build. To sync updated values into + Trigger.dev, trigger a new Vercel deployment — either by pushing a commit to your connected branch + or by redeploying from the Vercel dashboard. + + If you are experiencing incorrectly populated environment variables, check that you are not using the `syncVercelEnvVars` build extension in your `trigger.config.ts`. This extension is deprecated From 03e4d5fe317864bc119077c71362218c931f6af3 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 20 Apr 2026 18:28:16 +0100 Subject: [PATCH 022/279] feat(webapp,database): API key rotation grace period (#3420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Regenerating a RuntimeEnvironment API key no longer immediately invalidates the previous one. Rotation is now overlap-based: the old key keeps working for 24 hours so customers can roll it out in their env vars without downtime, then stops working. ## Design - **New `RevokedApiKey` table** (one row per revocation). Holds the archived `apiKey`, a FK to the env, an `expiresAt`, and a `createdAt`. Indexed on `apiKey` (high-cardinality equality — single-row hits) and on `runtimeEnvironmentId`. - **`regenerateApiKey` wraps both writes in a single `$transaction`:** insert a `RevokedApiKey` with `expiresAt = now + 24h`, update the env with the new `apiKey`/`pkApiKey`. - **`findEnvironmentByApiKey` does a two-step lookup:** primary unique-index hit on `RuntimeEnvironment.apiKey` first; on miss, `RevokedApiKey.findFirst({ apiKey, expiresAt: { gt: now } })` with an `include: { runtimeEnvironment }`. Two-step (not `OR`-join) keeps the hot path identical to today and puts the fallback cost only on invalid keys. Both lookups use `$replica`. - **Admin endpoint** `POST /admin/api/v1/revoked-api-keys/:id` accepts `{ expiresAt }` and updates the row. Setting to `now` ends the grace window immediately; setting to the future extends it. - **Modal copy** on the regenerate dialog updated — previously warned of downtime, now explains the 24h overlap. ## Why a separate table instead of columns on `RuntimeEnvironment` - Keeps the hot auth path's primary lookup unchanged — no OR/nullable-apiKey semantics to reason about. - Naturally supports multiple in-flight grace windows (regenerate twice in a day → two old keys valid until their independent expiries). - FK + cascade cleans up correctly when an env is deleted; nothing to backfill. ## Test plan Verified locally against hello-world with dev and prod env keys: - [x] baseline — current key authenticates (`GET /api/v1/runs`) → `200` - [x] regenerate via UI — DB shows old key in `RevokedApiKey` with `expiresAt ≈ now+24h`, env has new key - [x] grace window — both old and new keys → `200`; bogus key → `401` - [x] admin endpoint: `expiresAt = now` → old key `401` - [x] admin endpoint: `expiresAt = +1h` (after early-expire) → old key `200` again - [x] admin endpoint: `expiresAt = past` → old key `401` - [x] admin 400 (invalid body), 404 (unknown id), 401 (missing/non-admin PAT) - [x] same flow exercised end-to-end on a PROD-typed env — behavior identical - [x] `pnpm run typecheck --filter webapp` passes --- .../revoked-api-key-grace-period.md | 6 +++ .../environments/RegenerateApiKeyModal.tsx | 5 +- apps/webapp/app/models/api-key.server.ts | 30 ++++++++--- .../app/models/runtimeEnvironment.server.ts | 53 +++++++++++++------ ...pi.v1.revoked-api-keys.$revokedApiKeyId.ts | 48 +++++++++++++++++ apps/webapp/app/routes/api.v1.auth.jwt.ts | 5 +- .../migration.sql | 24 +++++++++ .../database/prisma/schema.prisma | 15 ++++++ 8 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 .server-changes/revoked-api-key-grace-period.md create mode 100644 apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts create mode 100644 internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql diff --git a/.server-changes/revoked-api-key-grace-period.md b/.server-changes/revoked-api-key-grace-period.md new file mode 100644 index 00000000000..df8727295ea --- /dev/null +++ b/.server-changes/revoked-api-key-grace-period.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Regenerating a RuntimeEnvironment API key no longer invalidates the previous key immediately. The old key is recorded in a new `RevokedApiKey` table with a 24 hour grace window, and `findEnvironmentByApiKey` falls back to it when the submitted key doesn't match any live environment. The grace window can be ended early (or extended) by updating `expiresAt` on the row. diff --git a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx index 439fd892f91..52e1f499cbe 100644 --- a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx +++ b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx @@ -75,8 +75,9 @@ const RegenerateApiKeyModalContent = ({ return (
- {`Regenerating the keys for this environment will temporarily break any live tasks in the - ${title} environment until the new API keys are set in the relevant environment variables.`} + {`A new API key will be issued for the ${title} environment. The previous key stays valid + for 24 hours so you can roll out the new key in your environment variables without downtime. + After 24 hours, the previous key stops working.`} { + await tx.revokedApiKey.create({ + data: { + apiKey: environment.apiKey, + runtimeEnvironmentId: environment.id, + expiresAt: revokedApiKeyExpiresAt, + }, + }); + + return tx.runtimeEnvironment.update({ + data: { + apiKey: newApiKey, + pkApiKey: newPkApiKey, + }, + where: { + id: environmentId, + }, + }); }); return updatedEnviroment; diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index f65112b71fc..c919fe4a618 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -11,27 +11,48 @@ export async function findEnvironmentByApiKey( apiKey: string, branchName: string | undefined ): Promise { - const environment = await $replica.runtimeEnvironment.findFirst({ + const include = { + project: true, + organization: true, + orgMember: true, + childEnvironments: branchName + ? { + where: { + branchName: sanitizeBranchName(branchName), + archivedAt: null, + }, + } + : undefined, + } satisfies Prisma.RuntimeEnvironmentInclude; + + let environment = await $replica.runtimeEnvironment.findFirst({ where: { apiKey, }, - include: { - project: true, - organization: true, - orgMember: true, - childEnvironments: branchName - ? { - where: { - branchName: sanitizeBranchName(branchName), - archivedAt: null, - }, - } - : undefined, - }, + include, }); + // Fall back to keys that were revoked within the grace window + if (!environment) { + const revokedApiKey = await $replica.revokedApiKey.findFirst({ + where: { + apiKey, + expiresAt: { gt: new Date() }, + }, + include: { + runtimeEnvironment: { include }, + }, + }); + + environment = revokedApiKey?.runtimeEnvironment ?? null; + } + + if (!environment) { + return null; + } + //don't return deleted projects - if (environment?.project.deletedAt !== null) { + if (environment.project.deletedAt !== null) { return null; } @@ -43,7 +64,7 @@ export async function findEnvironmentByApiKey( return null; } - const childEnvironment = environment?.childEnvironments.at(0); + const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { return { diff --git a/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts b/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts new file mode 100644 index 00000000000..847828d981f --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts @@ -0,0 +1,48 @@ +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; + +const ParamsSchema = z.object({ + revokedApiKeyId: z.string(), +}); + +const RequestBodySchema = z.object({ + expiresAt: z.coerce.date(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + await requireAdminApiRequest(request); + + const { revokedApiKeyId } = ParamsSchema.parse(params); + + const rawBody = await request.json(); + const parsedBody = RequestBodySchema.safeParse(rawBody); + + if (!parsedBody.success) { + return json({ error: "Invalid request body", issues: parsedBody.error.issues }, { status: 400 }); + } + + const existing = await prisma.revokedApiKey.findFirst({ + where: { id: revokedApiKeyId }, + select: { id: true }, + }); + + if (!existing) { + return json({ error: "Revoked API key not found" }, { status: 404 }); + } + + const updated = await prisma.revokedApiKey.update({ + where: { id: revokedApiKeyId }, + data: { expiresAt: parsedBody.data.expiresAt }, + }); + + return json({ + success: true, + revokedApiKey: { + id: updated.id, + runtimeEnvironmentId: updated.runtimeEnvironmentId, + expiresAt: updated.expiresAt.toISOString(), + }, + }); +} diff --git a/apps/webapp/app/routes/api.v1.auth.jwt.ts b/apps/webapp/app/routes/api.v1.auth.jwt.ts index e495c9b3688..b95b1eb7877 100644 --- a/apps/webapp/app/routes/api.v1.auth.jwt.ts +++ b/apps/webapp/app/routes/api.v1.auth.jwt.ts @@ -36,8 +36,11 @@ export async function action({ request }: LoaderFunctionArgs) { ...parsedBody.data.claims, }; + // Sign with the environment's current canonical key, not the raw header key, + // so JWTs minted with a revoked (grace-window) key still validate — validation + // in jwtAuth.server.ts uses environment.apiKey. const jwt = await internal_generateJWT({ - secretKey: authenticationResult.apiKey, + secretKey: authenticationResult.environment.apiKey, payload: claims, expirationTime: parsedBody.data.expirationTime ?? "1h", }); diff --git a/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql b/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql new file mode 100644 index 00000000000..f3a2ffa199f --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "RevokedApiKey" ( + "id" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + "runtimeEnvironmentId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RevokedApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RevokedApiKey_apiKey_idx" + ON "RevokedApiKey"("apiKey"); + +-- CreateIndex +CREATE INDEX "RevokedApiKey_runtimeEnvironmentId_idx" + ON "RevokedApiKey"("runtimeEnvironmentId"); + +-- AddForeignKey +ALTER TABLE "RevokedApiKey" + ADD CONSTRAINT "RevokedApiKey_runtimeEnvironmentId_fkey" + FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1462752d8dd..9ccf2495d3a 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -355,6 +355,7 @@ model RuntimeEnvironment { prompts Prompt[] errorGroupStates ErrorGroupState[] taskIdentifiers TaskIdentifier[] + revokedApiKeys RevokedApiKey[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -363,6 +364,20 @@ model RuntimeEnvironment { @@index([organizationId]) } +/// Records of previously-valid API keys that are still accepted for authentication +/// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`. +model RevokedApiKey { + id String @id @default(cuid()) + apiKey String + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([apiKey]) + @@index([runtimeEnvironmentId]) +} + enum RuntimeEnvironmentType { PRODUCTION STAGING From b570586899b6c125b1b9cc7b8b892a3675981c83 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:33:17 +0100 Subject: [PATCH 023/279] fix(webapp): allow cancelling runs in DEQUEUED status from the runs list (#3421) The cancel button was missing from the runs list for runs in `DEQUEUED` status. The runs list gates the button on `run.isCancellable`, which goes through `isCancellableRunStatus` -> `CANCELLABLE_RUN_STATUSES` = `NON_FINAL_RUN_STATUSES`. `DEQUEUED` was never added to that list when it was introduced in the run engine. The single run page uses a separate check (`!run.isFinished`, i.e. the inverse of `FINAL_RUN_STATUSES`), so cancellation already worked there - only the list was affected. Adding `DEQUEUED` to `NON_FINAL_RUN_STATUSES` also flips `isCrashableRunStatus` and `isFailableRunStatus`, but: - The crash path is the right behaviour - a `DEQUEUED` run (worker has claimed but not yet executing) can legitimately crash before `EXECUTING`, same as `PENDING`/`DELAYED` already do. - The fail path (`failedTaskRun.server.ts`) is only reached from V1 code paths (marqs consumers, v1 heartbeat handler). `DEQUEUED` is a V2-engine-only status, so V1 consumers never see it. When cancelling a `DEQUEUED` run the execution snapshot goes to `PENDING_CANCEL` (worker must ack) but `TaskRun.status` flips to `CANCELED` immediately - the UI reflects cancellation without waiting for the worker. Added an integration test in `run-engine/src/engine/tests/cancelling.test.ts` covering the full trigger -> dequeue -> cancel -> worker-ack flow. ## Stall safety The stall recovery path (PENDING_EXECUTING heartbeat miss -> nack-and-requeue -> back to QUEUED) lives entirely inside `@internal/run-engine` and never touches the webapp's `taskStatus.ts` helpers - the engine has zero imports from `~/v3/taskStatus` and doesn't know `CrashTaskRunService` / `FailedTaskRunService` exist. A stalled DEQUEUED run still goes back to the queue for retry; this change cannot cause stalls to crash or fail. The only realistic impact is the intended UI fix - the theoretical V1 crash/fail branches for DEQUEUED are unreachable in practice because V1 runs never have DEQUEUED status. --- .server-changes/cancel-dequeued-runs.md | 6 + apps/webapp/app/v3/taskStatus.ts | 1 + .../src/engine/tests/cancelling.test.ts | 127 ++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 .server-changes/cancel-dequeued-runs.md diff --git a/.server-changes/cancel-dequeued-runs.md b/.server-changes/cancel-dequeued-runs.md new file mode 100644 index 00000000000..4e393411010 --- /dev/null +++ b/.server-changes/cancel-dequeued-runs.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Show the cancel button in the runs list for runs in `DEQUEUED` status. `DEQUEUED` was missing from `NON_FINAL_RUN_STATUSES` so the list hid the button even though the single run page allowed it. diff --git a/apps/webapp/app/v3/taskStatus.ts b/apps/webapp/app/v3/taskStatus.ts index b5e1d915cf7..8606bcdafce 100644 --- a/apps/webapp/app/v3/taskStatus.ts +++ b/apps/webapp/app/v3/taskStatus.ts @@ -18,6 +18,7 @@ export const NON_FINAL_RUN_STATUSES = [ "PENDING", "PENDING_VERSION", "WAITING_FOR_DEPLOY", + "DEQUEUED", "EXECUTING", "WAITING_TO_RESUME", "RETRYING_AFTER_FAILURE", diff --git a/internal-packages/run-engine/src/engine/tests/cancelling.test.ts b/internal-packages/run-engine/src/engine/tests/cancelling.test.ts index 1e5947cfbc1..aecae7a2632 100644 --- a/internal-packages/run-engine/src/engine/tests/cancelling.test.ts +++ b/internal-packages/run-engine/src/engine/tests/cancelling.test.ts @@ -322,5 +322,132 @@ describe("RunEngine cancelling", () => { } }); + containerTest("Cancelling a run (dequeued)", async ({ prisma, redisOptions }) => { + //create environment + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + }, + queue: { + redis: redisOptions, + masterQueueConsumersDisabled: true, + processWorkerQueueDebounceMs: 50, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const parentTask = "parent-task"; + + //create background worker + await setupBackgroundWorker(engine, authenticatedEnvironment, [parentTask]); + + //trigger the run + const parentRun = await engine.trigger( + { + number: 1, + friendlyId: "run_p1234", + environment: authenticatedEnvironment, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + workerQueue: "main", + queue: `task/${parentTask}`, + isTest: false, + tags: [], + }, + prisma + ); + + //dequeue the run, but don't start an attempt — this leaves TaskRun.status = DEQUEUED + //and execution snapshot = PENDING_EXECUTING (a worker has claimed the run) + await setTimeout(500); + const dequeued = await engine.dequeueFromWorkerQueue({ + consumerId: "test_12345", + workerQueue: "main", + }); + expect(dequeued.length).toBe(1); + + const dequeuedRun = await prisma.taskRun.findFirstOrThrow({ + where: { id: parentRun.id }, + }); + expect(dequeuedRun.status).toBe("DEQUEUED"); + + //cancel the dequeued run — a worker has already claimed it, so the snapshot goes to + //PENDING_CANCEL pending the worker ack. TaskRun.status flips to CANCELED immediately + //so the UI reflects cancellation without waiting. + const result = await engine.cancelRun({ + runId: parentRun.id, + completedAt: new Date(), + reason: "Cancelled by the user", + }); + expect(result.snapshot.executionStatus).toBe("PENDING_CANCEL"); + + const pendingCancel = await engine.getRunExecutionData({ runId: parentRun.id }); + expect(pendingCancel?.snapshot.executionStatus).toBe("PENDING_CANCEL"); + expect(pendingCancel?.run.status).toBe("CANCELED"); + + let cancelledEventData: EventBusEventArgs<"runCancelled">[0][] = []; + engine.eventBus.on("runCancelled", (result) => { + cancelledEventData.push(result); + }); + + //simulate worker acknowledging the cancellation + const completeResult = await engine.completeRunAttempt({ + runId: parentRun.id, + snapshotId: pendingCancel!.snapshot.id, + completion: { + ok: false, + id: parentRun.id, + error: { + type: "INTERNAL_ERROR" as const, + code: "TASK_RUN_CANCELLED" as const, + }, + }, + }); + expect(completeResult.snapshot.executionStatus).toBe("FINISHED"); + expect(completeResult.run.status).toBe("CANCELED"); + + //check emitted event after worker ack + expect(cancelledEventData.length).toBe(1); + const parentEvent = cancelledEventData.find((r) => r.run.id === parentRun.id); + assertNonNullable(parentEvent); + expect(parentEvent.run.spanId).toBe(parentRun.spanId); + + //concurrency should have been released + const envConcurrencyCompleted = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + expect(envConcurrencyCompleted).toBe(0); + } finally { + await engine.quit(); + } + }); + //todo bulk cancelling runs }); From 7c95ee498e44b59c9694216e213e882a7a42a2d4 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 21 Apr 2026 16:56:17 +0100 Subject: [PATCH 024/279] feat(webapp): tag Prisma spans with db.datasource attribute (#3422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stamp every Prisma span with `db.datasource: "writer" | "replica"` so traces can distinguish which client the query went through. Both `PrismaClient` instances share the same global `@prisma/instrumentation`, so their spans come out with identical names and attributes today. This makes them trivially filterable. ## How Two pieces in `apps/webapp/app/`: 1. **`v3/tracer.server.ts`** — a `DatasourceAttributeSpanProcessor` reads an OTel context key in `onStart` and calls `span.setAttribute("db.datasource", value)`. Registered as the first span processor. 2. **`db.server.ts`** — `tagDatasource(datasource, client)` wraps each `PrismaClient` with `$extends({ query: { $allOperations } })`. The middleware sets the context key around the query and directly tags the active span (to catch `prisma:client:operation`, which Prisma creates before the middleware fires). ### Context-propagation gotcha `PrismaPromise` is lazy — `query(args)` returns a thenable that only starts when someone `.then()`s it. The naive `context.with(ctx, () => query(args))` restores ALS synchronously, so when Prisma's internal code awaits the thenable later, the engine spans fire with the original ALS. Wrapping as `async () => await query(args)` forces the `.then()` inside the `context.with` callback, so ALS stays on our context for the engine spans. ### Coverage - **Tagged**: all `prisma:engine:*` (`connection`, `db_query`, `serialize`, `query`, etc.), `prisma:client:operation`, `prisma:client:serialize`, `prisma:client:connect` - **Not tagged**: `prisma:client:load_engine` — one-time startup, fires before any query Concurrent `Promise.all([writer.x, replica.y])` correctly tags each pool separately (ALS isolates per-Promise chain). ### Performance One `context.with` (~200ns) and one `setAttribute` per span (effectively free per OTel JS benchmarks) per Prisma op. Negligible against a query path measured in milliseconds. ## Test plan - [ ] Verify `db.datasource` appears on `prisma:engine:connection` spans after the webapp is restarted - [ ] Spot-check a handful of real traces carry the attribute --- .../prisma-span-datasource-attribute.md | 6 ++++ apps/webapp/app/db.server.ts | 32 +++++++++++++++---- apps/webapp/app/v3/tracer.server.ts | 21 +++++++++++- references/hello-world/src/trigger/example.ts | 6 +++- 4 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 .server-changes/prisma-span-datasource-attribute.md diff --git a/.server-changes/prisma-span-datasource-attribute.md b/.server-changes/prisma-span-datasource-attribute.md new file mode 100644 index 00000000000..86507b89790 --- /dev/null +++ b/.server-changes/prisma-span-datasource-attribute.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Tag Prisma spans with `db.datasource: "writer" | "replica"` so monitors and trace queries can distinguish the writer pool from the replica pool. Applies to all `prisma:engine:*` spans (including `prisma:engine:connection` used by the connection-pool monitors) and the outer `prisma:client:operation` span. diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 4668b58fb02..96f6307f576 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -13,8 +13,8 @@ import { env } from "./env.server"; import { logger } from "./services/logger.server"; import { isValidDatabaseUrl } from "./utils/db"; import { singleton } from "./utils/singleton"; -import { startActiveSpan } from "./v3/tracer.server"; -import { Span } from "@opentelemetry/api"; +import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server"; +import { context, Span, trace } from "@opentelemetry/api"; import { queryPerformanceMonitor } from "./utils/queryPerformanceMonitor.server"; export type { @@ -98,12 +98,30 @@ export async function $transaction( export { Prisma }; -export const prisma = singleton("prisma", getClient); +function tagDatasource( + datasource: "writer" | "replica", + client: T +): T { + return client.$extends({ + name: "datasource-tagger", + query: { + $allOperations: ({ query, args }) => { + trace.getActiveSpan()?.setAttribute("db.datasource", datasource); + return context.with( + context.active().setValue(DATASOURCE_CONTEXT_KEY, datasource), + async () => await query(args) + ); + }, + }, + }) as unknown as T; +} -export const $replica: PrismaReplicaClient = singleton( - "replica", - () => getReplicaClient() ?? prisma -); +export const prisma = singleton("prisma", () => tagDatasource("writer", getClient())); + +export const $replica: PrismaReplicaClient = singleton("replica", () => { + const replica = getReplicaClient(); + return replica ? tagDatasource("replica", replica) : prisma; +}); function getClient() { const { DATABASE_URL } = process.env; diff --git a/apps/webapp/app/v3/tracer.server.ts b/apps/webapp/app/v3/tracer.server.ts index 71e14521e50..2ce5aa275c7 100644 --- a/apps/webapp/app/v3/tracer.server.ts +++ b/apps/webapp/app/v3/tracer.server.ts @@ -1,6 +1,7 @@ import { type Attributes, type Context, + createContextKey, DiagConsoleLogger, DiagLogLevel, type Link, @@ -61,6 +62,24 @@ import { performance } from "node:perf_hooks"; export const SEMINTATTRS_FORCE_RECORDING = "forceRecording"; +export const DATASOURCE_CONTEXT_KEY = createContextKey("trigger.db.datasource"); + +class DatasourceAttributeSpanProcessor implements SpanProcessor { + onStart(span: Span, parentContext: Context): void { + const ds = parentContext.getValue(DATASOURCE_CONTEXT_KEY); + if (typeof ds === "string") { + span.setAttribute("db.datasource", ds); + } + } + onEnd(): void {} + shutdown(): Promise { + return Promise.resolve(); + } + forceFlush(): Promise { + return Promise.resolve(); + } +} + class CustomWebappSampler implements Sampler { constructor(private readonly _baseSampler: Sampler) {} @@ -205,7 +224,7 @@ function setupTelemetry() { const samplingRate = 1.0 / Math.max(parseInt(env.INTERNAL_OTEL_TRACE_SAMPLING_RATE, 10), 1); - const spanProcessors: SpanProcessor[] = []; + const spanProcessors: SpanProcessor[] = [new DatasourceAttributeSpanProcessor()]; if (env.INTERNAL_OTEL_TRACE_EXPORTER_URL) { const headers = parseInternalTraceHeaders() ?? {}; diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 5e04f57144f..a3070d56339 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -1,4 +1,4 @@ -import { batch, logger, task, tasks, timeout, wait } from "@trigger.dev/sdk"; +import { batch, logger, task, tasks, timeout, wait, waitUntil } from "@trigger.dev/sdk"; import { setTimeout } from "timers/promises"; import { ResourceMonitor } from "../resourceMonitor.js"; import { fixedLengthTask } from "./batches.js"; @@ -21,6 +21,10 @@ export const helloWorldTask = task({ env: process.env, }); + waitUntil((async () => { + logger.info("Hello, world from the waitUntil hook", { payload }); + })()); + logger.debug("debug: Hello, worlds!", { payload }); logger.info("info: Hello, world!", { payload }); logger.log("log: Hello, world!", { payload }); From 2d3b2e82e68fd892fdbddbf020b5183dc471d311 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 22 Apr 2026 11:48:04 +0100 Subject: [PATCH 025/279] feat(run-engine): flag to route getSnapshotsSince through read replica (#3423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED` (default `"0"`). When enabled, the Prisma reads inside `RunEngine.getSnapshotsSince` run against the read-only replica client instead of the primary. Offloads the snapshot-polling queries fired by every running task runner off the writer. ## Why `getSnapshotsSince` is called from the managed runner's fetch-and-process loop (once per poll interval, plus on every snapshot-change notification). It runs four sequential reads per call — one `findFirst` by snapshot id, one `findMany` on snapshots with `createdAt > X`, one raw SQL against `_completedWaitpoints`, and chunked `findMany` on `waitpoint`. Per concurrent run, every few seconds. It's read-only, tolerates a small amount of staleness, and is an obvious candidate for the replica. ## Replica-lag considerations - **Step 1 "since snapshot not found"**: if the runner just received a snapshot id from the primary and asks the replica before it replicates, the function throws and the caller treats the response as an error (runner falls back to a metadata refresh). Self-correcting, not silent. - **Step 2 missing newly-created snapshots**: the next poll's `createdAt > sinceSnapshot.createdAt` filter still picks them up once the replica catches up. - **Waitpoint junction race**: the riskiest path — if a latest snapshot is replicated but its `_completedWaitpoints` join rows aren't yet, the runner could advance past that snapshot with `completedWaitpoints: []`. WAL/storage-level replication replays commits in order, so in practice both should appear atomically on the reader, but the race window is why the flag ships disabled. Aurora reader shrinks all three windows to single-digit ms in typical conditions, and its storage-level replication gives atomic visibility of committed transactions on the reader. ## Test plan - [ ] Flip the flag on in a non-prod environment, confirm snapshot polling behaves normally and `getSnapshotsSince` errors in Sentry stay flat. - [ ] Verify writer query volume drops and reader query volume rises on the snapshot-polling queries. - [ ] Keep an eye on `AuroraReplicaLag` (or equivalent) during rollout. --- .server-changes/read-replica-snapshots-since.md | 6 ++++++ apps/webapp/app/env.server.ts | 1 + apps/webapp/app/v3/runEngine.server.ts | 2 ++ internal-packages/run-engine/src/engine/index.ts | 3 ++- internal-packages/run-engine/src/engine/types.ts | 4 ++++ 5 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .server-changes/read-replica-snapshots-since.md diff --git a/.server-changes/read-replica-snapshots-since.md b/.server-changes/read-replica-snapshots-since.md new file mode 100644 index 00000000000..24f4f070c7d --- /dev/null +++ b/.server-changes/read-replica-snapshots-since.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Add `RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED` flag (default off) to route the Prisma reads inside `RunEngine.getSnapshotsSince` through the read-only replica client. Offloads the snapshot polling queries (fired by every running task runner) from the primary. When disabled, behavior is unchanged. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index b839af6eb5d..ba40624058f 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -835,6 +835,7 @@ const EnvironmentSchema = z .enum(["log", "error", "warn", "info", "debug"]) .default("info"), RUN_ENGINE_TREAT_PRODUCTION_EXECUTION_STALLS_AS_OOM: z.string().default("0"), + RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED: z.string().default("0"), /** How long should the presence ttl last */ DEV_PRESENCE_SSE_TIMEOUT: z.coerce.number().int().default(30_000), diff --git a/apps/webapp/app/v3/runEngine.server.ts b/apps/webapp/app/v3/runEngine.server.ts index b55bc352a24..8db60aed1ac 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -19,6 +19,8 @@ function createRunEngine() { logLevel: env.RUN_ENGINE_WORKER_LOG_LEVEL, treatProductionExecutionStallsAsOOM: env.RUN_ENGINE_TREAT_PRODUCTION_EXECUTION_STALLS_AS_OOM === "1", + readReplicaSnapshotsSinceEnabled: + env.RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED === "1", worker: { disabled: env.RUN_ENGINE_WORKER_ENABLED === "0", workers: env.RUN_ENGINE_WORKER_COUNT, diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index b3e85b7839f..92cf7365a9c 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1633,7 +1633,8 @@ export class RunEngine { snapshotId: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = + tx ?? (this.options.readReplicaSnapshotsSinceEnabled ? this.readOnlyPrisma : this.prisma); try { const snapshots = await getExecutionSnapshotsSince(prisma, runId, snapshotId); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index cd90e0b8ac4..255643ef2f5 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -145,6 +145,10 @@ export type RunEngineOptions = { /** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL * will use this as their TTL, and runs with a TTL larger than this will be clamped. */ defaultMaxTtl?: string; + /** When true, `getSnapshotsSince` reads through the read-only replica client instead + * of the primary. Defaults to false. Callers passing an explicit `tx` always use + * that client regardless of this flag. */ + readReplicaSnapshotsSinceEnabled?: boolean; tracer: Tracer; meter?: Meter; logger?: Logger; From 8eb596f3fe89407e4b5068fe3275f7395425b43c Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Wed, 22 Apr 2026 19:01:34 +0200 Subject: [PATCH 026/279] fix(vercel): Fix vercel settings page (#3424) --- ...ettings-fix-and-onboarding-improvements.md | 6 ++++++ .../integrations/VercelBuildSettings.tsx | 7 +++++-- .../integrations/VercelOnboardingModal.tsx | 20 ++++++++++++++++++- ...cts.$projectParam.env.$envParam.github.tsx | 10 ++++++++-- ...cts.$projectParam.env.$envParam.vercel.tsx | 7 +------ 5 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 .server-changes/vercel-settings-fix-and-onboarding-improvements.md diff --git a/.server-changes/vercel-settings-fix-and-onboarding-improvements.md b/.server-changes/vercel-settings-fix-and-onboarding-improvements.md new file mode 100644 index 00000000000..a78a9012432 --- /dev/null +++ b/.server-changes/vercel-settings-fix-and-onboarding-improvements.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix Vercel integration settings page (remove redundant section toggles) and improve the Vercel onboarding flow so the modal closes after connecting a GitHub repo and the marketplace `next` URL is preserved across the GitHub app install redirect. diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index d665a53f1a1..92d0d0a9992 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -23,6 +23,8 @@ type BuildSettingsFieldsProps = { disabledEnvSlugs?: Partial>; autoPromote?: boolean; onAutoPromoteChange?: (value: boolean) => void; + /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ + hideSectionToggles?: boolean; }; export function BuildSettingsFields({ @@ -37,6 +39,7 @@ export function BuildSettingsFields({ disabledEnvSlugs, autoPromote, onAutoPromoteChange, + hideSectionToggles, }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s)); @@ -48,7 +51,7 @@ export function BuildSettingsFields({
- {availableEnvSlugs.length > 1 && ( + {!hideSectionToggles && availableEnvSlugs.length > 1 && (
- {availableEnvSlugs.length > 1 && ( + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( { + if (state === "github-connection" && isGitHubConnectedForOnboarding) { + trackOnboarding("vercel onboarding github completed"); + if (fromMarketplaceContext && nextUrl) { + const validUrl = safeRedirectUrl(nextUrl); + if (validUrl) { + window.location.href = validUrl; + return; + } + } + setState("completed"); + } + }, [state, isGitHubConnectedForOnboarding, fromMarketplaceContext, nextUrl, trackOnboarding]); + useEffect(() => { if (state === "completed" && !hasTrackedCompletionRef.current) { hasTrackedCompletionRef.current = true; @@ -1114,6 +1128,7 @@ export function VercelOnboardingModal({ redirectParams.set("next", nextUrl); } const redirectUrlWithContext = `${baseSettingsPath}?${redirectParams.toString()}`; + const nextDirectRedirect = nextUrl ? safeRedirectUrl(nextUrl) : null; return gitHubAppInstallations.length === 0 ? (
@@ -1137,7 +1152,10 @@ export function VercelOnboardingModal({ organizationSlug={organizationSlug} projectSlug={projectSlug} environmentSlug={environmentSlug} - redirectUrl={redirectUrlWithContext} + redirectUrl={ + nextDirectRedirect ?? + (fromMarketplaceContext ? redirectUrlWithContext : baseSettingsPath) + } preventDismiss={fromMarketplaceContext} /> diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index 15062a718bc..fe1b32f8925 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -865,8 +865,14 @@ export function GitHubSettingsPanel({ const fetcher = useTypedFetcher(); const location = useLocation(); - // Use provided redirectUrl or fall back to current path (without search params) - const effectiveRedirectUrl = location.pathname; + // Preserve current search params (e.g. origin=marketplace, next=...) but strip + // openGithubRepoModal so the modal doesn't re-open in a loop after the action redirect. + const effectiveRedirectUrl = (() => { + const params = new URLSearchParams(location.search); + params.delete("openGithubRepoModal"); + const search = params.toString(); + return search ? `${location.pathname}?${search}` : location.pathname; + })(); useEffect(() => { fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug)); }, [organizationSlug, projectSlug, environmentSlug]); 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 d6131083bdf..a37d85b0a56 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 @@ -819,6 +819,7 @@ function ConnectedVercelProjectForm({ onAutoPromoteChange={(value) => setConfigValues((prev) => ({ ...prev, autoPromote: value })) } + hideSectionToggles /> {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} @@ -904,12 +905,6 @@ function VercelSettingsPanel({ } }, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]); - useEffect(() => { - if (hasFetched && fetcher.state === "idle" && fetcher.data === undefined && !hasError) { - setHasError(true); - } - }, [fetcher.state, fetcher.data, hasError, hasFetched]); - if (hasError) { return (
From fc71e7dd759c011046a103dc4115b778ca79df61 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 23 Apr 2026 13:45:41 +0100 Subject: [PATCH 027/279] fix: handle fast-completion race in batch streaming seal check (#3427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When `batchTrigger()` is called with large payloads, each item's payload is uploaded to R2 server-side during the streaming loop before being enqueued. This makes the loop slow — around 3 seconds per item. Workers pick up and execute each item as it's enqueued, running concurrently with the ongoing stream. For the last item in the batch, a race exists between the streaming loop finishing and the batch completion cleanup: 1. The loop enqueues the last item and returns from `enqueueBatchItem()` 2. A waiting worker picks up the item almost instantly and executes it 3. `recordSuccess()` fires, `processedCount` hits the expected total, `finalizeBatch()` runs 4. `cleanup()` deletes all Redis keys for the batch, including `enqueuedItemsKey` 5. The streaming loop exits and calls `getBatchEnqueuedCount()` — reads the now-deleted key — returns 0 The count check finds `enqueuedCount (0) !== batch.runCount`, falls through to a Postgres fallback, but the fallback only checked `sealed`. The BatchQueue completion path sets `status = COMPLETED` in Postgres without setting `sealed = true` (that's the streaming endpoint's job), so the fallback misses it too. This causes the endpoint to return `sealed: false`. The SDK treats this as retryable and retries up to 5 times with exponential backoff. Each retry calls `enqueueBatchItem()`, which reads the batch meta key from Redis — also deleted by `cleanup()` — and throws "Batch not found or not initialized" (500). The final retry gets a 422 because the batch is already COMPLETED, which the SDK does not retry, causing an `ApiError` to be thrown from `await batchTrigger()` in the parent run — even though all child runs completed successfully. ## Fix In the Postgres fallback inside `StreamBatchItemsService`, also check `status === "COMPLETED"` alongside `sealed`. This covers the fast-completion path where the BatchQueue finishes all runs before the streaming endpoint gets to seal the batch normally. Also switches `findUnique` to `findFirst` per webapp convention. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../services/streamBatchItems.server.ts | 37 ++++-- .../test/engine/streamBatchItems.test.ts | 124 ++++++++++++++++++ 2 files changed, 151 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/runEngine/services/streamBatchItems.server.ts b/apps/webapp/app/runEngine/services/streamBatchItems.server.ts index 859dfe2e6b9..79c84eb540d 100644 --- a/apps/webapp/app/runEngine/services/streamBatchItems.server.ts +++ b/apps/webapp/app/runEngine/services/streamBatchItems.server.ts @@ -212,15 +212,18 @@ export class StreamBatchItemsService extends WithRunEngine { // Validate we received the expected number of items if (enqueuedCount !== batch.runCount) { // The batch queue consumers may have already processed all items and - // cleaned up the Redis keys before we got here (especially likely when - // items include pre-failed runs that complete instantly). Check if the - // batch was already sealed/completed in Postgres. - const currentBatch = await this._prisma.batchTaskRun.findUnique({ + // cleaned up the Redis keys before we got here. This happens when all + // runs complete fast enough that cleanup() deletes the enqueuedItemsKey + // before we read it — typically when the last item executes in the + // milliseconds between the loop ending and getBatchEnqueuedCount() being called. + // Check both sealed (sealed by this endpoint on a concurrent request) and + // COMPLETED (sealed by the BatchQueue completion path before we got here). + const currentBatch = await this._prisma.batchTaskRun.findFirst({ where: { id: batchId }, select: { sealed: true, status: true }, }); - if (currentBatch?.sealed) { + if (currentBatch?.sealed || currentBatch?.status === "COMPLETED") { logger.info("Batch already sealed before count check (fast completion)", { batchId: batchFriendlyId, itemsAccepted, @@ -279,8 +282,18 @@ export class StreamBatchItemsService extends WithRunEngine { // Check if we won the race to seal the batch if (sealResult.count === 0) { - // Another request sealed the batch first - re-query to check current state - const currentBatch = await this._prisma.batchTaskRun.findUnique({ + // The conditional update failed because the batch was no longer in + // PENDING status. Re-query to determine which path got there first: + // - A concurrent streaming request already sealed and moved it to + // PROCESSING. + // - The BatchQueue completion path finished all runs and set it to + // COMPLETED (without setting sealed=true — that's this endpoint's + // job). This window exists between completionCallback (which calls + // tryCompleteBatch) and cleanup() in BatchQueue — see + // batch-queue/index.ts. + // Either way the goal — a durable batch that the SDK stops retrying — + // has been achieved, so we return sealed: true. + const currentBatch = await this._prisma.batchTaskRun.findFirst({ where: { id: batchId }, select: { id: true, @@ -290,13 +303,17 @@ export class StreamBatchItemsService extends WithRunEngine { }, }); - if (currentBatch?.sealed && currentBatch.status === "PROCESSING") { - // The batch was sealed by another request - this is fine, the goal was achieved - logger.info("Batch already sealed by concurrent request", { + if ( + (currentBatch?.sealed && currentBatch.status === "PROCESSING") || + currentBatch?.status === "COMPLETED" + ) { + logger.info("Batch already sealed/completed by concurrent path", { batchId: batchFriendlyId, itemsAccepted, itemsDeduplicated, envId: environment.id, + batchStatus: currentBatch.status, + batchSealed: currentBatch.sealed, }); span.setAttribute("itemsAccepted", itemsAccepted); diff --git a/apps/webapp/test/engine/streamBatchItems.test.ts b/apps/webapp/test/engine/streamBatchItems.test.ts index 2dee8668762..6e5f2264b1f 100644 --- a/apps/webapp/test/engine/streamBatchItems.test.ts +++ b/apps/webapp/test/engine/streamBatchItems.test.ts @@ -384,6 +384,130 @@ describe("StreamBatchItemsService", () => { } ); + containerTest( + "should return sealed=true when batch is COMPLETED by BatchQueue before seal attempt", + async ({ prisma, redisOptions }) => { + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + disabled: true, + }, + queue: { + redis: redisOptions, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0005, + }, + batchQueue: { + redis: redisOptions, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + + // Create a batch in PENDING state + const batch = await createBatch(prisma, authenticatedEnvironment.id, { + runCount: 2, + status: "PENDING", + sealed: false, + }); + + // Initialize the batch in Redis + await engine.initializeBatch({ + batchId: batch.id, + friendlyId: batch.friendlyId, + environmentId: authenticatedEnvironment.id, + environmentType: authenticatedEnvironment.type, + organizationId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + runCount: 2, + processingConcurrency: 10, + }); + + // Enqueue items - the enqueued count check passes but the seal updateMany + // will race with tryCompleteBatch moving status to COMPLETED. + await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, { + task: "test-task", + payload: JSON.stringify({ data: "item1" }), + payloadType: "application/json", + }); + await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, { + task: "test-task", + payload: JSON.stringify({ data: "item2" }), + payloadType: "application/json", + }); + + // Simulate the race where BatchQueue's completionCallback runs + // tryCompleteBatch between getEnqueuedCount and the seal updateMany. + // tryCompleteBatch sets status=COMPLETED but NOT sealed=true. + const racingPrisma = { + ...prisma, + batchTaskRun: { + ...prisma.batchTaskRun, + findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun), + updateMany: async () => { + await prisma.batchTaskRun.update({ + where: { id: batch.id }, + data: { + status: "COMPLETED", + }, + }); + // The conditional updateMany(where: status="PENDING") would now fail + return { count: 0 }; + }, + findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun), + }, + } as unknown as PrismaClient; + + const service = new StreamBatchItemsService({ + prisma: racingPrisma, + engine, + }); + + const result = await service.call( + authenticatedEnvironment, + batch.friendlyId, + itemsToAsyncIterable([]), + { + maxItemBytes: 1024 * 1024, + } + ); + + // The endpoint should accept the COMPLETED state as a success case so the + // SDK does not retry a batch whose child runs have already finished. + expect(result.sealed).toBe(true); + expect(result.id).toBe(batch.friendlyId); + + const updatedBatch = await prisma.batchTaskRun.findUnique({ + where: { id: batch.id }, + }); + + expect(updatedBatch?.status).toBe("COMPLETED"); + // sealed stays false because the BatchQueue completion path does not set + // it - that's fine, the batch is terminal. + expect(updatedBatch?.sealed).toBe(false); + + await engine.quit(); + } + ); + containerTest( "should throw error when race condition leaves batch in unexpected state", async ({ prisma, redisOptions }) => { From 87b671653597a5278cdf2f71f3c5b7a90038be05 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:08:54 +0100 Subject: [PATCH 028/279] fix(helm): support webapp serviceAccount annotations for IRSA (#3429) Mirrors the existing `supervisor.serviceAccount` pattern onto webapp so operators can annotate the SA (IRSA `eks.amazonaws.com/role-arn`, Workload Identity, etc.) or bring their own SA. Without this, `webapp.serviceAccount.annotations` isn't exposed and operators have to patch the SA out-of-band. ```yaml webapp: serviceAccount: create: true name: "" annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/trigger-webapp ``` Three pieces, same as supervisor: - `webapp.serviceAccount.create` toggle on the SA block - `webapp.serviceAccount.annotations` + `name` values - `trigger-v4.webappServiceAccountName` helper, used by the SA, the token-syncer RoleBinding subject, and the Deployment's `serviceAccountName` Role + RoleBinding are left unguarded (matching supervisor's shape where `rbac.create` is a separate toggle from `serviceAccount.create`) - BYO-SA users take on the responsibility of ensuring the SA they supply has the permissions the RoleBinding grants. Verified with `helm template` against default values, an IRSA annotation override, and `create: false` with a custom name. --- .github/workflows/pr_checks.yml | 1 + hosting/k8s/helm/Chart.yaml | 2 +- hosting/k8s/helm/templates/_helpers.tpl | 25 +++++++++++++++++++++++-- hosting/k8s/helm/templates/webapp.yaml | 12 +++++++++--- hosting/k8s/helm/values.yaml | 9 +++++++++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index dab18223e35..12da89db3b2 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -6,6 +6,7 @@ on: paths-ignore: - "docs/**" - ".changeset/**" + - "hosting/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index 155dc1bf771..ac8e81b9f4b 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: trigger description: The official Trigger.dev Helm chart type: application -version: 4.0.5 +version: 4.0.6 appVersion: v4.0.4 home: https://trigger.dev sources: diff --git a/hosting/k8s/helm/templates/_helpers.tpl b/hosting/k8s/helm/templates/_helpers.tpl index cb148678c92..09901518086 100644 --- a/hosting/k8s/helm/templates/_helpers.tpl +++ b/hosting/k8s/helm/templates/_helpers.tpl @@ -521,13 +521,34 @@ http://{{ include "trigger-v4.fullname" . }}-supervisor:{{ .Values.supervisor.se {{- end }} {{/* -Create the name of the supervisor service account to use +Create the name of the supervisor service account to use. +When create is false, name must be set explicitly - falling back to the namespace's +default ServiceAccount would silently grant it the RoleBinding's permissions. */}} {{- define "trigger-v4.supervisorServiceAccountName" -}} {{- if .Values.supervisor.serviceAccount.create }} {{- default (printf "%s-supervisor" (include "trigger-v4.fullname" .)) .Values.supervisor.serviceAccount.name }} {{- else }} -{{- default "default" .Values.supervisor.serviceAccount.name }} +{{- if not .Values.supervisor.serviceAccount.name }} +{{- fail "supervisor.serviceAccount.name must be set when supervisor.serviceAccount.create is false" }} +{{- end }} +{{- .Values.supervisor.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the name of the webapp service account to use. +When create is false, name must be set explicitly - falling back to the namespace's +default ServiceAccount would silently grant it the token-syncer RoleBinding's permissions. +*/}} +{{- define "trigger-v4.webappServiceAccountName" -}} +{{- if .Values.webapp.serviceAccount.create }} +{{- default (printf "%s-webapp" (include "trigger-v4.fullname" .)) .Values.webapp.serviceAccount.name }} +{{- else }} +{{- if not .Values.webapp.serviceAccount.name }} +{{- fail "webapp.serviceAccount.name must be set when webapp.serviceAccount.create is false" }} +{{- end }} +{{- .Values.webapp.serviceAccount.name }} {{- end }} {{- end }} diff --git a/hosting/k8s/helm/templates/webapp.yaml b/hosting/k8s/helm/templates/webapp.yaml index 0dd1bddbc41..721e5e60705 100644 --- a/hosting/k8s/helm/templates/webapp.yaml +++ b/hosting/k8s/helm/templates/webapp.yaml @@ -1,10 +1,16 @@ +{{- if .Values.webapp.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "trigger-v4.fullname" . }}-webapp + name: {{ include "trigger-v4.webappServiceAccountName" . }} labels: {{- $component := "webapp" }} {{- include "trigger-v4.componentLabels" (dict "Chart" .Chart "Release" .Release "Values" .Values "component" $component) | nindent 4 }} + {{- with .Values.webapp.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role @@ -27,7 +33,7 @@ metadata: {{- include "trigger-v4.componentLabels" (dict "Chart" .Chart "Release" .Release "Values" .Values "component" $component) | nindent 4 }} subjects: - kind: ServiceAccount - name: {{ include "trigger-v4.fullname" . }}-webapp + name: {{ include "trigger-v4.webappServiceAccountName" . }} namespace: {{ .Release.Namespace }} roleRef: kind: Role @@ -56,7 +62,7 @@ spec: labels: {{- include "trigger-v4.componentSelectorLabels" (dict "Chart" .Chart "Release" .Release "Values" .Values "component" $component) | nindent 8 }} spec: - serviceAccountName: {{ include "trigger-v4.fullname" . }}-webapp + serviceAccountName: {{ include "trigger-v4.webappServiceAccountName" . }} {{- with .Values.global.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} diff --git a/hosting/k8s/helm/values.yaml b/hosting/k8s/helm/values.yaml index 262ffa4b8ed..3ed254397e7 100644 --- a/hosting/k8s/helm/values.yaml +++ b/hosting/k8s/helm/values.yaml @@ -208,6 +208,15 @@ webapp: runReplication: logLevel: "info" # one of: log, error, warn, info, debug + # ServiceAccount configuration + serviceAccount: + create: true + # Name of the ServiceAccount to use. Required when create is false - otherwise + # the token-syncer RoleBinding would bind to the namespace's "default" SA. + name: "" + # Annotations to add to the ServiceAccount (e.g. eks.amazonaws.com/role-arn for IRSA) + annotations: {} + # Observability configuration (OTel) observability: tracing: From 486f49791d4307042426e6f54400d2c2f57fe276 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 23 Apr 2026 14:54:05 +0100 Subject: [PATCH 029/279] fix(webapp): eliminate SSE abort-signal memory leak (#3430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a server-side memory leak in the webapp's SSE helper. Every aborted SSE connection (client tab close, navigation, timeout) was pinning its full request/response graph indefinitely on Node 20, so any long-running webapp process accumulated retained memory proportional to streaming-request churn. ## Root cause `apps/webapp/app/utils/sse.ts` combined four abort signals via `AbortSignal.any([requestAbortSignal, timeoutSignal, internalController.signal])`. The composite signal tracks its source signals in an internal `Set` registered against a `FinalizationRegistry`; under sustained traffic those entries accumulate faster than they're cleaned up, pinning every source signal (and its listeners, and anything those listeners close over) until the parent signal itself is GC'd or aborts. This is a long-standing Node issue with multiple open reports: - [nodejs/node#54614](https://github.com/nodejs/node/issues/54614) — original report, still open. A [follow-up from ChainSafe](https://github.com/nodejs/node/issues/54614#issuecomment-4055656572) describes the exact same shape in a Lodestar production workload (req + timeout signals composed per request accumulating in long-running worker) and the same mitigation: drop `AbortSignal.any`, compose manually. - [nodejs/node#55351](https://github.com/nodejs/node/issues/55351) — mechanism confirmed by Node member @jasnell: *"the set of dependent signals known to the AbortSignal are kept in an internal Set using WeakRefs. The AbortSignals are being properly gc'd but the Set is never cleaned out of the WeakRefs making those leak."* Partially fixed by [PR #55354](https://github.com/nodejs/node/pull/55354), shipped in Node 22.12.0 — but only covers the tight-loop case, not long-lived parent signals. - [nodejs/node#57584](https://github.com/nodejs/node/issues/57584) — circular-dependency variant, still open. - [nodejs/node#62363](https://github.com/nodejs/node/issues/62363) — regression in Node 24/25 from an unrelated V8 change ("Don't pretenure WeakCells"). Different root cause, same symptom. A separate issue in `apps/webapp/app/entry.server.tsx` — `setTimeout(abort, ABORT_DELAY)` with no `clearTimeout` on success paths — kept the React render tree + `remixContext` alive for 30s per successful HTML request. Same pattern fixed upstream in React Router templates ([react-router#14200](https://github.com/remix-run/react-router/pull/14200)), never backported to Remix v2. ## What changed - **`apps/webapp/app/utils/sse.ts`** — single-signal abort chain. `AbortSignal.any` removed; `AbortSignal.timeout` replaced by a plain `setTimeout` cleared when the controller aborts; named sentinel constants used as stackless abort reasons; request-abort handler explicitly removed on cleanup. - **`apps/webapp/app/entry.server.tsx`** — clears the `setTimeout(abort, ABORT_DELAY)` timer in `onShellReady` / `onAllReady` / `onShellError`. - **`apps/webapp/app/v3/tracer.server.ts` + `env.server.ts`** — gates OpenTelemetry `HttpInstrumentation` and `ExpressInstrumentation` behind `DISABLE_HTTP_INSTRUMENTATION=true` as an escape hatch for future OTel-listener retention patterns. Defaults to enabled. - **`apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts`** — uses the shared `ABORT_REASON_SEND_ERROR` sentinel. ## Verification ### Full-app reproduction (memlab) Isolated local harness, 500 abrupt SSE disconnects against a dev-presence route, GC between passes, heap snapshot diff with [memlab](https://facebook.github.io/memlab/): | Run | Heap delta after 500 conns + GC | memlab retained leaks | | --- | --- | --- | | Before | +16.0 MB (linear with request count) | 158 clusters; 250 `ServerResponse`, 1000 `AbortController`, 250 `SpanImpl` retained | | After | **+3.3 MB (noise)** | **0 app-code leaks** | ### Standalone mechanism isolation To confirm *which* axis of the change is load-bearing, a separate standalone Node script (`/tmp/abort-leak-test.mjs`) ran 2000 requests × 200 KB payload per variant: | Variant | Heap delta after GC | | --- | --- | | baseline (no signal machinery) | 0 MB | | V1: `AbortSignal.any` + string abort reason | **+9.1 MB** | | V2: `AbortSignal.any` only (no reason) | **+10.8 MB** | | V3: string reason only (no `AbortSignal.any`) | 0 MB | | V4: neither (the fix) | 0 MB | | V5: `AbortSignal.any` with no listener on the composite | **+10.2 MB** | This proves `AbortSignal.any` is the sole mechanism. The reason type (`.abort()` vs `.abort("string")`) is irrelevant for retention — V3 is clean, V5 leaks even without a listener on the composite. ## Risk - `sse.ts` is used by the dev-presence routes. Behaviour is equivalent — timeouts and client disconnects still abort the stream. `signal.reason` is now a named string sentinel (`"timeout"`, `"request_aborted"`, etc.) instead of the previous string arg or default `AbortError`. No in-tree reader of `signal.reason` exists. - `entry.server.tsx` change is a standard cleanup of an abort timer, matches upstream React Router guidance. - `tracer.server.ts` change is env-gated and defaults to current behaviour. - Three other webapp `AbortSignal.timeout()` callsites (alert delivery, remote-build status) are fire-and-forget passed directly to `fetch` — not composed with anything long-lived, no retention risk, untouched. ## Test plan - [ ] Existing SSE integration tests pass - [ ] Dev-presence SSE behaves normally across tab open/close cycles - [ ] No heap growth under sustained aborted-connection traffic (heap snapshot diff) ## Follow-up The same `AbortSignal.any([userSignal, internalSignal])` pattern exists in several SDK/core callsites that ship to customers (`packages/core/src/v3/realtimeStreams/manager.ts`, `packages/trigger-sdk/src/v3/{ai,chat,chat-client,sessions}.ts`, `packages/core/src/v3/workers/warmStartClient.ts`). Whether those leak in practice depends on the user passing a long-lived signal. Tracked separately. --- .server-changes/fix-sse-memory-leak.md | 6 ++ apps/webapp/app/entry.server.tsx | 16 ++- apps/webapp/app/env.server.ts | 1 + .../v3/RunStreamPresenter.server.ts | 8 +- apps/webapp/app/utils/sse.ts | 98 ++++++++++++------- apps/webapp/app/v3/tracer.server.ts | 6 +- 6 files changed, 92 insertions(+), 43 deletions(-) create mode 100644 .server-changes/fix-sse-memory-leak.md diff --git a/.server-changes/fix-sse-memory-leak.md b/.server-changes/fix-sse-memory-leak.md new file mode 100644 index 00000000000..e2b9ddd1810 --- /dev/null +++ b/.server-changes/fix-sse-memory-leak.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix memory leak where every aborted SSE connection pinned the full request/response graph on Node 20, caused by `AbortSignal.any()` in `sse.ts` retaining its source signals indefinitely (see nodejs/node#54614, nodejs/node#55351). Also clear the `setTimeout(abort)` timer in `entry.server.tsx` so successful HTML renders don't pin the React tree for 30s per request. diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 4ee4f252a32..87171011e03 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -83,6 +83,10 @@ function handleBotRequest( ) { return new Promise((resolve, reject) => { let shellRendered = false; + // Timer handle is cleared in every terminal callback so the abort closure + // (which captures the full React render tree + remixContext) doesn't pin + // memory for 30s per successful request. See react-router PR #14200. + let abortTimer: NodeJS.Timeout | undefined; const { pipe, abort } = renderToPipeableStream( @@ -105,8 +109,10 @@ function handleBotRequest( ); pipe(body); + clearTimeout(abortTimer); }, onShellError(error: unknown) { + clearTimeout(abortTimer); reject(error); }, onError(error: unknown) { @@ -121,7 +127,7 @@ function handleBotRequest( } ); - setTimeout(abort, ABORT_DELAY); + abortTimer = setTimeout(abort, ABORT_DELAY); }); } @@ -135,6 +141,10 @@ function handleBrowserRequest( ) { return new Promise((resolve, reject) => { let shellRendered = false; + // Timer handle is cleared in every terminal callback so the abort closure + // (which captures the full React render tree + remixContext) doesn't pin + // memory for 30s per successful request. See react-router PR #14200. + let abortTimer: NodeJS.Timeout | undefined; const { pipe, abort } = renderToPipeableStream( @@ -157,8 +167,10 @@ function handleBrowserRequest( ); pipe(body); + clearTimeout(abortTimer); }, onShellError(error: unknown) { + clearTimeout(abortTimer); reject(error); }, onError(error: unknown) { @@ -173,7 +185,7 @@ function handleBrowserRequest( } ); - setTimeout(abort, ABORT_DELAY); + abortTimer = setTimeout(abort, ABORT_DELAY); }); } diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index ba40624058f..c10446d08ab 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -438,6 +438,7 @@ const EnvironmentSchema = z INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"), INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"), INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"), + DISABLE_HTTP_INSTRUMENTATION: BoolEnv.default(false), INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(), INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(), diff --git a/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts b/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts index 1dd4edc6233..69560c49e88 100644 --- a/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts @@ -1,7 +1,7 @@ import { type PrismaClient, prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; -import { createSSELoader, SendFunction } from "~/utils/sse"; +import { ABORT_REASON_SEND_ERROR, createSSELoader, SendFunction } from "~/utils/sse"; import { throttle } from "~/utils/throttle"; import { tracePubSub } from "~/v3/services/tracePubSub.server"; @@ -66,8 +66,10 @@ export class RunStreamPresenter { }); } } - // Abort the stream on send error - context.controller.abort("Send error"); + // Abort the stream on send error. Uses a stackless string sentinel + // from sse.ts — a no-arg abort() would create a DOMException with a + // stack trace, which is unnecessary retention on the signal.reason. + context.controller.abort(ABORT_REASON_SEND_ERROR); } }, 1000 diff --git a/apps/webapp/app/utils/sse.ts b/apps/webapp/app/utils/sse.ts index f48cc9e31f9..53f9aa010cd 100644 --- a/apps/webapp/app/utils/sse.ts +++ b/apps/webapp/app/utils/sse.ts @@ -38,6 +38,20 @@ type SSEOptions = { // This is used to track the open connections, for debugging const connections: Set = new Set(); +// Stackless sentinel reasons passed to AbortController#abort. Calling .abort() +// with no argument produces a DOMException that captures a ~500-byte stack +// trace; a string reason is stored verbatim with no stack. The choice of +// reason type does not cause the retention we saw in prod (that was the +// AbortSignal.any composite — see comment near the timeoutTimer below for the +// Node issue refs), but naming the sentinels keeps call sites readable and +// lets future signal.reason consumers branch on the cause. +export const ABORT_REASON_REQUEST = "request_aborted"; +export const ABORT_REASON_TIMEOUT = "timeout"; +export const ABORT_REASON_SEND_ERROR = "send_error"; +export const ABORT_REASON_INIT_STOP = "init_requested_stop"; +export const ABORT_REASON_ITERATOR_STOP = "iterator_requested_stop"; +export const ABORT_REASON_ITERATOR_ERROR = "iterator_error"; + export function createSSELoader(options: SSEOptions) { const { timeout, interval = 500, debug = false, handler } = options; @@ -45,7 +59,6 @@ export function createSSELoader(options: SSEOptions) { const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8); const internalController = new AbortController(); - const timeoutSignal = AbortSignal.timeout(timeout); const log = (message: string) => { if (debug) @@ -60,16 +73,20 @@ export function createSSELoader(options: SSEOptions) { if (!internalController.signal.aborted) { originalSend(event); } - // If controller is aborted, silently ignore the send attempt } catch (error) { if (error instanceof Error) { if (error.message?.includes("Controller is already closed")) { - // Silently handle controller closed errors return; } log(`Error sending event: ${error.message}`); } - throw error; // Re-throw other errors + // Abort before rethrowing so timer + request-abort listener are cleaned + // up immediately. Otherwise a send-failure in initStream leaves them + // alive until `timeout` fires. + if (!internalController.signal.aborted) { + internalController.abort(ABORT_REASON_SEND_ERROR); + } + throw error; } }; }; @@ -92,51 +109,57 @@ export function createSSELoader(options: SSEOptions) { const requestAbortSignal = getRequestAbortSignal(); - const combinedSignal = AbortSignal.any([ - requestAbortSignal, - timeoutSignal, - internalController.signal, - ]); - log("Start"); - requestAbortSignal.addEventListener( - "abort", - () => { - log(`request signal aborted`); - internalController.abort("Request aborted"); - }, - { once: true, signal: internalController.signal } - ); + // Single-signal abort chain: everything rolls up into internalController. + // Timeout is a plain setTimeout cleared on abort rather than an + // AbortSignal.timeout() combined via AbortSignal.any() — AbortSignal.any + // keeps its source signals in an internal Set managed by a + // FinalizationRegistry, and under sustained request traffic those entries + // accumulate faster than they get cleaned up, pinning every source signal + // (and its listeners, and anything those listeners close over) until the + // parent signal is GC'd or aborts. Reproduced locally in isolation; shape + // matches the ChainSafe Lodestar production case described in + // nodejs/node#54614. See also nodejs/node#55351 (mechanism confirmed by + // @jasnell, narrow fix in 22.12.0 via #55354) and nodejs/node#57584 + // (circular-dep variant, still open). + const timeoutTimer = setTimeout(() => { + if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_TIMEOUT); + }, timeout); + + const onRequestAbort = () => { + log("request signal aborted"); + if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_REQUEST); + }; - combinedSignal.addEventListener( + internalController.signal.addEventListener( "abort", () => { - log(`combinedSignal aborted: ${combinedSignal.reason}`); + clearTimeout(timeoutTimer); + requestAbortSignal.removeEventListener("abort", onRequestAbort); }, - { once: true, signal: internalController.signal } + { once: true } ); - timeoutSignal.addEventListener( - "abort", - () => { - if (internalController.signal.aborted) return; - log(`timeoutSignal aborted: ${timeoutSignal.reason}`); - internalController.abort("Timeout"); - }, - { once: true, signal: internalController.signal } - ); + // The request could have been aborted during `await handler(context)` above. + // AbortSignal listeners added after the signal is already aborted never fire, + // so invoke cleanup synchronously in that case instead of waiting for `timeout`. + if (requestAbortSignal.aborted) { + onRequestAbort(); + } else { + requestAbortSignal.addEventListener("abort", onRequestAbort, { once: true }); + } if (handlers.beforeStream) { const shouldContinue = await handlers.beforeStream(); if (shouldContinue === false) { log("beforeStream returned false, so we'll exit before creating the stream"); - internalController.abort("Init requested stop"); + internalController.abort(ABORT_REASON_INIT_STOP); return; } } - return eventStream(combinedSignal, function setup(send) { + return eventStream(internalController.signal, function setup(send) { connections.add(id); const safeSend = createSafeSend(send); @@ -147,14 +170,14 @@ export function createSSELoader(options: SSEOptions) { const shouldContinue = await handlers.initStream({ send: safeSend }); if (shouldContinue === false) { log("initStream returned false, so we'll stop the stream"); - internalController.abort("Init requested stop"); + internalController.abort(ABORT_REASON_INIT_STOP); return; } } log("Starting interval"); for await (const _ of setInterval(interval, null, { - signal: combinedSignal, + signal: internalController.signal, })) { log("PING"); @@ -165,13 +188,16 @@ export function createSSELoader(options: SSEOptions) { const shouldContinue = await handlers.iterator({ date, send: safeSend }); if (shouldContinue === false) { log("iterator return false, so we'll stop the stream"); - internalController.abort("Iterator requested stop"); + internalController.abort(ABORT_REASON_ITERATOR_STOP); break; } } catch (error) { log("iterator threw an error, aborting stream"); // Immediately abort to trigger cleanup - internalController.abort(error instanceof Error ? error.message : "Iterator error"); + if (error instanceof Error && error.name !== "AbortError") { + log(`iterator error: ${error.message}`); + } + internalController.abort(ABORT_REASON_ITERATOR_ERROR); // No need to re-throw as we're handling it by aborting return; // Exit the run function immediately } diff --git a/apps/webapp/app/v3/tracer.server.ts b/apps/webapp/app/v3/tracer.server.ts index 2ce5aa275c7..1115ab42de8 100644 --- a/apps/webapp/app/v3/tracer.server.ts +++ b/apps/webapp/app/v3/tracer.server.ts @@ -302,13 +302,15 @@ function setupTelemetry() { provider.register(); let instrumentations: Instrumentation[] = [ - new HttpInstrumentation(), - new ExpressInstrumentation(), new AwsSdkInstrumentation({ suppressInternalInstrumentation: true, }), ]; + if (!env.DISABLE_HTTP_INSTRUMENTATION) { + instrumentations.unshift(new HttpInstrumentation(), new ExpressInstrumentation()); + } + if (env.INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED === "1") { instrumentations.push(new PrismaInstrumentation()); } From cbb1f35ef0bae59eae3bbf7fd70fcca3ec79f939 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:47:44 +0200 Subject: [PATCH 030/279] chore(helm): bump appVersion to v4.4.4 (#3432) --- hosting/k8s/helm/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index ac8e81b9f4b..e3b3c886456 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.0.6 -appVersion: v4.0.4 +version: 4.4.4 +appVersion: v4.4.4 home: https://trigger.dev sources: - https://github.com/triggerdotdev/trigger.dev From 41434b536b00c222040b0b9155bf312729367d80 Mon Sep 17 00:00:00 2001 From: Iss <74388823+isshaddad@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:51:49 -0400 Subject: [PATCH 031/279] feat(webapp): admin Back Office tab with org API rate limit editor (#3434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - New **Back office** tab at `/admin`, per-org detail page at `/admin/back-office/orgs/:orgId` designed to host future per-org admin actions (project count, delete account, YC deals). - First action: edit an organization's API rate limit — tokenBucket override (refill rate, interval, max tokens), with a live plain-English preview (e.g. *"1,500 requests per minute · 750 request burst allowance"*). Writes are audit-logged via the server logger. - Cleanup: removed unused `v2?` / `v3?` columns from the admin orgs list (display only — Prisma select untouched). ## Test plan - [ ] Back office tab visible in admin nav and highlighted when on a sub-route - [ ] `/admin/orgs` shows a Back office "Open" link per row; no v2/v3 columns - [ ] Empty state at `/admin/back-office` links back to `/admin/orgs` - [ ] Detail page renders the effective rate limit in view mode; Edit reveals the form - [ ] Save writes `Organization.apiRateLimiterConfig`, returns to view mode, shows "Rate limit saved." banner - [ ] Invalid values surface inline field errors and keep edit mode - [ ] Non-admins hitting any new route are redirected to `/` - [ ] Server logs show `admin.backOffice.rateLimit` info line per mutation --- .../admin-back-office-rate-limit.md | 6 + .../webapp/app/components/primitives/Tabs.tsx | 17 +- .../app/routes/admin.back-office._index.tsx | 29 ++ .../routes/admin.back-office.orgs.$orgId.tsx | 452 ++++++++++++++++++ apps/webapp/app/routes/admin.back-office.tsx | 23 + apps/webapp/app/routes/admin.orgs.tsx | 15 +- apps/webapp/app/routes/admin.tsx | 5 + 7 files changed, 538 insertions(+), 9 deletions(-) create mode 100644 .server-changes/admin-back-office-rate-limit.md create mode 100644 apps/webapp/app/routes/admin.back-office._index.tsx create mode 100644 apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx create mode 100644 apps/webapp/app/routes/admin.back-office.tsx diff --git a/.server-changes/admin-back-office-rate-limit.md b/.server-changes/admin-back-office-rate-limit.md new file mode 100644 index 00000000000..da6835f0c5e --- /dev/null +++ b/.server-changes/admin-back-office-rate-limit.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add a "Back office" tab to `/admin` and a per-organization detail page at `/admin/back-office/orgs/:orgId`. The first action available on that page is editing the org's API rate limit: admins can save a `tokenBucket` override (refill rate, interval, max tokens) and see a plain-English preview of the resulting sustained rate and burst allowance. Writes are audit-logged via the server logger. diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index cbc5cf42752..0fb8a020a7b 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -11,6 +11,7 @@ export type TabsProps = { tabs: { label: string; to: string; + end?: boolean; }[]; className?: string; layoutId: string; @@ -21,7 +22,13 @@ export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsP return ( {tabs.map((tab, index) => ( - + {tab.label} ))} @@ -62,18 +69,20 @@ export function TabLink({ children, layoutId, variant = "underline", + end = true, }: { to: string; children: ReactNode; layoutId: string; variant?: Variants; + end?: boolean; }) { if (variant === "segmented") { return ( {({ isActive, isPending }) => { const active = isActive || isPending; @@ -110,7 +119,7 @@ export function TabLink({ {({ isActive, isPending }) => { const active = isActive || isPending; @@ -131,7 +140,7 @@ export function TabLink({ // underline variant (default) return ( - + {({ isActive, isPending }) => { return ( <> diff --git a/apps/webapp/app/routes/admin.back-office._index.tsx b/apps/webapp/app/routes/admin.back-office._index.tsx new file mode 100644 index 00000000000..15e6f699b9a --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office._index.tsx @@ -0,0 +1,29 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect, typedjson } from "remix-typedjson"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { requireUser } from "~/services/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return typedjson({}); +} + +export default function BackOfficeIndex() { + return ( +
+ Back office + + Back-office actions are applied to a single organization. Pick an org from the + Organizations tab to open its detail page. + + + Pick an organization + +
+ ); +} diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx new file mode 100644 index 00000000000..211a5a4fd2e --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -0,0 +1,452 @@ +import { Form, useNavigation, useSearchParams } from "@remix-run/react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useEffect, useState } from "react"; +import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { FormError } from "~/components/primitives/FormError"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { + RateLimitTokenBucketConfig, + RateLimiterConfig, +} from "~/services/authorizationRateLimitMiddleware.server"; +import { logger } from "~/services/logger.server"; +import { type Duration } from "~/services/rateLimiter.server"; +import { requireUser } from "~/services/session.server"; + +const SAVED_QUERY_KEY = "saved"; +const SAVED_QUERY_VALUE = "1"; + +type EffectiveRateLimit = { + source: "override" | "default"; + config: RateLimiterConfig; +}; + +function systemDefaultRateLimit(): RateLimiterConfig { + return { + type: "tokenBucket", + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }; +} + +function resolveEffectiveRateLimit(override: unknown): EffectiveRateLimit { + if (override == null) { + return { source: "default", config: systemDefaultRateLimit() }; + } + const parsed = RateLimiterConfig.safeParse(override); + if (parsed.success) { + return { source: "override", config: parsed.data }; + } + // Column holds malformed JSON — fall back silently. Admin must investigate + // at the DB level; this UI can't recover it. + return { source: "default", config: systemDefaultRateLimit() }; +} + +function parseDurationToMs(duration: string): number { + const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/); + if (!match) return 0; + const value = parseInt(match[1], 10); + switch (match[2]) { + case "ms": + return value; + case "s": + return value * 1_000; + case "m": + return value * 60_000; + case "h": + return value * 3_600_000; + case "d": + return value * 86_400_000; + default: + return 0; + } +} + +function describeRateLimit( + refillRate: number, + intervalMs: number, + maxTokens: number +): { sustained: string; burst: string } | null { + if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null; + const perMin = (refillRate * 60_000) / intervalMs; + let sustained: string; + if (perMin >= 1) { + sustained = `${Math.round(perMin).toLocaleString()} requests per minute`; + } else { + const perHour = perMin * 60; + if (perHour >= 1) { + sustained = `${Math.round(perHour).toLocaleString()} requests per hour`; + } else { + const perDay = perHour * 24; + const formatted = + perDay >= 10 ? Math.round(perDay).toLocaleString() : perDay.toFixed(1); + sustained = `${formatted} requests per day`; + } + } + return { + sustained, + burst: `${maxTokens.toLocaleString()} request burst allowance`, + }; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const orgId = params.orgId; + if (!orgId) { + throw new Response(null, { status: 404 }); + } + + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { + id: true, + slug: true, + title: true, + createdAt: true, + apiRateLimiterConfig: true, + }, + }); + + if (!org) { + throw new Response(null, { status: 404 }); + } + + const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig); + + return typedjson({ + org, + effective, + }); +} + +const SetRateLimitSchema = z.object({ + intent: z.literal("set-rate-limit"), + refillRate: z.coerce.number().int().min(1), + interval: z + .string() + .trim() + .refine((v) => parseDurationToMs(v) > 0, { + message: "Must be a duration like 10s, 1m, 500ms.", + }), + maxTokens: z.coerce.number().int().min(1), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const orgId = params.orgId; + if (!orgId) { + throw new Response(null, { status: 404 }); + } + + const formData = await request.formData(); + const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return typedjson( + { errors: submission.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { apiRateLimiterConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + + const built = RateLimitTokenBucketConfig.safeParse({ + type: "tokenBucket", + refillRate: submission.data.refillRate, + interval: submission.data.interval, + maxTokens: submission.data.maxTokens, + }); + if (!built.success) { + return typedjson( + { errors: built.error.flatten().fieldErrors }, + { status: 400 } + ); + } + const next = built.data; + + await prisma.organization.update({ + where: { id: orgId }, + data: { apiRateLimiterConfig: next as any }, + }); + + logger.info("admin.backOffice.rateLimit", { + adminUserId: user.id, + orgId, + previous: existing.apiRateLimiterConfig, + next, + }); + + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}` + ); +} + +export default function BackOfficeOrgPage() { + const { org, effective } = useTypedLoaderData(); + const actionData = useTypedActionData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state !== "idle"; + + const errors = + actionData && "errors" in actionData ? actionData.errors : null; + const hasFieldErrors = + !!errors && typeof errors === "object" && Object.keys(errors).length > 0; + const fieldError = (field: string) => + errors && typeof errors === "object" && field in errors + ? (errors as Record)[field]?.[0] + : undefined; + + const current = + effective.config.type === "tokenBucket" ? effective.config : null; + + const [isEditing, setIsEditing] = useState(false); + const [refillRate, setRefillRate] = useState( + current ? String(current.refillRate) : "" + ); + const [intervalStr, setIntervalStr] = useState( + current ? String(current.interval) : "" + ); + const [maxTokens, setMaxTokens] = useState( + current ? String(current.maxTokens) : "" + ); + + const [searchParams, setSearchParams] = useSearchParams(); + const savedJustNow = searchParams.get(SAVED_QUERY_KEY) === SAVED_QUERY_VALUE; + + // If a submit comes back with validation errors, re-open edit mode so the + // admin can see and correct them without clicking Edit again. + useEffect(() => { + if (hasFieldErrors) setIsEditing(true); + }, [hasFieldErrors]); + + // On successful save, drop back to view mode (the component stays mounted + // across the same-route redirect, so `isEditing` wouldn't reset on its own). + useEffect(() => { + if (savedJustNow) setIsEditing(false); + }, [savedJustNow]); + + // Auto-dismiss the "saved" banner after a few seconds. + useEffect(() => { + if (!savedJustNow) return; + const t = setTimeout(() => { + setSearchParams( + (prev) => { + prev.delete(SAVED_QUERY_KEY); + return prev; + }, + { replace: true, preventScrollReset: true } + ); + }, 3000); + return () => clearTimeout(t); + }, [savedJustNow, setSearchParams]); + + const currentDescription = current + ? describeRateLimit( + current.refillRate, + parseDurationToMs(String(current.interval)), + current.maxTokens + ) + : null; + + const previewDescription = describeRateLimit( + Number(refillRate) || 0, + parseDurationToMs(intervalStr), + Number(maxTokens) || 0 + ); + + const cancelEdit = () => { + setRefillRate(current ? String(current.refillRate) : ""); + setIntervalStr(current ? String(current.interval) : ""); + setMaxTokens(current ? String(current.maxTokens) : ""); + setIsEditing(false); + }; + + return ( +
+
+
+ {org.title} + + · + +
+ + Back to organizations + +
+ +
+
+ API rate limit + {!isEditing && ( + + )} +
+ + {savedJustNow && ( +
+ + Rate limit saved. + +
+ )} + + + Status:{" "} + {effective.source === "override" + ? "Custom override active." + : "Using system default."} + + + {!isEditing ? ( + <> + + {effective.config.type === "tokenBucket" ? ( + currentDescription ? ( + <> + + Sustained rate + {currentDescription.sustained} + + + Burst allowance + {currentDescription.burst} + + + ) : ( + + + Invalid interval on the stored config. + + + ) + ) : ( + <> + + Type + {effective.config.type} + + + Window + {String(effective.config.window)} + + + Tokens + + {effective.config.tokens.toLocaleString()} + + + + )} + + {effective.config.type !== "tokenBucket" && ( + + This override is a {effective.config.type} limit and can't be + edited from this form. Change it in the database directly. + + )} + + ) : ( +
+ + +
+ + setRefillRate(e.target.value)} + required + /> + {fieldError("refillRate")} +
+ +
+ + setIntervalStr(e.target.value)} + required + /> + {fieldError("interval")} +
+ +
+ + setMaxTokens(e.target.value)} + required + /> + {fieldError("maxTokens")} +
+ + + {previewDescription + ? `Preview: ${previewDescription.sustained} · ${previewDescription.burst}.` + : "Preview: enter valid values to see the effective limit."} + + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/admin.back-office.tsx b/apps/webapp/app/routes/admin.back-office.tsx new file mode 100644 index 00000000000..026fc13fdc5 --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office.tsx @@ -0,0 +1,23 @@ +import { Outlet } from "@remix-run/react"; +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect, typedjson } from "remix-typedjson"; +import { requireUser } from "~/services/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return typedjson({}); +} + +export default function BackOfficeLayout() { + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index 6f496362947..6d16ab99c9d 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -85,15 +85,14 @@ export default function AdminDashboardRoute() { Slug Members id - v2? - v3? Deleted? + Back office Actions {organizations.length === 0 ? ( - + No orgs found for search ) : ( @@ -120,9 +119,15 @@ export default function AdminDashboardRoute() { - {org.v2Enabled ? "✅" : ""} - {org.v3Enabled ? "✅" : ""} {org.deletedAt ? "☠️" : ""} + + + Open + +
)} - {/* Docs iframe */} + {/* Docs link */} {setupMethod === "docs" && (
Setup Guide @@ -659,37 +675,47 @@ export default function Page() { <> When adding allowed principals to your VPC Endpoint Service, use the following - AWS account ID(s): + AWS account ARN(s): -
+
{awsAccountIds.map((id) => ( - - {id} - + ))}
)} -