From 3c822489400757bb030fc955a10c97582dd7252f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:59:16 +0100 Subject: [PATCH 01/23] chore(deps): bump tar to 7.5.19 (#4345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins `tar` to `7.5.19` via a root `pnpm.overrides` entry, replacing a stale range override (`tar@>=7 <7.5.11`) that no longer matched any installed copy. The single override collapses all resolved `tar` copies onto one version: - `packages/cli-v3` — direct dependency (was 7.5.13) - `@kubernetes/client-node` (apps/supervisor) — transitive (was 7.5.13) - `cacache` — transitive (was 6.2.1) - `giget` — transitive (was 6.2.1) No source changes; cli-v3's published `^7.5.13` spec already permits `7.5.19`, so no changeset is needed. --- package.json | 2 +- pnpm-lock.yaml | 84 +++++++++----------------------------------------- 2 files changed, 15 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index dddb227538..3b00102ac8 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@ai-sdk/provider-utils@^4": "4.0.29", "express@^4>body-parser": "1.20.3", "@remix-run/dev@2.17.5>tar-fs": "2.1.4", - "tar@>=7 <7.5.11": "^7.5.11", + "tar": "7.5.19", "form-data@^2": "2.5.4", "form-data@^3": "3.0.5", "form-data@^4": "4.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd2d2d93fb..b09dc718a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ overrides: '@ai-sdk/provider-utils@^4': 4.0.29 express@^4>body-parser: 1.20.3 '@remix-run/dev@2.17.5>tar-fs': 2.1.4 - tar@>=7 <7.5.11: ^7.5.11 + tar: 7.5.19 form-data@^2: 2.5.4 form-data@^3: 3.0.5 form-data@^4: 4.0.6 @@ -1625,8 +1625,8 @@ importers: specifier: ^10.0.0 version: 10.0.0 tar: - specifier: ^7.5.13 - version: 7.5.13 + specifier: 7.5.19 + version: 7.5.19 tiny-invariant: specifier: ^1.2.0 version: 1.3.1 @@ -3226,19 +3226,19 @@ packages: '@esbuild-kit/core-utils@3.0.0': resolution: {integrity: sha512-TXmwH9EFS3DC2sI2YJWJBgHGhlteK0Xyu1VabwetMULfm3oYhbrsWV5yaSr2NTWZIgDGVLHbRf0inxbjXqAcmQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.5.4': resolution: {integrity: sha512-afmtLf6uqxD5IgwCzomtqCYIgz/sjHzCWZFvfS5+FzeYxOURPUo4QcHtqJxbxWOMOogKriZanN/1bJQE/ZL93A==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.23.0': resolution: {integrity: sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==} @@ -8896,10 +8896,6 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -10576,10 +10572,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - fs-minipass@3.0.3: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -12212,22 +12204,10 @@ packages: resolution: {integrity: sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==} engines: {node: '>=8'} - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} - minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -12239,11 +12219,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -14526,13 +14501,8 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + tar@7.5.19: + resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} engines: {node: '>=18'} tdigest@0.1.2: @@ -18795,7 +18765,7 @@ snapshots: openid-client: 6.3.3 rfc4648: 1.5.3 stream-buffers: 3.0.2 - tar: 7.5.13 + tar: 7.5.19 tmp-promise: 3.0.3 tslib: 2.6.2 ws: 8.21.0(bufferutil@4.0.9) @@ -23713,7 +23683,7 @@ snapshots: minipass-pipeline: 1.2.4 p-map: 4.0.0 ssri: 10.0.5 - tar: 6.2.1 + tar: 7.5.19 unique-filename: 3.0.0 cacheable-request@6.1.0: @@ -23823,8 +23793,6 @@ snapshots: chownr@1.1.4: {} - chownr@2.0.0: {} - chownr@3.0.0: {} chrome-trace-event@1.0.4: {} @@ -25801,10 +25769,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - fs-minipass@3.0.3: dependencies: minipass: 7.1.3 @@ -25910,7 +25874,7 @@ snapshots: nypm: 0.3.9 ohash: 1.1.3 pathe: 1.1.2 - tar: 6.2.1 + tar: 7.5.19 giget@2.0.0: dependencies: @@ -27832,17 +27796,8 @@ snapshots: dependencies: yallist: 4.0.0 - minipass@5.0.0: {} - - minipass@7.1.2: {} - minipass@7.1.3: {} - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - minizlib@3.1.0: dependencies: minipass: 7.1.3 @@ -27851,8 +27806,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} ml-array-max@1.2.4: @@ -30524,20 +30477,11 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - tar@7.5.13: + tar@7.5.19: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 From 88ca0091a9c53fc1e220bcb34cf52ed471a6bcb7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 23 Jul 2026 11:51:34 +0100 Subject: [PATCH 02/23] fix(docker): stop the container entrypoint printing database connection strings in logs (#4346) ## Summary The container entrypoint runs under `set -x`, which echoes every command to the logs with its variables expanded. Several startup guards reference full database connection strings, so the DSN (including the password) was printed to the container logs on every boot. This turns tracing off around those lines so connection strings are never traced, while leaving migration behavior and ordinary startup logging unchanged. ## Fix The leaking lines are the `[ -n "$RUN_OPS_DATABASE_URL" ]` and `[ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]` guards, and the ClickHouse block (its `[ -n "$CLICKHOUSE_URL" ]` guard plus the lines that build `GOOSE_DBSTRING` from `CLICKHOUSE_URL`). `set -x` prints each of these with the credential expanded. Tracing is now disabled around each region and restored afterward, so non-secret tracing is preserved everywhere else. The existing legacy-migration subshell already protected its own command body; this adds the missing protection for the guards and the ClickHouse block. ```sh { set +x; } 2>/dev/null if [ -n "$RUN_OPS_DATABASE_URL" ]; then set -x ... ``` ## Verification Built the webapp image and ran it with dummy sentinel connection strings whose password token is `S3NTINEL_PW_DoNotLog`, then grepped the boot logs. Before (unmodified), the token appears in the traced guards: ``` + [ -n postgresql://user:S3NTINEL_PW_DoNotLog@fake-host:6432/run-ops ] + [ -n postgresql://user:S3NTINEL_PW_DoNotLog@fake-host:5432/legacy ] + [ -n https://default:S3NTINEL_PW_DoNotLog@fake-host:8443 ] ``` After, `grep S3NTINEL_PW_DoNotLog` on the same run returns nothing, and the normal "skipping ... migrations" lines still log. --- .../entrypoint-no-log-db-connection-strings.md | 6 ++++++ docker/scripts/entrypoint.sh | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 .server-changes/entrypoint-no-log-db-connection-strings.md diff --git a/.server-changes/entrypoint-no-log-db-connection-strings.md b/.server-changes/entrypoint-no-log-db-connection-strings.md new file mode 100644 index 0000000000..611408cf14 --- /dev/null +++ b/.server-changes/entrypoint-no-log-db-connection-strings.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs. diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index 1871755f98..1e5c7c7cab 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -15,7 +15,9 @@ fi # Run-ops split: migrate the dedicated NEW run-ops database only when it is configured. Single-DB # installs never set the URL, so this is a no-op there. +{ set +x; } 2>/dev/null if [ -n "$RUN_OPS_DATABASE_URL" ]; then + set -x if [ "$SKIP_RUN_OPS_MIGRATIONS" != "1" ]; then echo "Running run-ops migrations" pnpm --filter @internal/run-ops-database db:migrate:deploy @@ -24,13 +26,16 @@ if [ -n "$RUN_OPS_DATABASE_URL" ]; then echo "SKIP_RUN_OPS_MIGRATIONS=1, skipping run-ops migrations." fi else + set -x echo "RUN_OPS_DATABASE_URL not set, skipping run-ops migrations." fi # Run-ops split: keep the legacy runs DB's schema current by applying the full @trigger.dev/database # migrations to it too, pointed at its direct (non-pooled) URL. Only runs when that URL is configured; # installs that never set it skip this entirely. +{ set +x; } 2>/dev/null if [ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]; then + set -x if [ "$SKIP_RUN_OPS_LEGACY_MIGRATIONS" != "1" ]; then echo "Running legacy run-ops migrations" # Subshell with tracing off so `set -x` does not print the DSN (with credentials) to the logs. @@ -40,6 +45,7 @@ if [ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]; then echo "SKIP_RUN_OPS_LEGACY_MIGRATIONS=1, skipping legacy run-ops migrations." fi else + set -x echo "RUN_OPS_LEGACY_DIRECT_URL not set, skipping legacy run-ops migrations." fi @@ -51,6 +57,7 @@ else echo "SKIP_DASHBOARD_AGENT_MIGRATIONS=1, skipping dashboard agent migrations." fi +{ set +x; } 2>/dev/null if [ -n "$CLICKHOUSE_URL" ] && [ "$SKIP_CLICKHOUSE_MIGRATIONS" != "1" ]; then # Run ClickHouse migrations echo "Running ClickHouse migrations..." @@ -76,6 +83,7 @@ elif [ "$SKIP_CLICKHOUSE_MIGRATIONS" = "1" ]; then else echo "CLICKHOUSE_URL not set, skipping ClickHouse migrations." fi +set -x # Copy over required prisma files cp internal-packages/database/prisma/schema.prisma apps/webapp/prisma/ From 722e240e4d74a72fd072bd7f6e1f2adbb6a3f36e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:18:58 +0100 Subject: [PATCH 03/23] feat(supervisor): add prometheus metric for outbound http requests (#4350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Prometheus metrics so the supervisor's outbound HTTP calls are observable - including client-side failures that previously only surfaced as a log line. - `supervisor_outbound_request_total{name, method, status, outcome}` - counts every outbound request. `outcome` separates a transport failure (`network_error`), an HTTP error response (`http_error`), a response that failed schema validation (`invalid_response`), and success (`ok`). - `supervisor_outbound_request_duration_seconds{name, outcome}` - latency histogram. Leaner labels than the counter (no `status`) to avoid bucket×label cardinality; buckets match the existing dequeue-latency histogram since these calls share the same retrying HTTP client and long-poll envelope. Coverage: - The warm-start request (a one-off `fetch`) - instrumented inline; the response status code is now also included in the failure log (it was previously dropped). - All worker API client calls (`SupervisorHttpClient`: dequeue, run attempt start/complete, heartbeats, snapshots, continue, suspend, debug-log, connect) - routed through a single instrumented `request()` helper that reports via an optional `onHttpRequestComplete` callback on the client, which the supervisor wires into the counter + histogram. Low cardinality by design: `name` is a **static per-endpoint label** (e.g. `dequeue`, `start_run_attempt`), never the interpolated URL - so no run/snapshot IDs land in labels, mirroring the templated `route` labels on the inbound HTTP server. Registered on the existing metrics registry, exposed on `/metrics` with no new wiring. Internal-only change (no package release needed), so the changelog note is a single `.server-changes` entry. --- .../supervisor-outbound-request-metrics.md | 6 ++ apps/supervisor/src/index.ts | 39 +++++++- .../src/v3/runEngineWorker/supervisor/http.ts | 88 ++++++++++++++++--- .../v3/runEngineWorker/supervisor/types.ts | 9 ++ 4 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 .server-changes/supervisor-outbound-request-metrics.md diff --git a/.server-changes/supervisor-outbound-request-metrics.md b/.server-changes/supervisor-outbound-request-metrics.md new file mode 100644 index 0000000000..c66a499b81 --- /dev/null +++ b/.server-changes/supervisor-outbound-request-metrics.md @@ -0,0 +1,6 @@ +--- +area: supervisor +type: improvement +--- + +Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor. diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index cf73be90ee..f30203df54 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -22,7 +22,7 @@ import { isKubernetesEnvironment, } from "@trigger.dev/core/v3/serverOnly"; import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js"; -import { collectDefaultMetrics, Gauge, Histogram } from "prom-client"; +import { collectDefaultMetrics, Counter, Gauge, Histogram } from "prom-client"; import { register } from "./metrics.js"; import { PodCleaner } from "./services/podCleaner.js"; import { FailedPodHandler } from "./services/failedPodHandler.js"; @@ -60,6 +60,21 @@ const workloadCreateDuration = new Histogram({ registers: [register], }); +const outboundRequestsTotal = new Counter({ + name: "supervisor_outbound_request_total", + help: "Count of outbound HTTP requests from the supervisor, by target name, method, response status, and outcome (ok, http_error, invalid_response, network_error).", + labelNames: ["name", "method", "status", "outcome"], + registers: [register], +}); + +const outboundRequestDuration = new Histogram({ + name: "supervisor_outbound_request_duration_seconds", + help: "Duration of outbound HTTP requests from the supervisor, by target name and outcome. Includes the HTTP client's internal retries and backoff.", + labelNames: ["name", "outcome"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 11, 12.5, 15, 20, 30, 60], + registers: [register], +}); + class ManagedSupervisor { private readonly workerSession: SupervisorSession; private readonly metricsServer?: HttpServer; @@ -322,6 +337,10 @@ class ManagedSupervisor { runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED, heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS, sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS, + onHttpRequestComplete: ({ name, method, status, outcome, durationMs }) => { + outboundRequestsTotal.inc({ name, method, status, outcome }); + outboundRequestDuration.observe({ name, outcome }, durationMs / 1000); + }, preDequeue: async () => { // Synchronous, hot-path-safe cached read; false when no monitors are active. const skipForBackpressure = this.backpressureMonitors.some((m) => m.shouldSkipDequeue()); @@ -692,6 +711,18 @@ class ManagedSupervisor { headers.traceparent = traceparent; } + const requestStart = performance.now(); + const record = ( + status: string, + outcome: "ok" | "http_error" | "invalid_response" | "network_error" + ) => { + outboundRequestsTotal.inc({ name: "warm_start", method: "POST", status, outcome }); + outboundRequestDuration.observe( + { name: "warm_start", outcome }, + (performance.now() - requestStart) / 1000 + ); + }; + try { const res = await fetch(warmStartUrlWithPath.href, { method: "POST", @@ -700,8 +731,10 @@ class ManagedSupervisor { }); if (!res.ok) { + record(String(res.status), "http_error"); this.logger.error("Warm start failed", { runId: dequeuedMessage.run.id, + statusCode: res.status, }); return false; } @@ -710,6 +743,7 @@ class ManagedSupervisor { const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data); if (!parsedData.success) { + record(String(res.status), "invalid_response"); this.logger.error("Warm start response invalid", { runId: dequeuedMessage.run.id, data, @@ -717,8 +751,11 @@ class ManagedSupervisor { return false; } + record(String(res.status), "ok"); + return parsedData.data.didWarmStart; } catch (error) { + record("none", "network_error"); this.logger.error("Warm start error", { runId: dequeuedMessage.run.id, error, diff --git a/packages/core/src/v3/runEngineWorker/supervisor/http.ts b/packages/core/src/v3/runEngineWorker/supervisor/http.ts index d6d51b1c2b..450be43257 100644 --- a/packages/core/src/v3/runEngineWorker/supervisor/http.ts +++ b/packages/core/src/v3/runEngineWorker/supervisor/http.ts @@ -21,9 +21,9 @@ import { WorkerApiSuspendRunResponseBody, WorkerApiRunSnapshotsSinceResponseBody, } from "./schemas.js"; -import type { SupervisorClientCommonOptions } from "./types.js"; +import type { SupervisorClientCommonOptions, SupervisorHttpRequestMetric } from "./types.js"; import { getDefaultWorkerHeaders } from "./util.js"; -import { wrapZodFetch } from "../../zodfetch.js"; +import { wrapZodFetch, type ApiResult, type ZodFetchOptions } from "../../zodfetch.js"; import { createHeaders } from "../util.js"; import { WORKER_HEADERS } from "../consts.js"; import { SimpleStructuredLogger } from "../../utils/structuredLogger.js"; @@ -36,6 +36,7 @@ export class SupervisorHttpClient { private readonly instanceName: string; private readonly defaultHeaders: Record; private readonly sendRunDebugLogs: boolean; + private readonly onHttpRequestComplete?: (metric: SupervisorHttpRequestMetric) => void; private readonly logger = new SimpleStructuredLogger("supervisor-http-client"); @@ -45,6 +46,7 @@ export class SupervisorHttpClient { this.instanceName = opts.instanceName; this.defaultHeaders = getDefaultWorkerHeaders(opts); this.sendRunDebugLogs = opts.sendRunDebugLogs ?? false; + this.onHttpRequestComplete = opts.onHttpRequestComplete; if (!this.apiUrl) { throw new Error("apiURL is required and needs to be a non-empty string"); @@ -59,8 +61,55 @@ export class SupervisorHttpClient { } } + private async request( + name: string, + schema: T, + url: string, + requestInit?: RequestInit, + options?: ZodFetchOptions> + ): Promise>> { + const start = performance.now(); + const result = await wrapZodFetch(schema, url, requestInit, options); + + if (this.onHttpRequestComplete) { + const durationMs = performance.now() - start; + const method = requestInit?.method ?? "GET"; + + if (result.success) { + this.onHttpRequestComplete({ name, method, status: "2xx", outcome: "ok", durationMs }); + } else if (result.statusCode === 200) { + this.onHttpRequestComplete({ + name, + method, + status: "200", + outcome: "invalid_response", + durationMs, + }); + } else if (typeof result.statusCode === "number") { + this.onHttpRequestComplete({ + name, + method, + status: String(result.statusCode), + outcome: "http_error", + durationMs, + }); + } else { + this.onHttpRequestComplete({ + name, + method, + status: "none", + outcome: "network_error", + durationMs, + }); + } + } + + return result; + } + async connect(body: WorkerApiConnectRequestBody) { - return wrapZodFetch( + return this.request( + "connect", WorkerApiConnectResponseBody, `${this.apiUrl}/engine/v1/worker-actions/connect`, { @@ -75,7 +124,8 @@ export class SupervisorHttpClient { } async dequeue(body: WorkerApiDequeueRequestBody) { - return wrapZodFetch( + return this.request( + "dequeue", WorkerApiDequeueResponseBody, `${this.apiUrl}/engine/v1/worker-actions/dequeue`, { @@ -91,7 +141,8 @@ export class SupervisorHttpClient { /** @deprecated Not currently used */ async dequeueFromVersion(deploymentId: string, maxRunCount = 1, runnerId?: string) { - return wrapZodFetch( + return this.request( + "dequeue_from_version", WorkerApiDequeueResponseBody, `${this.apiUrl}/engine/v1/worker-actions/deployments/${deploymentId}/dequeue?maxRunCount=${maxRunCount}`, { @@ -104,7 +155,8 @@ export class SupervisorHttpClient { } async heartbeatWorker(body: WorkerApiHeartbeatRequestBody) { - return wrapZodFetch( + return this.request( + "heartbeat_worker", WorkerApiHeartbeatResponseBody, `${this.apiUrl}/engine/v1/worker-actions/heartbeat`, { @@ -124,7 +176,8 @@ export class SupervisorHttpClient { body: WorkerApiRunHeartbeatRequestBody, runnerId?: string ) { - return wrapZodFetch( + return this.request( + "heartbeat_run", WorkerApiRunHeartbeatResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/heartbeat`, { @@ -146,7 +199,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "start_run_attempt", WorkerApiRunAttemptStartResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/attempts/start`, { @@ -168,7 +222,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "complete_run_attempt", WorkerApiRunAttemptCompleteResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/attempts/complete`, { @@ -184,7 +239,8 @@ export class SupervisorHttpClient { } async getLatestSnapshot(runId: string, runnerId?: string, environmentId?: string) { - return wrapZodFetch( + return this.request( + "get_latest_snapshot", WorkerApiRunLatestSnapshotResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/latest`, { @@ -204,7 +260,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "get_snapshots_since", WorkerApiRunSnapshotsSinceResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/since/${snapshotId}`, { @@ -224,7 +281,8 @@ export class SupervisorHttpClient { } try { - const res = await wrapZodFetch( + const res = await this.request( + "send_debug_log", z.unknown(), `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/logs/debug`, { @@ -252,7 +310,8 @@ export class SupervisorHttpClient { runnerId?: string, environmentId?: string ) { - return wrapZodFetch( + return this.request( + "continue_run_execution", WorkerApiContinueRunExecutionRequestBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/continue`, { @@ -292,7 +351,8 @@ export class SupervisorHttpClient { runnerId?: string; body: WorkerApiSuspendRunRequestBody; }) { - return wrapZodFetch( + return this.request( + "submit_suspend_completion", WorkerApiSuspendRunResponseBody, `${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/suspend`, { diff --git a/packages/core/src/v3/runEngineWorker/supervisor/types.ts b/packages/core/src/v3/runEngineWorker/supervisor/types.ts index 4d0479cbca..aff6ffa7d2 100644 --- a/packages/core/src/v3/runEngineWorker/supervisor/types.ts +++ b/packages/core/src/v3/runEngineWorker/supervisor/types.ts @@ -1,5 +1,13 @@ import type { MachineResources } from "../../schemas/runEngine.js"; +export type SupervisorHttpRequestMetric = { + name: string; + method: string; + status: string; + outcome: "ok" | "http_error" | "invalid_response" | "network_error"; + durationMs: number; +}; + export type SupervisorClientCommonOptions = { apiUrl: string; workerToken: string; @@ -7,6 +15,7 @@ export type SupervisorClientCommonOptions = { deploymentId?: string; managedWorkerSecret?: string; sendRunDebugLogs?: boolean; + onHttpRequestComplete?: (metric: SupervisorHttpRequestMetric) => void; }; export type PreDequeueFn = () => Promise<{ From 9c85e0ecdc22cdf7599b15df4917c0269e4dc321 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 24 Jul 2026 11:52:34 +0100 Subject: [PATCH 04/23] perf(database): index BatchTaskRun on (runtimeEnvironmentId, createdAt, id) for the batches list (#4361) ## Summary The batches list page orders by `createdAt DESC, id DESC` filtered by environment and a created-at window, but the only supporting index on `BatchTaskRun` was `(runtimeEnvironmentId, id)`. That index can't satisfy the `createdAt` ordering, so on environments with a large number of batches the query fell back to a full table scan and in-memory sort, which could run long enough to hit the statement timeout. ## Fix Adds `(runtimeEnvironmentId, createdAt DESC, id DESC)` on `BatchTaskRun`. The query now reads straight from the index in order with no sort step, returning a page with only a handful of heap fetches instead of scanning the whole environment slice. The migration uses `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, so it takes no table lock and is a no-op if the index already exists. --- .server-changes/batch-list-created-at-index.md | 6 ++++++ .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 1 + 3 files changed, 9 insertions(+) create mode 100644 .server-changes/batch-list-created-at-index.md create mode 100644 internal-packages/database/prisma/migrations/20260724120000_add_batch_task_run_created_at_dashboard_index/migration.sql diff --git a/.server-changes/batch-list-created-at-index.md b/.server-changes/batch-list-created-at-index.md new file mode 100644 index 0000000000..b5a2438661 --- /dev/null +++ b/.server-changes/batch-list-created-at-index.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Speed up the Batches list page for environments with a large number of batches, which could previously time out while loading. diff --git a/internal-packages/database/prisma/migrations/20260724120000_add_batch_task_run_created_at_dashboard_index/migration.sql b/internal-packages/database/prisma/migrations/20260724120000_add_batch_task_run_created_at_dashboard_index/migration.sql new file mode 100644 index 0000000000..4ff6ed34bc --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260724120000_add_batch_task_run_created_at_dashboard_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_createdAt_id_idx" ON "public"."BatchTaskRun"("runtimeEnvironmentId", "createdAt" DESC, "id" DESC); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 2800dd5dd2..7d6f4ac549 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1935,6 +1935,7 @@ model BatchTaskRun { @@index([dependentTaskAttemptId]) // This is for the batch list dashboard page @@index([runtimeEnvironmentId, id(sort: Desc)]) + @@index([runtimeEnvironmentId, createdAt(sort: Desc), id(sort: Desc)]) } enum BatchTaskRunStatus { From 7188eecd83ceef77094e28b6b25804f4631550fe Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 24 Jul 2026 11:56:58 +0100 Subject: [PATCH 05/23] perf(webapp): clamp list-endpoint page size to 100 (#4360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Several list endpoints accepted an unbounded page size (`perPage` / `per_page` / `pageSize`). An unbounded page lets one request pull an arbitrarily large result set and do a proportional amount of work, which is a poor default for a shared API. This clamps the page size to 100 on every list endpoint that was uncapped, matching the existing cap on `api.v1.runs` and `api.v1.sessions`. Clamping rather than rejecting keeps existing clients working: a request for a larger page returns up to 100 items and offset pagination continues from there. ## Endpoints capped - `api.v1.schedules` (`perPage`) - `api.v1.queues` (`perPage`) - `resources.…versions` (`per_page`) - `resources.…queues` (`per_page`) - `admin.api.v1.…engine.report` (`per_page`) - `admin.api.v1.llm-models` (`pageSize`) Already capped, left as-is: `api.v1.runs`, `api.v1.sessions`, `api.v1.deployments`. --- .server-changes/cap-list-endpoint-page-size.md | 6 ++++++ ...min.api.v1.environments.$environmentId.engine.report.ts | 7 ++++++- apps/webapp/app/routes/admin.api.v1.llm-models.ts | 5 ++++- apps/webapp/app/routes/api.v1.queues.ts | 7 ++++++- apps/webapp/app/routes/api.v1.schedules.ts | 7 ++++++- ...tionSlug.projects.$projectParam.env.$envParam.queues.ts | 6 +++++- ...onSlug.projects.$projectParam.env.$envParam.versions.ts | 6 +++++- 7 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 .server-changes/cap-list-endpoint-page-size.md diff --git a/.server-changes/cap-list-endpoint-page-size.md b/.server-changes/cap-list-endpoint-page-size.md new file mode 100644 index 0000000000..2649f30578 --- /dev/null +++ b/.server-changes/cap-list-endpoint-page-size.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +List API endpoints now clamp the page size to a maximum of 100. Requests asking for a larger page size return up to 100 items and keep paginating, rather than pulling an unbounded page. 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 7179680d39..4fdca9475f 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 @@ -13,7 +13,12 @@ const ParamsSchema = z.object({ const SearchParamsSchema = z.object({ verbose: z.string().default("0"), page: z.coerce.number().optional(), - per_page: z.coerce.number().optional(), + per_page: z.coerce + .number() + .int() + .positive() + .transform((n) => Math.min(n, 100)) + .optional(), }); export async function loader({ request, params }: LoaderFunctionArgs) { 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 1d8136ca37..c35a1d6566 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.ts @@ -9,7 +9,10 @@ export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? "1"); - const pageSize = parseInt(url.searchParams.get("pageSize") ?? "50"); + const pageSize = Math.max( + 1, + Math.min(parseInt(url.searchParams.get("pageSize") ?? "50") || 50, 100) + ); const [models, total] = await Promise.all([ prisma.llmModel.findMany({ diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index 0e976ec813..b257a248a0 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -12,7 +12,12 @@ import { ServiceValidationError } from "~/v3/services/baseService.server"; const SearchParamsSchema = z.object({ page: z.coerce.number().int().positive().optional(), - perPage: z.coerce.number().int().positive().optional(), + perPage: z.coerce + .number() + .int() + .positive() + .transform((n) => Math.min(n, 100)) + .optional(), }); export const loader = createLoaderApiRoute( diff --git a/apps/webapp/app/routes/api.v1.schedules.ts b/apps/webapp/app/routes/api.v1.schedules.ts index 510cf45eac..b5fd2fd968 100644 --- a/apps/webapp/app/routes/api.v1.schedules.ts +++ b/apps/webapp/app/routes/api.v1.schedules.ts @@ -12,7 +12,12 @@ import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.serv const SearchParamsSchema = z.object({ page: z.coerce.number().int().positive().optional(), - perPage: z.coerce.number().int().positive().optional(), + perPage: z.coerce + .number() + .int() + .positive() + .transform((n) => Math.min(n, 100)) + .optional(), }); export async function action({ request }: ActionFunctionArgs) { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts index 98e31daa4d..b5c1755eae 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts @@ -9,7 +9,11 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder"; const SearchParamsSchema = z.object({ query: z.string().optional(), page: z.coerce.number().min(1).default(1), - per_page: z.coerce.number().min(1).default(20), + per_page: z.coerce + .number() + .min(1) + .transform((n) => Math.min(n, 100)) + .default(20), type: z.enum(["task", "custom"]).optional(), }); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts index f17f2a95c8..2275ab73ba 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts @@ -8,7 +8,11 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder"; const SearchParamsSchema = z.object({ query: z.string().optional(), - per_page: z.coerce.number().min(1).default(25), + per_page: z.coerce + .number() + .min(1) + .transform((n) => Math.min(n, 100)) + .default(25), }); export async function loader({ request, params }: LoaderFunctionArgs) { From bf41c5d5fc5e5f99e1badd916282419396a85a59 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:11:55 +0100 Subject: [PATCH 06/23] feat(supervisor): configurable warm-start dispatch url (#4362) Adds an optional `TRIGGER_WARM_START_DISPATCH_URL`. The warm-start dispatch request uses it when set, otherwise falls back to `TRIGGER_WARM_START_URL`, so the dispatch target can differ from the default warm-start URL. No behavior change when unset. --- apps/supervisor/src/env.ts | 1 + apps/supervisor/src/index.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 6c954b4e76..2d96cb1e40 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -112,6 +112,7 @@ export const Env = z // Optional services TRIGGER_WARM_START_URL: z.string().optional(), + TRIGGER_WARM_START_DISPATCH_URL: z.string().optional(), TRIGGER_CHECKPOINT_URL: z.string().optional(), TRIGGER_METADATA_URL: z.string().optional(), diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index f30203df54..39b9aa47e0 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -95,6 +95,8 @@ class ManagedSupervisor { private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED); private readonly warmStartUrl = env.TRIGGER_WARM_START_URL; + private readonly warmStartDispatchUrl = + env.TRIGGER_WARM_START_DISPATCH_URL ?? env.TRIGGER_WARM_START_URL; private readonly wideEventOpts: WideEventOptions = { service: "supervisor", @@ -698,7 +700,7 @@ class ManagedSupervisor { return false; } - const warmStartUrlWithPath = new URL("/warm-start", this.warmStartUrl); + const warmStartUrlWithPath = new URL("/warm-start", this.warmStartDispatchUrl); const headers: Record = { "Content-Type": "application/json", From 109e245d56e7f001c6ebd8a3ea54a8c31d03bc51 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:55:31 +0100 Subject: [PATCH 07/23] feat(webapp): show the first and last characters of a new PAT (#4363) --- .../pat-token-reveal-last-chars.md | 6 +++ .../components/primitives/ClipboardField.tsx | 40 ++++++++++++++++++- .../app/routes/account.tokens/route.tsx | 3 ++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .server-changes/pat-token-reveal-last-chars.md diff --git a/.server-changes/pat-token-reveal-last-chars.md b/.server-changes/pat-token-reveal-last-chars.md new file mode 100644 index 0000000000..29797f73d3 --- /dev/null +++ b/.server-changes/pat-token-reveal-last-chars.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +When you create a Personal Access Token, the generated token now shows its first and last few characters instead of being fully hidden, so you can confirm you copied the right value. diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index 0304e5bca6..7774f30121 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -60,9 +60,43 @@ const variants = { }, }; +const SECURE_MASK = "••••••••••••••••"; + +/** + * Builds the masked display string, optionally revealing the first/last few + * characters in cleartext so users can confirm a copied value. A custom mask + * string (when `secure` is a string) is always shown as-is. + */ +function maskValue( + value: string, + secure: boolean | string, + revealStart: number, + revealEnd: number +) { + if (typeof secure === "string") { + return secure; + } + + const start = Math.max(0, revealStart); + const end = Math.max(0, revealEnd); + + // Nothing to reveal, or revealing would leak the whole value: fully mask. + if ((start === 0 && end === 0) || start + end >= value.length) { + return SECURE_MASK; + } + + const revealedStart = start > 0 ? value.slice(0, start) : ""; + const revealedEnd = end > 0 ? value.slice(-end) : ""; + return `${revealedStart}${SECURE_MASK}${revealedEnd}`; +} + type ClipboardFieldProps = { value: string; secure?: boolean | string; + /** When masked, reveal this many of the first characters in cleartext. */ + secureRevealStart?: number; + /** When masked, reveal this many of the last characters in cleartext. */ + secureRevealEnd?: number; variant: keyof typeof variants; className?: string; icon?: React.ReactNode; @@ -73,6 +107,8 @@ type ClipboardFieldProps = { export function ClipboardField({ value, secure = false, + secureRevealStart = 0, + secureRevealEnd = 0, variant, className, icon, @@ -87,6 +123,8 @@ export function ClipboardField({ setIsSecure(secure !== undefined && secure); }, [secure]); + const maskedValue = maskValue(value, secure, secureRevealStart, secureRevealEnd); + return ( {icon && ( @@ -100,7 +138,7 @@ export function ClipboardField({ } From be45cf9e61d53d7455eaa9a549fe77deb8dd19d2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 24 Jul 2026 14:20:20 +0100 Subject: [PATCH 08/23] fix(sdk): preserve partial assistant message on chat stream failure (#4348) ## Summary When a `chat.agent` (or `chat.createSession`) turn's model stream fails mid-response (e.g. a transport timeout like `UND_ERR_BODY_TIMEOUT`), the assistant output that already streamed was dropped: `onTurnComplete` fired with `responseMessage: undefined`, and the manual loop's `turn.complete()` rethrew without keeping the partial. Apps that register `hydrateMessages` are hit hardest, since boot-time tail-replay recovery is off by design. This preserves the streamed-so-far assistant output while still reporting the turn as errored, so persistence and recovery keep it. ## Scope of behavior change Only the **error path** changes. Successful turns are unaffected: the same chunks stream to the client in the same order, and backpressure/cancel behave as before. Everything here is a correctness improvement on a turn that hit a source-stream failure. ## What it does Follow-up to #4304 (`chat.pipeAndCapture`), extending the same partial-recovery to the two loops that lacked it: - **`chat.agent`**: taps the response stream (via a `TransformStream`, so pass-through backpressure and cancel are preserved) to buffer chunks, and on a source-stream failure reconstructs the partial (preferring the `onFinish` message). It's surfaced on the error-path `onTurnComplete` (`responseMessage`, `rawResponseMessage`, `uiMessages`, `newUIMessages`, `newMessages`) and committed to the accumulator so the next turn and the reboot snapshot keep it. - **`chat.createSession` / `turn.complete()`**: the reconstructed partial is accumulated (so `turn.uiMessages` reflects it and the caller can persist after catching) before `turn.complete()` rethrows. `onBeforeTurnComplete` stays skipped on the error path (it hands out a writer for a stream that has already broken). ## Correctness properties (each covered by a regression test) Each test below was confirmed to fail without its fix: - The recovered partial reaches `onTurnComplete` and the next turn's accumulated messages. - An already-committed (possibly enriched) response is not overwritten if a post-response hook then throws. - Incomplete tool parts are cleaned from the recovered partial (text kept), so the UI and model views agree and the next turn isn't poisoned. - A prior turn's model-only compaction survives an errored turn (append only the new tail, don't reconvert the full history). - A reconstructed fragment that reuses an existing message id does not clobber the complete message. - Queued `chat.response` data parts are folded into the recovered partial, matching the success path. - `newMessages` (model delta) stays symmetric with `newUIMessages`. ## Tests New `chat-agent-source-stream-error.test.ts` covers the cases above. The full `@trigger.dev/sdk` unit suite passes and the package build is green across all supported runtimes (Node 20 to 26, Bun, Deno, Cloudflare Workers). --- ...gent-preserve-partial-on-stream-failure.md | 5 + packages/trigger-sdk/src/v3/ai.ts | 137 ++++-- .../chat-agent-source-stream-error.test.ts | 435 ++++++++++++++++++ 3 files changed, 551 insertions(+), 26 deletions(-) create mode 100644 .changeset/chat-agent-preserve-partial-on-stream-failure.md create mode 100644 packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts diff --git a/.changeset/chat-agent-preserve-partial-on-stream-failure.md b/.changeset/chat-agent-preserve-partial-on-stream-failure.md new file mode 100644 index 0000000000..3e7defddec --- /dev/null +++ b/.changeset/chat-agent-preserve-partial-on-stream-failure.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 4612302c4a..0931ff1d89 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6389,6 +6389,9 @@ function chatAgent< // Declared here so the finally can detach it — a handler leaked past // its turn duplicates every mid-stream message into the shared buffer. let turnMsgSub: { off: () => void } | undefined; + let capturedPartialResponse: TUIMessage | undefined; + let responseCommitted = false; + const turnBufferedChunks: UIMessageChunk[] = []; try { // Extract turn-level context before entering the span. Slim // wire: at most one delta message per record. `headStartMessages` @@ -7175,11 +7178,12 @@ function chatAgent< finishReason?: FinishReason; }) => { capturedResponseMessage = responseMessage as TUIMessage; + capturedPartialResponse = responseMessage as TUIMessage; capturedFinishReason = finishReason; resolveOnFinish!(); }, }); - await pipeChat(uiStream, { + await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), { signal: combinedSignal, spanName: "stream response", }); @@ -7390,6 +7394,12 @@ function chatAgent< } } + if (capturedResponseMessage) { + responseCommitted = true; + capturedPartialResponse = capturedResponseMessage; + turnBufferedChunks.length = 0; + } + if (runSignal.aborted) return "exit"; // Await deferred background work (e.g. DB writes from onTurnStart) @@ -7611,6 +7621,7 @@ function chatAgent< parts: [...(msg.parts ?? []), ...lateParts], } as TUIMessage; capturedResponseMessage = accumulatedUIMessages[idx] as TUIMessage; + capturedPartialResponse = capturedResponseMessage; turnCompleteEvent.responseMessage = capturedResponseMessage; turnCompleteEvent.uiMessages = accumulatedUIMessages; } @@ -7904,10 +7915,77 @@ function chatAgent< ? [...accumulatedUIMessages, erroredWireMessage] : accumulatedUIMessages; - // Fire onTurnComplete on the error path too — the docs promise it - // runs "after every turn, successful or errored" so customers can - // mark the turn failed. `responseMessage` is undefined/partial and - // `error` carries the thrown value. + let partialResponse: TUIMessage | undefined = + capturedPartialResponse ?? + ((await assemblePartialFromChunks(turnBufferedChunks)) as TUIMessage | undefined); + if (partialResponse) { + partialResponse = cleanupAbortedParts(partialResponse); + } + + let partialIdx = partialResponse?.id + ? erroredUIMessages.findIndex((m) => m.id === partialResponse!.id) + : -1; + if (partialResponse && capturedPartialResponse === undefined && partialIdx !== -1) { + partialResponse = undefined; + partialIdx = -1; + } + if (partialResponse && !partialResponse.id) { + partialResponse = { ...partialResponse, id: generateMessageId() } as TUIMessage; + } + if (partialResponse && !responseCommitted) { + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + partialResponse = { + ...partialResponse, + parts: [...partialResponse.parts, ...(queuedParts as UIMessage["parts"])], + } as TUIMessage; + locals.set(chatResponsePartsKey, []); + } + } + const includePartial = partialResponse != null && !responseCommitted; + let erroredUIMessagesWithPartial: TUIMessage[] = !includePartial + ? erroredUIMessages + : partialIdx === -1 + ? [...erroredUIMessages, partialResponse!] + : (erroredUIMessages.map((m, i) => + i === partialIdx ? partialResponse! : m + ) as TUIMessage[]); + + let erroredNewUIMessages: TUIMessage[] = erroredWireMessage ? [erroredWireMessage] : []; + if (includePartial) { + erroredNewUIMessages.push(partialResponse!); + } + + let erroredNewModelMessages: ModelMessage[] = []; + + if (!responseCommitted) { + try { + if (erroredNewUIMessages.length > 0) { + erroredNewModelMessages = await toModelMessages( + erroredNewUIMessages.map((m) => stripProviderMetadata(m)) + ); + } + if (erroredUIMessagesWithPartial !== accumulatedUIMessages) { + if (partialIdx === -1) { + const appended = erroredUIMessagesWithPartial.slice( + accumulatedUIMessages.length + ); + accumulatedMessages.push( + ...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))) + ); + } else { + accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial); + } + accumulatedUIMessages = erroredUIMessagesWithPartial; + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + } catch { + erroredNewModelMessages = []; + erroredUIMessagesWithPartial = erroredUIMessages; + erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : []; + } + } + if (onTurnComplete) { try { await tracer.startActiveSpan( @@ -7917,11 +7995,11 @@ function chatAgent< ctx, chatId: currentWirePayload.chatId, messages: accumulatedMessages, - uiMessages: erroredUIMessages, - newMessages: [], - newUIMessages: erroredWireMessage ? [erroredWireMessage] : [], - responseMessage: undefined, - rawResponseMessage: undefined, + uiMessages: erroredUIMessagesWithPartial, + newMessages: erroredNewModelMessages, + newUIMessages: erroredNewUIMessages, + responseMessage: partialResponse, + rawResponseMessage: partialResponse, turn, runId: ctx.run.id, chatAccessToken: "", @@ -7967,7 +8045,7 @@ function chatAgent< await writeChatSnapshot(sessionIdForSnapshot, { version: 1, savedAt: Date.now(), - messages: erroredUIMessages, + messages: erroredUIMessagesWithPartial, lastOutEventId: errorTurnCompleteResult?.lastEventId, lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined, @@ -8887,28 +8965,26 @@ export type PipeAndCaptureResult = { * can return, and propagates a source error to the consumer after buffering * whatever streamed first. See {@link pipeChatAndCapture} for why. */ -async function* tapUIMessageChunks( +function tapUIMessageChunks( source: AsyncIterable | ReadableStream, buffer: UIMessageChunk[] -): AsyncGenerator { +): ReadableStream | AsyncGenerator { if (isReadableStream(source)) { - const reader = source.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer.push(value as UIMessageChunk); - yield value; - } - } finally { - reader.releaseLock(); - } - } else { + return source.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer.push(chunk as UIMessageChunk); + controller.enqueue(chunk); + }, + }) + ); + } + return (async function* () { for await (const chunk of source) { buffer.push(chunk as UIMessageChunk); yield chunk; } - } + })(); } /** @@ -9744,6 +9820,15 @@ function createChatSession( // Surface a genuine stream failure to the caller. A user stop // (status "aborted") falls through so the partial is accumulated. if (captured.status === "error") { + if (captured.message) { + const partial = cleanupAbortedParts(captured.message); + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + (partial as any).parts = [...(partial.parts ?? []), ...queuedParts]; + locals.set(chatResponsePartsKey, []); + } + await accumulator.addResponse(partial); + } throw captured.error; } response = captured.message; diff --git a/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts b/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts new file mode 100644 index 0000000000..0f79b445f7 --- /dev/null +++ b/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts @@ -0,0 +1,435 @@ +// Import the test harness FIRST — this installs the resource catalog so +// `chat.agent()` calls below register their task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import type { ModelMessage, UIMessage } from "ai"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { chat } from "../src/v3/ai.js"; +import type { TurnCompleteEvent } from "../src/v3/ai.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function userMessage(text: string, id: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function extractText(message: UIMessage | undefined): string { + if (!message) return ""; + return (message.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); +} + +async function waitFor(check: () => boolean, timeoutMs = 5_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error("waitFor timed out"); +} + +/** + * A `run()` return value that looks like a `StreamTextResult` (has + * `toUIMessageStream()`) but whose UI stream emits a partial assistant + * message and then errors — reproducing a source-stream transport failure + * (e.g. `UND_ERR_BODY_TIMEOUT`) mid-turn. `onFinish` is never invoked, which + * is exactly what happens on a hard transport error. Chunks are delivered + * one-per-pull before the error so they aren't discarded (calling + * `controller.error()` in the same tick as `enqueue()` resets the queue). + */ +function erroringSource(errorMessage: string) { + const partialChunks = [ + { type: "start", messageId: "a-err" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "partial answer" }, + ]; + return sourceFromChunks(partialChunks, errorMessage); +} + +function sourceFromChunks(chunks: unknown[], errorMessage: string) { + return { + toUIMessageStream() { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(chunks[i++]); + } else { + controller.error(new Error(errorMessage)); + } + }, + }); + }, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("chat.agent managed loop — source-stream failure", () => { + it("preserves the partial assistant message on onTurnComplete when the source stream fails", async () => { + const turnCompletes: TurnCompleteEvent[] = []; + + const agent = chat.agent({ + id: "chatAgent.source-stream-error", + run: async () => erroringSource("UND_ERR_BODY_TIMEOUT") as never, + onTurnComplete: async (event) => { + turnCompletes.push(event); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-source-error" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => turnCompletes.length >= 1); + + const evt = turnCompletes[0]!; + + expect(evt.finishReason).toBe("error"); + expect(evt.error).toBeInstanceOf(Error); + expect((evt.error as Error).message).toBe("UND_ERR_BODY_TIMEOUT"); + + expect(evt.responseMessage).toBeDefined(); + expect(extractText(evt.responseMessage)).toBe("partial answer"); + + const newAssistantText = (evt.newMessages as ModelMessage[]) + .filter((m) => m.role === "assistant") + .map((m) => + typeof m.content === "string" + ? m.content + : (m.content as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("") + ) + .join(""); + expect(newAssistantText).toBe("partial answer"); + + expect((evt.newMessages as ModelMessage[]).some((m) => m.role === "user")).toBe(true); + expect((evt.newUIMessages as UIMessage[]).some((m) => m.role === "user")).toBe(true); + } finally { + await harness.close(); + } + }); + + it("carries the recovered partial into the next turn's accumulated messages", async () => { + let turn = 0; + let turn2Messages: ModelMessage[] | undefined; + + const okStream = () => + simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t2" }, + { type: "text-delta", id: "t2", delta: "second answer" }, + { type: "text-end", id: "t2" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }); + + const agent = chat.agent({ + id: "chatAgent.source-stream-error-continuation", + run: async ({ messages }) => { + turn++; + if (turn === 1) { + return erroringSource("UND_ERR_BODY_TIMEOUT") as never; + } + turn2Messages = messages; + return streamText({ + model: new MockLanguageModelV3({ doStream: async () => ({ stream: okStream() }) }), + messages, + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-source-error-cont" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await harness.sendMessage(userMessage("still there?", "u-2")); + await waitFor(() => turn2Messages !== undefined); + + const assistantText = turn2Messages! + .filter((m) => m.role === "assistant") + .map((m) => + typeof m.content === "string" + ? m.content + : (m.content as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("") + ) + .join(""); + expect(assistantText).toContain("partial answer"); + } finally { + await harness.close(); + } + }); + + it("does not overwrite an already-committed enriched response when a post-response hook throws", async () => { + const events: TurnCompleteEvent[] = []; + + const okModel = (text: string) => + new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }), + }), + }); + + const agent = chat.agent({ + id: "chatAgent.post-commit-hook-throw", + run: async ({ messages }) => { + chat.response.write({ type: "data-marker", data: { kept: true } } as never); + return streamText({ model: okModel("full response"), messages }); + }, + onTurnComplete: async (event) => { + events.push(event); + if (event.error == null) { + throw new Error("hook boom after commit"); + } + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-post-commit-throw" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => events.some((e) => e.error != null)); + + const errorEvent = events.find((e) => e.error != null)!; + const assistant = (errorEvent.uiMessages as UIMessage[]).find((m) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect( + (assistant!.parts as Array<{ type: string }>).some((p) => p.type === "data-marker") + ).toBe(true); + expect(extractText(assistant)).toBe("full response"); + } finally { + await harness.close(); + } + }); + + it("does not clobber an existing message when a reconstructed fragment reuses its id", async () => { + let turn = 0; + let firstAssistantId: string | undefined; + const events: TurnCompleteEvent[] = []; + + const okModel = () => + new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "first answer" }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }), + }), + }); + + const collidingErroringSource = (id: string) => ({ + toUIMessageStream() { + const chunks = [ + { type: "start", messageId: id }, + { type: "text-start", id: "t2" }, + { type: "text-delta", id: "t2", delta: "clobber" }, + ]; + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(chunks[i++]); + else controller.error(new Error("UND_ERR_BODY_TIMEOUT")); + }, + }); + }, + }); + + const agent = chat.agent({ + id: "chatAgent.fragment-id-collision", + run: async ({ messages }) => { + turn++; + if (turn === 1) { + return streamText({ model: okModel(), messages }); + } + return collidingErroringSource(firstAssistantId!) as never; + }, + onTurnComplete: async (event) => { + events.push(event); + if (event.error == null && event.responseMessage) { + firstAssistantId = event.responseMessage.id; + } + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-fragment-collision" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => firstAssistantId !== undefined); + await harness.sendMessage(userMessage("again", "u-2")); + await waitFor(() => events.some((e) => e.error != null)); + + const errorEvent = events.find((e) => e.error != null)!; + const preserved = (errorEvent.uiMessages as UIMessage[]).find( + (m) => m.id === firstAssistantId + ); + expect(preserved).toBeDefined(); + expect(extractText(preserved)).toBe("first answer"); + expect( + (errorEvent.uiMessages as UIMessage[]).some((m) => extractText(m).includes("clobber")) + ).toBe(false); + } finally { + await harness.close(); + } + }); + + it("cleans dangling tool parts from the recovered partial while keeping its text", async () => { + const turnCompletes: TurnCompleteEvent[] = []; + + const agent = chat.agent({ + id: "chatAgent.error-partial-cleanup", + run: async () => + sourceFromChunks( + [ + { type: "start", messageId: "a-tool" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "thinking" }, + { type: "text-end", id: "t1" }, + { type: "tool-input-start", toolCallId: "tc1", toolName: "search" }, + { + type: "tool-input-available", + toolCallId: "tc1", + toolName: "search", + input: { q: "x" }, + }, + ], + "UND_ERR_BODY_TIMEOUT" + ) as never, + onTurnComplete: async (event) => { + turnCompletes.push(event); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-partial-cleanup" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => turnCompletes.length >= 1); + + const evt = turnCompletes[0]!; + expect(evt.responseMessage).toBeDefined(); + const parts = evt.responseMessage!.parts as Array<{ type: string }>; + expect(extractText(evt.responseMessage)).toBe("thinking"); + expect(parts.some((p) => p.type.startsWith("tool-"))).toBe(false); + } finally { + await harness.close(); + } + }); + + it("folds queued response data parts into the recovered partial", async () => { + const turnCompletes: TurnCompleteEvent[] = []; + + const agent = chat.agent({ + id: "chatAgent.error-queued-parts", + run: async () => { + chat.response.write({ type: "data-marker", data: { kept: true } } as never); + return erroringSource("UND_ERR_BODY_TIMEOUT") as never; + }, + onTurnComplete: async (event) => { + turnCompletes.push(event); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-error-queued-parts" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => turnCompletes.length >= 1); + + const evt = turnCompletes[0]!; + expect(evt.responseMessage).toBeDefined(); + const parts = evt.responseMessage!.parts as Array<{ type: string }>; + expect(extractText(evt.responseMessage)).toBe("partial answer"); + expect(parts.some((p) => p.type === "data-marker")).toBe(true); + } finally { + await harness.close(); + } + }); +}); + +describe("chat.createSession turn.complete() — source-stream failure", () => { + it("accumulates the partial before rethrowing so the caller can persist it", async () => { + let caughtError: unknown; + let uiMessagesAfterError: UIMessage[] = []; + + const agent = chat.customAgent({ + id: "createSession.source-stream-error", + run: async (payload) => { + const session = chat.createSession(payload, { + signal: new AbortController().signal, + idleTimeoutInSeconds: 2, + }); + for await (const turn of session) { + try { + await turn.complete(erroringSource("UND_ERR_BODY_TIMEOUT") as never); + } catch (err) { + caughtError = err; + uiMessagesAfterError = [...turn.uiMessages]; + await turn.done(); + } + } + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cs-source-error" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => caughtError !== undefined); + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toBe("UND_ERR_BODY_TIMEOUT"); + + const partial = uiMessagesAfterError.find((m) => m.role === "assistant"); + expect(partial).toBeDefined(); + expect(extractText(partial)).toBe("partial answer"); + } finally { + await harness.close(); + } + }); +}); From d3906241a53e7590095bc26020e87c75409f2029 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 26 Jul 2026 19:43:41 +0100 Subject: [PATCH 09/23] feat(webapp): read realtime run rows from the primary, not the replica (#4378) ## Summary The realtime runs feed hydrates run rows from read replicas, which means it needs a replica-lag gate to avoid serving a run's previous state right after a write. Setting `REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY=1` reads those rows from each run store's primary instead, so there is no lag to gate against: no probe, no wake delay, no stale-read retries. Off by default, so nothing changes unless you set it. ## Design The run stores already decide replica-vs-primary from the *brand* on the read client they are handed: a branded replica keeps the read on the owning store's replica, an unbranded writer escalates it to that store's own primary. So this is a one-line choice at the hydrator, and it stays correct across topologies. With the run-ops split on, each leg lands on its own writer and the caller's client is never forwarded across databases; with the split off, it is the single database's primary. ```ts const runReader = new RunHydrator({ readClient: runReadsFromPrimary ? prisma : $replica, runStore, }); ``` The same flag skips constructing the lag estimator, since probing a replica the feed no longer reads would be measuring the wrong thing. Independently, `AuroraReplicaLagSource` detected Aurora by letting `aurora_replica_status()` fail, on the assumption that the app-level catch made that free. It isn't: an unresolvable function is a query error the driver reports to the error log on every sample, so a non-Aurora replica produced a continuous stream of error events while the estimator quietly fell through to its next candidate. It now resolves the function with `to_regproc` and memoizes the answer, so the unparseable call never reaches the wire. --- .../realtime-run-reads-from-primary.md | 6 +++ apps/webapp/app/env.server.ts | 1 + .../nativeRealtimeClientInstance.server.ts | 8 ++-- .../realtime/replicaLagEstimator.server.ts | 14 ++++++- .../app/services/realtime/runReader.server.ts | 12 +++--- .../shadowRealtimeClientInstance.server.ts | 2 +- .../test/realtime/replicaLagEstimator.test.ts | 41 +++++++++++++++++++ .../test/realtime/runReaderProjection.test.ts | 5 ++- .../realtime/runReaderReadThrough.test.ts | 18 ++++---- .../test/realtimeServices.replicaLag.test.ts | 4 +- 10 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 .server-changes/realtime-run-reads-from-primary.md diff --git a/.server-changes/realtime-run-reads-from-primary.md b/.server-changes/realtime-run-reads-from-primary.md new file mode 100644 index 0000000000..c52ea44078 --- /dev/null +++ b/.server-changes/realtime-run-reads-from-primary.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Realtime run subscriptions can now be configured to read run data straight from the primary database, so a run's latest state is never served from a lagging replica. Off by default; replica reads are unchanged unless you turn it on. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 53b5edf0a4..2d28827f7d 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -462,6 +462,7 @@ const EnvironmentSchema = z // TTL/size of the per-org realtimeBackend flag cache used to pick the serving backend. REALTIME_BACKEND_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000), REALTIME_BACKEND_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(50_000), + REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY: z.string().default("0"), // "1" enables the read-your-writes gate: wake hydrates wait out the measured replica lag // (anchored to the change record's updatedAtMs) and stale reads are retried. REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED: z.string().default("1"), diff --git a/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts b/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts index 77a9a02e5f..b1552a1ec3 100644 --- a/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts +++ b/apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts @@ -1,5 +1,5 @@ import { getMeter } from "@internal/tracing"; -import { $replica } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; @@ -130,9 +130,11 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient { }) : undefined; + const runReadsFromPrimary = env.REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY === "1"; + // One RunHydrator shared by the router and the client, so its single-flight + short-TTL cache covers both. const runReader = new RunHydrator({ - replica: $replica, + readClient: runReadsFromPrimary ? prisma : $replica, runStore, cacheTtlMs: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_TTL_MS, maxCacheEntries: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_MAX_ENTRIES, @@ -142,7 +144,7 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient { // when idle) and the router delays wake hydrates by it, anchored to each record's // updatedAtMs — so a publish racing the replica's apply is waited out, not read stale. const lagEstimator = - env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED === "1" + !runReadsFromPrimary && env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED === "1" ? new ReplicaLagEstimator({ source: createPostgresReplicaLagSource($replica), sampleIntervalMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_SAMPLE_INTERVAL_MS, diff --git a/apps/webapp/app/services/realtime/replicaLagEstimator.server.ts b/apps/webapp/app/services/realtime/replicaLagEstimator.server.ts index 17aa7e2b89..d077ea3243 100644 --- a/apps/webapp/app/services/realtime/replicaLagEstimator.server.ts +++ b/apps/webapp/app/services/realtime/replicaLagEstimator.server.ts @@ -25,13 +25,25 @@ export interface ReplicaLagSource { /** Aurora: replicas share the storage layer and reject every standard WAL function; * `aurora_replica_status()` is the only live lag source. Max across readers, since the * `$replica` pool balances over all of them. No reader rows = `$replica` is the writer = - * no lag. Throws on non-Aurora (the function doesn't exist). */ + * no lag. Throws on non-Aurora, but detects that with a `to_regproc` lookup rather than by + * letting the call fail — an unresolvable function is a query error the driver reports to the + * error log (and Sentry) regardless of the app-level catch, once per sample. */ export class AuroraReplicaLagSource implements ReplicaLagSource { readonly name = "aurora"; + #available: boolean | undefined; constructor(private readonly db: RawQueryable) {} async sampleLagMs(): Promise { + if (this.#available === undefined) { + const probe = await this.db.$queryRawUnsafe<{ available: boolean | null }[]>( + `SELECT to_regproc('aurora_replica_status') IS NOT NULL AS available` + ); + this.#available = probe[0]?.available === true; + } + if (!this.#available) { + throw new Error("aurora_replica_status() is not available on this database"); + } const rows = await this.db.$queryRawUnsafe<{ lag: number | null }[]>( `SELECT max(replica_lag_in_msec)::float8 AS lag FROM aurora_replica_status() WHERE session_id <> 'MASTER_SESSION_ID' AND replica_lag_in_msec IS NOT NULL` ); diff --git a/apps/webapp/app/services/realtime/runReader.server.ts b/apps/webapp/app/services/realtime/runReader.server.ts index c6a34d7de7..c215423b1d 100644 --- a/apps/webapp/app/services/realtime/runReader.server.ts +++ b/apps/webapp/app/services/realtime/runReader.server.ts @@ -82,9 +82,11 @@ export interface RunListResolver { } export type RunHydratorOptions = { - /** A read-replica Prisma client (`$replica`). Always Postgres. */ - replica: Pick; - /** RunStore the reads are routed through; `replica` is passed as the read client. */ + /** The Prisma client handed to the RunStore as the read client. Always Postgres. A branded + * replica (`$replica`) keeps routed reads on each store's replica; an unbranded writer + * (`prisma`) escalates them to each store's own primary. */ + readClient: Pick; + /** RunStore the reads are routed through. */ runStore: RunStore; /** Read-through cache TTL (ms) collapsing duplicate refetches for the same run. Set 0 to disable. Defaults to 250ms. */ cacheTtlMs?: number; @@ -154,7 +156,7 @@ export class RunHydrator { }, select: buildHydratorSelect(skipColumns), }, - this.options.replica as PrismaClientOrTransaction + this.options.readClient as PrismaClientOrTransaction ); return rows as unknown as RealtimeRunRow[]; } @@ -166,7 +168,7 @@ export class RunHydrator { runtimeEnvironmentId: environmentId, }, { select: RUN_HYDRATOR_SELECT }, - this.options.replica as PrismaClientOrTransaction + this.options.readClient as PrismaClientOrTransaction ); return (run ?? null) as RealtimeRunRow | null; diff --git a/apps/webapp/app/services/realtime/shadowRealtimeClientInstance.server.ts b/apps/webapp/app/services/realtime/shadowRealtimeClientInstance.server.ts index 7453b10783..d480319165 100644 --- a/apps/webapp/app/services/realtime/shadowRealtimeClientInstance.server.ts +++ b/apps/webapp/app/services/realtime/shadowRealtimeClientInstance.server.ts @@ -21,7 +21,7 @@ function initializeShadowRealtimeClient(): ShadowRealtimeClient { }); const comparator = new RealtimeShadowComparator({ - runReader: new RunHydrator({ replica: $replica, runStore }), + runReader: new RunHydrator({ readClient: $replica, runStore }), runListResolver: new ClickHouseRunListResolver({ getClickhouse: (organizationId) => clickhouseFactory.getClickhouseForOrganization(organizationId, "realtime"), diff --git a/apps/webapp/test/realtime/replicaLagEstimator.test.ts b/apps/webapp/test/realtime/replicaLagEstimator.test.ts index 7666ea28ef..a4a233b613 100644 --- a/apps/webapp/test/realtime/replicaLagEstimator.test.ts +++ b/apps/webapp/test/realtime/replicaLagEstimator.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { + AuroraReplicaLagSource, FirstSupportedReplicaLagSource, ReplicaLagEstimator, type ReplicaLagSource, @@ -162,3 +163,43 @@ describe("FirstSupportedReplicaLagSource", () => { expect(composed.name).toBe("flaky"); }); }); + +describe("AuroraReplicaLagSource", () => { + function fakeDb(available: boolean) { + const queries: string[] = []; + return { + queries, + db: { + async $queryRawUnsafe(query: string): Promise { + queries.push(query); + if (query.includes("to_regproc")) { + return [{ available: available ? true : null }] as T; + } + return [{ lag: 12.5 }] as T; + }, + }, + }; + } + + it("never puts aurora_replica_status() on the wire when the function is absent", async () => { + const { db, queries } = fakeDb(false); + const aurora = new AuroraReplicaLagSource(db); + + await expect(aurora.sampleLagMs()).rejects.toThrow(/not available/); + await expect(aurora.sampleLagMs()).rejects.toThrow(/not available/); + + expect(queries.filter((q) => q.includes("FROM aurora_replica_status()"))).toHaveLength(0); + expect(queries.filter((q) => q.includes("to_regproc"))).toHaveLength(1); + }); + + it("samples lag on Aurora and probes for the function only once", async () => { + const { db, queries } = fakeDb(true); + const aurora = new AuroraReplicaLagSource(db); + + expect(await aurora.sampleLagMs()).toBe(12.5); + expect(await aurora.sampleLagMs()).toBe(12.5); + + expect(queries.filter((q) => q.includes("to_regproc"))).toHaveLength(1); + expect(queries.filter((q) => q.includes("FROM aurora_replica_status()"))).toHaveLength(2); + }); +}); diff --git a/apps/webapp/test/realtime/runReaderProjection.test.ts b/apps/webapp/test/realtime/runReaderProjection.test.ts index ad6616f546..7b92e1f96e 100644 --- a/apps/webapp/test/realtime/runReaderProjection.test.ts +++ b/apps/webapp/test/realtime/runReaderProjection.test.ts @@ -56,7 +56,10 @@ describe("RunHydrator.hydrateByIds column projection", () => { }, } as any; const runStore = new PostgresRunStore({ prisma: replica, readOnlyPrisma: replica }); - return { hydrator: new RunHydrator({ replica, runStore }), getSelect: () => capturedSelect }; + return { + hydrator: new RunHydrator({ readClient: replica, runStore }), + getSelect: () => capturedSelect, + }; } it("projects the SELECT by skipColumns", async () => { diff --git a/apps/webapp/test/realtime/runReaderReadThrough.test.ts b/apps/webapp/test/realtime/runReaderReadThrough.test.ts index cd23d954fe..8146b52795 100644 --- a/apps/webapp/test/realtime/runReaderReadThrough.test.ts +++ b/apps/webapp/test/realtime/runReaderReadThrough.test.ts @@ -218,7 +218,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () = }); const runStore = makeRoutingShapedStore({ newStore, legacyStore }); - const hydrator = new RunHydrator({ replica: prisma14, runStore }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore }); const rows = await hydrator.hydrateByIds(envId, [newRunId, legacyRunId]); expect(rows.map((r) => r.id).sort()).toEqual([legacyRunId, newRunId].sort()); @@ -267,7 +267,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () = }); const runStore = makeRoutingShapedStore({ newStore, legacyStore }); - const hydrator = new RunHydrator({ replica: prisma14, runStore }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore }); const row = await hydrator.getRunById(envId, migratedRunId); expect(row?.id).toBe(migratedRunId); @@ -300,7 +300,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () = }); const runStore = makeRoutingShapedStore({ newStore, legacyStore }); - const hydrator = new RunHydrator({ replica: prisma14, runStore }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore }); const byId = await hydrator.getRunById(envId, oldRunId); expect(byId?.payload).toBe('{"era":"old"}'); @@ -338,7 +338,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () = // A generic legacy replica would miss the NEW row entirely — the metadata must come off NEW. const runStore = makeRoutingShapedStore({ newStore, legacyStore }); - const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 0 }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 0 }); const snapshot = await hydrator.getRunById(envId, terminalRunId); expect(snapshot?.id).toBe(terminalRunId); @@ -385,7 +385,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () = // Use a 0ms TTL so each getRunById re-reads through the seam (no cached stale row across the // crossing). Single-flight/TTL are proven separately below. const runStore = makeRoutingShapedStore({ newStore, legacyStore, classify }); - const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 0 }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 0 }); // Before migration: served from LEGACY. const before = await hydrator.getRunById(envId, runId); @@ -434,7 +434,7 @@ describe("RunHydrator single-flight + TTL cache intact across the seam", () => { }); const runStore = makeRoutingShapedStore({ newStore, legacyStore }); - const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 60_000 }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 60_000 }); // Two concurrent calls -> single-flight collapses to ONE underlying read. const [a, b] = await Promise.all([ @@ -466,7 +466,7 @@ describe("RunHydrator single-flight + TTL cache intact across the seam", () => { const missingRunId = newId("missing_run"); const runStore = makeRoutingShapedStore({ newStore, legacyStore }); - const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 60_000 }); + const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 60_000 }); const first = await hydrator.getRunById(envId, missingRunId); expect(first).toBeNull(); @@ -504,7 +504,7 @@ describe("RunHydrator single-DB passthrough (one PostgresRunStore over one clien }); } - const hydrator = new RunHydrator({ replica: prisma, runStore: store, cacheTtlMs: 60_000 }); + const hydrator = new RunHydrator({ readClient: prisma, runStore: store, cacheTtlMs: 60_000 }); // hydrateByIds returns both rows from the single client. const rows = await hydrator.hydrateByIds(envId, [runIdA, runIdB]); @@ -523,7 +523,7 @@ describe("RunHydrator single-DB passthrough (one PostgresRunStore over one clien postgresTest("empty id-set returns [] without touching the store", async ({ prisma }) => { const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); const findRunsSpy = vi.spyOn(store, "findRuns"); - const hydrator = new RunHydrator({ replica: prisma, runStore: store }); + const hydrator = new RunHydrator({ readClient: prisma, runStore: store }); const rows = await hydrator.hydrateByIds("env_none", []); expect(rows).toEqual([]); diff --git a/apps/webapp/test/realtimeServices.replicaLag.test.ts b/apps/webapp/test/realtimeServices.replicaLag.test.ts index 7df81778db..02bb605afe 100644 --- a/apps/webapp/test/realtimeServices.replicaLag.test.ts +++ b/apps/webapp/test/realtimeServices.replicaLag.test.ts @@ -216,7 +216,7 @@ describe("realtime-svc — replica-lag guards", () => { const replica = laggingReplica(prisma, [{ model: "taskRun", mode: "missing" }]); // The hydrator holds the real store; hydrateByIds passes `options.replica` as the read client. const hydrator = new RunHydrator({ - replica: replica.client as PrismaClient, + readClient: replica.client as PrismaClient, runStore: writerStore, cacheTtlMs: 0, }); @@ -263,7 +263,7 @@ describe("realtime-svc — replica-lag guards", () => { const replica = laggingReplica(prisma, [{ model: "taskRun", mode: "missing" }]); const hydrator = new RunHydrator({ - replica: replica.client as PrismaClient, + readClient: replica.client as PrismaClient, runStore: writerStore, cacheTtlMs: 0, }); From 4d8b5d6f8709ddaf705de08a1215abaa6fe20651 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:51:05 +0000 Subject: [PATCH 10/23] chore(ci): remove per-repo Dependabot alert workflows (#4384) 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. - [ ] I ran and tested the code works — n/a, this PR only deletes two workflow files --- ## Summary **Before:** two scheduled workflows in this repo posted Dependabot digests to Slack — a critical-alert check every morning at 08:00 UTC, and a summary of all open alerts on Mondays at 08:00 UTC. **After:** neither runs. This reporting is handled centrally now, so the two in-repo workflows were duplicating it. **How:** deletes `.github/workflows/dependabot-critical-alerts.yml` and `.github/workflows/dependabot-weekly-summary.yml`. Both were self-contained — inline shell, no shared scripts or composite actions — so nothing else in `.github/` referenced them. Dependabot itself is unchanged: `.github/dependabot.yml`, alerts, and version updates all keep working. This removes only the two Slack notifiers. The `ENABLE_DEPENDABOT_ALERTS` repository variable existed only to switch these two workflows off. Nothing else reads it, so it can be removed from the repository settings if it's set. --- ## Testing No runtime code changes — this PR only removes two scheduled workflow files. Verified that nothing else in the repo references either filename, either workflow name, or the `ENABLE_DEPENDABOT_ALERTS` variable. --- ## Changelog Removed the two in-repo scheduled workflows that posted Dependabot digests to Slack. Co-authored-by: Claude --- .../workflows/dependabot-critical-alerts.yml | 87 -------- .../workflows/dependabot-weekly-summary.yml | 210 ------------------ 2 files changed, 297 deletions(-) delete mode 100644 .github/workflows/dependabot-critical-alerts.yml delete mode 100644 .github/workflows/dependabot-weekly-summary.yml diff --git a/.github/workflows/dependabot-critical-alerts.yml b/.github/workflows/dependabot-critical-alerts.yml deleted file mode 100644 index 62d68c3002..0000000000 --- a/.github/workflows/dependabot-critical-alerts.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Dependabot Critical Alerts - -on: - schedule: - - cron: "0 8 * * *" # Daily 08:00 UTC - workflow_dispatch: - inputs: - severity: - description: "Severity to alert on" - type: choice - options: - - critical - - high - - medium - - low - default: critical - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - alert: - name: Post critical alerts - # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off - # the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the - # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled. - if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }} - runs-on: warp-ubuntu-latest-x64-2x - environment: dependabot-summary - env: - SEVERITY: ${{ inputs.severity || 'critical' }} - steps: - - name: Fetch alerts - id: alerts - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - gh api -X GET "/repos/$REPO/dependabot/alerts" \ - -F state=open -F severity="$SEVERITY" --paginate > pages.json - jq -s 'add' pages.json > alerts.json - TOTAL=$(jq 'length' alerts.json) - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - if [ "$TOTAL" = "0" ]; then - exit 0 - fi - LIST=$(jq -r ' - map("• <\(.html_url)|#\(.number)> *\(.dependency.package.name)* - \(.security_advisory.summary)") - | join("\n") - ' alerts.json) - { - echo "list<> "$GITHUB_OUTPUT" - - - name: Build Slack payload - if: steps.alerts.outputs.total != '0' - env: - REPO: ${{ github.repository }} - CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} - TOTAL: ${{ steps.alerts.outputs.total }} - LIST: ${{ steps.alerts.outputs.list }} - run: | - jq -n \ - --arg channel "$CHANNEL" \ - --arg repo "$REPO" \ - --arg total "$TOTAL" \ - --arg list "$LIST" \ - --arg severity "$SEVERITY" \ - '{ - channel: $channel, - text: ":bufo-alarma: `\($repo)` - *\($total) open \($severity) alert(s)*\n\($list)\n\n" - }' > payload.json - - - name: Post Slack alert - if: steps.alerts.outputs.total != '0' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload-file-path: payload.json diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml deleted file mode 100644 index fc9eaebcb9..0000000000 --- a/.github/workflows/dependabot-weekly-summary.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: Dependabot Weekly Summary - -on: - schedule: - - cron: "0 8 * * 1" # Mon 08:00 UTC - workflow_dispatch: - -# Single-purpose monitoring workflow; serialise on workflow name only - we never -# want two concurrent summary runs racing to post the same digest. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read # gh CLI baseline - pull-requests: read # gh pr list (open dependabot PRs) - actions: read # gh run list / view (parse latest dependabot run logs) - -jobs: - summary: - name: Post weekly Dependabot summary - # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off - # the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the - # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled. - if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }} - runs-on: warp-ubuntu-latest-x64-2x - environment: dependabot-summary - env: - # Severities surface in the actions list when their remaining TTR drops - # below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS. - THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }} - steps: - - name: Fetch alerts and compute summaries - id: alerts - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} - REPO: ${{ github.repository }} - run: | - if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then - echo "total=?" >> "$GITHUB_OUTPUT" - ERR=$(head -c 200 err.txt | tr '\n' ' ') - echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT" - echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT" - exit 0 - fi - jq -s '[.[][] | select(.state == "open")]' pages.json > open.json - - TOTAL=$(jq 'length' open.json) - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - - if [ "$TOTAL" = "0" ]; then - echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT" - echo "actions=_None_" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Severity breakdown - real newlines so jq --arg in the payload - # builder encodes them as proper \n in JSON (Slack renders as breaks). - BY_SEV=$(jq -r ' - group_by(.security_advisory.severity) - | map({sev: .[0].security_advisory.severity, - count: length, - weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])}) - | sort_by(.weight) - | map("• *\(.count)* \(.sev)") - | join("\n") - ' open.json) - { - echo "by_severity<> "$GITHUB_OUTPUT" - - # Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) - # Grouped by (package, severity); shows earliest deadline per group. - ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" ' - [.[] - | (.security_advisory.severity) as $sev - | ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr - | select($ttr != null) - | ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age - | {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)} - ] - | group_by([.pkg, .sev]) - | map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)}) - | map(select(.min_remaining < $threshold)) - | sort_by(.min_remaining) - | if length == 0 then "_None_" - else (map( - "• *\(.pkg)* (\(.sev))" + - (if .count > 1 then " ×\(.count)" else "" end) + " - " + - (if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d" - else "\(.min_remaining)d remaining" end) - ) | join("\n")) - end - ' open.json) - { - echo "actions<> "$GITHUB_OUTPUT" - - - name: Fetch open dependabot PRs - id: prs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - REPO_URL: https://github.com/${{ github.repository }} - run: | - if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then - ERR=$(head -c 200 err.txt | tr '\n' ' ') - echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT" - exit 0 - fi - LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" ' - if length == 0 then "_None_" - else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n")) - end - ') - { - echo "list<> "$GITHUB_OUTPUT" - - - name: Find latest npm dependabot run - id: latest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - # Repos without a dependabot.yml have no "Dependabot Updates" workflow; - # treat the lookup failure as "no recent run found" rather than failing. - if ! RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq 'first(.[] | select(.name | startswith("npm_and_yarn")) | .databaseId) // empty' 2>/dev/null); then - RUN_ID="" - fi - echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" - - - name: Extract stuck deps (only if actions pending) - id: stuck - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - RUN_ID: ${{ steps.latest.outputs.run_id }} - ACTIONS: ${{ steps.alerts.outputs.actions }} - run: | - # Skip the stuck section entirely when nothing in the actions list - # - keeps the digest tidy when there's nothing to actually act on. - if [ "$ACTIONS" = "_None_" ]; then - echo "section=" >> "$GITHUB_OUTPUT" - exit 0 - fi - HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n' - if [ -z "$RUN_ID" ]; then - { - echo "section<> "$GITHUB_OUTPUT" - exit 0 - fi - gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true - STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true) - if [ -z "$STUCK" ]; then - { - echo "section<> "$GITHUB_OUTPUT" - exit 0 - fi - LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}') - { - echo "section<> "$GITHUB_OUTPUT" - - - name: Build Slack payload - env: - REPO: ${{ github.repository }} - CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} - TOTAL: ${{ steps.alerts.outputs.total }} - BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }} - PRS_LIST: ${{ steps.prs.outputs.list }} - ACTIONS: ${{ steps.alerts.outputs.actions }} - STUCK: ${{ steps.stuck.outputs.section }} - run: | - # Build payload via jq so PR titles or error strings containing - # quotes/backslashes/newlines can't break the JSON. - jq -n \ - --arg channel "$CHANNEL" \ - --arg repo "$REPO" \ - --arg total "$TOTAL" \ - --arg by_severity "$BY_SEVERITY" \ - --arg prs_list "$PRS_LIST" \ - --arg actions "$ACTIONS" \ - --arg stuck "$STUCK" \ - --arg threshold "$THRESHOLD_DAYS" \ - '{ - channel: $channel, - text: ":calendar: *Weekly Dependabot summary* - `\($repo)`\n\n*Open alerts (\($total)):*\n\($by_severity)\n\n*Open Dependabot PRs:*\n\($prs_list)\n\n*Actions needed (<\($threshold)d remaining):*\n\($actions)\($stuck)\n\n" - }' > payload.json - - - name: Post Slack summary - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload-file-path: payload.json From d91818f198e74e0eb4c5d09022c2a5fa3f38d031 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:39:43 +0100 Subject: [PATCH 11/23] fix(webapp): remove unawaited task list metrics promises (#4380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested via [Slack thread](https://triggerdotdev.slack.com/archives/C097ZHVKZFA/p1785082528841609)_ `TaskListPresenter` created promises that nothing ever consumed. Two of the three deferred metrics promises it returned had no reader, no `await` and no `.catch()`, so when the query behind one of them failed the rejection had nowhere to go. ## Before / After **Before** - `TaskListPresenter.call()` returned four things: `tasks`, `activity`, `runningStats` and `durations`. Its only caller reads `tasks` and `runningStats`. - Every load of the tasks page therefore fired two ClickHouse queries whose results were thrown away. - If either of those two queries failed, the resulting promise rejection was unhandled — nothing was awaiting it and nothing had attached an error handler, so it surfaced as an unhandled rejection at the process level rather than as an error anyone could attribute to a request. **After** - `TaskListPresenter.call()` returns `tasks` and `runningStats` only. - Two fewer queries run per tasks-page load. - There is no longer an unconsumed promise that can reject without a handler. `runningStats` is awaited by its caller, so its failures continue to be handled the way they always were. Nothing changes on screen: the tasks page renders `hourlyActivity` and `runningStates`, and neither of the removed values fed either of those. ## How The removed values were verified unreferenced before deleting anything: - `TaskListPresenter` has exactly one caller, `UnifiedTaskListPresenter`, which reads `taskResult.tasks` and `taskResult.runningStats` and nothing else. - No file anywhere in the repo — app code, tests, or type re-exports — reads an `activity` or `durations` field off the presenter's result. - `UnifiedTaskListPresenter` builds its own `unifiedTaskListHourlyActivity` query for the 24h chart the page actually renders, which is what made the presenter's separate 7-day daily activity data redundant. - `getDailyTaskActivity` and `getAverageDurations` on `ClickHouseEnvironmentMetricsRepository` had no callers other than the two lines being deleted, so they and their now-orphaned helpers and types were removed too. Changes: - `apps/webapp/app/presenters/v3/TaskListPresenter.server.ts` — drop the `activity` and `durations` fields (both from the main return and from the no-current-worker early return) and the two repository calls behind them. Drop the unreferenced `TaskActivity` type alias. The "don't await this" comment on the remaining `runningStats` promise now spells out that the caller has to consume it. - `apps/webapp/app/services/environmentMetricsRepository.server.ts` — remove `getDailyTaskActivity` and `getAverageDurations` from the `EnvironmentMetricsRepository` interface and its ClickHouse implementation, along with `fillInDailyTaskActivity` and the `DailyTaskActivity` / `AverageDurations` types. `getCurrentRunningStats` is the control that shows the diagnosis is right. It throws on query failure in exactly the same way as the two removed methods — `if (queryError) throw queryError` — but it never produced an unhandled rejection, because `UnifiedTaskListPresenter` passes its promise into a `Promise.all(...).then(...)` chain that the route then awaits. Same failure mode, opposite outcome, and the only difference is whether anything consumes the promise. Follow-ups, not in this PR: - `AgentListPresenter` returns three sparkline promises in the same shape and they look similarly unconsumed. Left alone here to keep this change reviewable. - With these two callers gone, the `getTaskActivity` and `getAverageDurations` query builders in `@internal/clickhouse` have no remaining callers in this repo. Whether to remove them is a separate call for someone who owns that package. ## ✅ 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 --- ## Testing - `pnpm run typecheck --filter webapp` — passes. This is the meaningful check here: it proves nothing still references the removed fields, methods or types. - `pnpm run format` and `pnpm run lint:fix` — clean, no changes produced. - No test file referenced the removed symbols, so no test needed updating. --- ## Changelog Server-only change, so this carries a `.server-changes/` note rather than a changeset: `.server-changes/task-list-remove-unused-metrics-queries.md`. > The tasks page no longer runs two queries whose results were never displayed, cutting wasted work on every page load and removing a source of hidden server errors --- ## Screenshots _No visual change — the removed data was never rendered._ Co-authored-by: Claude --- ...task-list-remove-unused-metrics-queries.md | 6 + .../presenters/v3/TaskListPresenter.server.ts | 30 +--- .../environmentMetricsRepository.server.ts | 133 ------------------ 3 files changed, 11 insertions(+), 158 deletions(-) create mode 100644 .server-changes/task-list-remove-unused-metrics-queries.md diff --git a/.server-changes/task-list-remove-unused-metrics-queries.md b/.server-changes/task-list-remove-unused-metrics-queries.md new file mode 100644 index 0000000000..23164a205c --- /dev/null +++ b/.server-changes/task-list-remove-unused-metrics-queries.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The tasks page no longer runs two queries whose results were never displayed, cutting wasted work on every page load and removing a source of hidden server errors diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index f62cfef5c7..1541329884 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -6,10 +6,8 @@ import { import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { - type AverageDurations, ClickHouseEnvironmentMetricsRepository, type CurrentRunningStats, - type DailyTaskActivity, } from "~/services/environmentMetricsRepository.server"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; @@ -21,8 +19,6 @@ export type TaskListItem = { triggerSource: TaskTriggerSource; }; -export type TaskActivity = DailyTaskActivity[string]; - export class TaskListPresenter { constructor(private readonly _replica: PrismaClientOrTransaction) {} @@ -55,9 +51,7 @@ export class TaskListPresenter { if (!currentWorker) { return { tasks: [], - activity: Promise.resolve({} as DailyTaskActivity), runningStats: Promise.resolve({} as CurrentRunningStats), - durations: Promise.resolve({} as AverageDurations), }; } @@ -89,16 +83,10 @@ export class TaskListPresenter { clickhouse, }); - // IMPORTANT: Don't await these, we want to return the promises - // so we can defer the loading of the data - const activity = environmentMetricsRepository.getDailyTaskActivity({ - organizationId, - projectId, - environmentId, - days: 6, // This actually means 7 days, because we want to show the current day too - tasks: slugs, - }); - + // IMPORTANT: Don't await this, we want to return the promise + // so we can defer the loading of the data. The caller is responsible for + // consuming it — an unconsumed promise here would become an unhandled + // rejection if the underlying query fails. const runningStats = environmentMetricsRepository.getCurrentRunningStats({ organizationId, projectId, @@ -107,15 +95,7 @@ export class TaskListPresenter { tasks: slugs, }); - const durations = environmentMetricsRepository.getAverageDurations({ - organizationId, - projectId, - environmentId, - days: 6, - tasks: slugs, - }); - - return { tasks, activity, runningStats, durations }; + return { tasks, runningStats }; } } diff --git a/apps/webapp/app/services/environmentMetricsRepository.server.ts b/apps/webapp/app/services/environmentMetricsRepository.server.ts index 61b53a4d6c..5ecdf13f20 100644 --- a/apps/webapp/app/services/environmentMetricsRepository.server.ts +++ b/apps/webapp/app/services/environmentMetricsRepository.server.ts @@ -2,19 +2,9 @@ import { type ClickHouse } from "@internal/clickhouse"; import type { TaskRunStatus } from "@trigger.dev/database"; import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; -export type DailyTaskActivity = Record)[]>; export type CurrentRunningStats = Record; -export type AverageDurations = Record; export interface EnvironmentMetricsRepository { - getDailyTaskActivity(options: { - organizationId: string; - projectId: string; - environmentId: string; - days: number; - tasks: string[]; - }): Promise; - getCurrentRunningStats(options: { organizationId: string; projectId: string; @@ -22,14 +12,6 @@ export interface EnvironmentMetricsRepository { days: number; tasks: string[]; }): Promise; - - getAverageDurations(options: { - organizationId: string; - projectId: string; - environmentId: string; - days: number; - tasks: string[]; - }): Promise; } export type ClickHouseEnvironmentMetricsRepositoryOptions = { @@ -39,45 +21,6 @@ export type ClickHouseEnvironmentMetricsRepositoryOptions = { export class ClickHouseEnvironmentMetricsRepository implements EnvironmentMetricsRepository { constructor(private readonly options: ClickHouseEnvironmentMetricsRepositoryOptions) {} - public async getDailyTaskActivity({ - organizationId, - projectId, - environmentId, - days, - tasks, - }: { - organizationId: string; - projectId: string; - environmentId: string; - days: number; - tasks: string[]; - }): Promise { - if (tasks.length === 0) { - return {}; - } - - const [queryError, activity] = await this.options.clickhouse.taskRuns.getTaskActivity({ - organizationId, - projectId, - environmentId, - days, - }); - - if (queryError) { - throw queryError; - } - - return fillInDailyTaskActivity( - activity.map((a) => ({ - taskIdentifier: a.task_identifier, - status: a.status as TaskRunStatus, - day: new Date(a.day), - count: BigInt(a.count), - })), - days - ); - } - public async getCurrentRunningStats({ organizationId, projectId, @@ -115,82 +58,6 @@ export class ClickHouseEnvironmentMetricsRepository implements EnvironmentMetric tasks ); } - - public async getAverageDurations({ - organizationId, - projectId, - environmentId, - days, - tasks, - }: { - organizationId: string; - projectId: string; - environmentId: string; - days: number; - tasks: string[]; - }): Promise { - if (tasks.length === 0) { - return {}; - } - - const [queryError, durations] = await this.options.clickhouse.taskRuns.getAverageDurations({ - organizationId, - projectId, - environmentId, - days, - }); - - if (queryError) { - throw queryError; - } - - return Object.fromEntries(durations.map((d) => [d.task_identifier, Number(d.duration)])); - } -} - -type TaskActivityResults = Array<{ - taskIdentifier: string; - status: TaskRunStatus; - day: Date; - count: bigint; -}>; - -function fillInDailyTaskActivity(activity: TaskActivityResults, days: number): DailyTaskActivity { - //today with no time - const today = new Date(); - today.setUTCHours(0, 0, 0, 0); - - return activity.reduce((acc, a) => { - let existingTask = acc[a.taskIdentifier]; - - if (!existingTask) { - existingTask = []; - //populate the array with the past 7 days - for (let i = days; i >= 0; i--) { - const day = new Date(today); - day.setUTCDate(today.getDate() - i); - day.setUTCHours(0, 0, 0, 0); - - existingTask.push({ - day: day.toISOString(), - ["COMPLETED_SUCCESSFULLY"]: 0, - } as { day: string } & Record); - } - - acc[a.taskIdentifier] = existingTask; - } - - const dayString = a.day.toISOString(); - const day = existingTask.find((d) => d.day === dayString); - - if (!day) { - return acc; - } - - day[a.status] = Number(a.count); - - return acc; - }, {} as DailyTaskActivity); } type CurrentRunningStatsResults = Array<{ From 72c2b2c650db389f21a41602447cfae3a3400961 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:09:49 +0000 Subject: [PATCH 12/23] chore(deps): bump express-rate-limit and ip-address (#4391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Before:** `ip-address` resolved twice in `pnpm-lock.yaml` — `8.1.0` under `@jsonhero/json-infer-types`, and `10.0.1` under `express-rate-limit`. **After:** a single `ip-address@10.2.0` entry, shared by both chains. **How:** `express-rate-limit@8.2.1` pinned `ip-address` to an exact version, so the parent itself had to move — `8.5.1` onwards declares a range instead, and `@modelcontextprotocol/sdk` already allows `^8.2.1`, so scoping that parent to `^8.6.0` lets `ip-address` resolve on its own. `@jsonhero/json-infer-types` caps `ip-address` at `^8.1.0` and is already at its latest published release, so that chain gets a scoped override instead of a parent bump. `jsbn` and `sprintf-js` drop out of the tree as a side effect. Both overrides are parent-scoped, so the `cli-v3` chain is deliberately untouched: it resolves `@modelcontextprotocol/sdk` 1.25.2, which declares `express-rate-limit ^7.5.0` and pulls in no `ip-address` at all. `pnpm-lock.yaml` regenerated. `package.json` and `pnpm-lock.yaml` are the only two files changed. Nothing in the repo imports `ip-address` or `express-rate-limit` directly. Both chains are transitive under `apps/webapp` — `@jsonhero/schema-infer` (used by `TestTaskPresenter.server.ts`) and `@vercel/sdk` — so no published `@trigger.dev/*` package is affected. --- ## Testing - `pnpm install --lockfile-only` regenerates cleanly, and `pnpm install --frozen-lockfile --lockfile-only` passes, so the lockfile matches the manifests. - Package churn is limited to the intended set: `express-rate-limit` 8.2.1 to 8.6.0, `ip-address` 8.1.0 and 10.0.1 collapsing to 10.2.0, and `jsbn` / `sprintf-js` removed. No other resolution moved. - `@jsonhero/json-infer-types` only calls `new Address4()` / `new Address6()` inside a try/catch to classify strings. Ran that exact logic against both `8.1.0` and `10.2.0` over 27 inputs (v4, v6, zone IDs, CIDR, IPv4-mapped, malformed, empty, non-strings): identical results in all 27. Both are still CJS named exports in `10.2.0`, with the same `engines` floor. - Drove the real `inferSchema()` path from `@jsonhero/schema-infer` with `ip-address` forced to `10.2.0`; it still detects `ipv4` and `ipv6` formats correctly. - `express-rate-limit` 8.6.0 keeps the same `express` peer range (`>= 4.11`) and the same node floor as 8.2.1. Its new `debug` dependency resolves to a version already present in the tree. - `oxfmt --check` passes on the modified `package.json`. - Both bumped versions clear the repo's `minimumReleaseAge` window; the newest `express-rate-limit` (8.6.1) and `ip-address` (10.2.1+) releases do not yet, which is why this lands on 8.6.0 and 10.2.0. - Not run here: a full monorepo install, typecheck and test suite. No TypeScript changed, and neither package leaks types into ours — `ip-address` is not referenced in `json-infer-types`' or `schema-infer`'s declaration files — so CI should be the judge of the wider suite. --- ## Changelog Routine dependency maintenance, no behaviour change. No changeset or `.server-changes/` entry: the diff touches only the root `package.json` and `pnpm-lock.yaml`, not `packages/*`, `integrations/*`, `apps/webapp/` or `apps/supervisor/`. Co-authored-by: Claude Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- package.json | 4 +++- pnpm-lock.yaml | 42 ++++++++++++++---------------------------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 3b00102ac8..f598705620 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,9 @@ "tmp@<0.2.7": "0.2.7", "brace-expansion@<1.1.13": "1.1.13", "brace-expansion@>=2 <2.0.3": "2.0.3", - "brace-expansion@>=5 <5.0.6": "5.0.6" + "brace-expansion@>=5 <5.0.6": "5.0.6", + "@jsonhero/json-infer-types>ip-address": "^10.2.0", + "@modelcontextprotocol/sdk@>=1.26.0>express-rate-limit": "^8.6.0" }, "onlyBuiltDependencies": [ "@depot/cli", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09dc718a6..e49d5239ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,8 @@ overrides: brace-expansion@<1.1.13: 1.1.13 brace-expansion@>=2 <2.0.3: 2.0.3 brace-expansion@>=5 <5.0.6: 5.0.6 + '@jsonhero/json-infer-types>ip-address': ^10.2.0 + '@modelcontextprotocol/sdk@>=1.26.0>express-rate-limit': ^8.6.0 patchedDependencies: '@changesets/assemble-release-plan@5.2.4': @@ -10300,8 +10302,8 @@ packages: peerDependencies: express: ^4.11 || 5 || ^5.0.0-beta.1 - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -11018,12 +11020,8 @@ packages: resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} engines: {node: '>=12.22.0'} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} - engines: {node: '>= 12'} - - ip-address@8.1.0: - resolution: {integrity: sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -11345,9 +11343,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - jsep@1.4.0: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} @@ -14198,9 +14193,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sprintf-js@1.1.2: - resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==} - sql-formatter@15.6.12: resolution: {integrity: sha512-mkpF+RG402P66VMsnQkWewTRzDBWfu9iLbOfxaW/nAKOS/2A9MheQmcU5cmX0D0At9azrorZwpvcBRNNBozACQ==} hasBin: true @@ -18717,7 +18709,7 @@ snapshots: '@jsonhero/json-infer-types@1.2.11': dependencies: - ip-address: 8.1.0 + ip-address: 10.2.0 json5: 2.2.3 jwt-decode: 3.1.2 uuid: 8.3.2 @@ -18901,7 +18893,7 @@ snapshots: eventsource: 3.0.5 eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) + express-rate-limit: 8.6.0(express@5.2.1) hono: 4.12.25 jose: 6.1.3 json-schema-typed: 8.0.2 @@ -25374,10 +25366,13 @@ snapshots: dependencies: express: 5.0.1(supports-color@10.0.0) - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.6.0(express@5.2.1): dependencies: + debug: 4.4.3(supports-color@10.0.0) express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color express@4.20.0: dependencies: @@ -26323,12 +26318,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.0.1: {} - - ip-address@8.1.0: - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.2 + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -26590,8 +26580,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbn@1.1.0: {} - jsep@1.4.0: {} jsesc@3.0.2: {} @@ -30111,8 +30099,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.2: {} - sql-formatter@15.6.12: dependencies: argparse: 2.0.1 From 269470fd87ffd7ad636a91c0f8933e3738625ba9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Jul 2026 13:08:16 +0100 Subject: [PATCH 13/23] feat(webapp): gate SSO on an entitlement instead of the Enterprise plan (#4393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSO & Directory Sync settings page decided access by comparing the organization's plan code against the literal string `"enterprise"`. The webapp now reads a `hasSso` entitlement from plan limits. ## Changes - **`settings.sso` route** — `planAllowsSso` reads `limits.hasSso` rather than the plan code; the loader and the action gate on a shared `getSsoEntitlement` helper. - **`platform.v3.server`** — new `getSsoEntitlement(orgId)` returning `entitled | not_entitled | unknown`, behind a new SWR cache namespace (60s fresh / 120s stale, memory + Redis). This replaces an uncached billing round-trip that previously ran on every settings load, so the page gets cheaper than it was. - **`directorySyncEffects`** — the entitlement is now checked before applying membership effects, per organization and memoised across a batch. - **`@trigger.dev/platform` 1.2.0 → 1.3.0** — required, see below. ## Behaviour worth reviewing **Revocation now stops SCIM.** Previously the plan check existed only on the settings page, so an org that lost access kept receiving directory-sync pushes indefinitely; only the config UI froze. Provision *and* deprovision are gated, so a revoked entitlement can't remove members either. **An unreadable entitlement throws instead of skipping.** Effects are idempotent and the worker retries, so retrying is lossless where dropping would silently lose a directory change. It's raised at `warn` level so a transient billing blip doesn't page anyone. **The login path is deliberately untouched.** A hard entitlement check there turns a billing outage into a login outage. Consequence: an org that loses the entitlement keeps its existing SSO logins working until the connection is removed. Gating sign-in is a separate decision. **Self-hosted is unaffected.** With no billing service configured the helper returns `entitled`, leaving plugin presence and the kill switch as the only gates — a self-hoster who installed the plugin isn't locked out of it. ## The dependency bump is load-bearing The `Limits` schema is a plain `z.object`, so it *strips* unknown keys. On 1.2.0 the `hasSso` field was silently discarded during parsing and read as `undefined` no matter what billing sent — a structural accessor would not have helped. Verified against both builds: ``` 1.2.0 → parsed: true | hasSso survives: false 1.3.0 → parsed: true | hasSso survives: true ``` This PR therefore cannot merge before 1.3.0 is published, which it now is. ## Testing `apps/webapp/test/directorySyncEffects.server.test.ts` — 7 tests over the gate: applies when entitled, skips provision and deprovision when not, throws a warn-level retryable error when unreadable, resolves once per org across a batch, and gates per org so one unentitled org doesn't block another. `pnpm run typecheck --filter webapp` passes (18/18), oxfmt and oxlint clean. --- .server-changes/sso-entitlement.md | 6 + .../route.tsx | 26 ++-- .../services/directorySyncEffects.server.ts | 30 ++++ .../webapp/app/services/platform.v3.server.ts | 68 ++++++++- apps/webapp/package.json | 2 +- .../test/directorySyncEffects.server.test.ts | 130 ++++++++++++++++++ pnpm-lock.yaml | 10 +- 7 files changed, 245 insertions(+), 27 deletions(-) create mode 100644 .server-changes/sso-entitlement.md create mode 100644 apps/webapp/test/directorySyncEffects.server.test.ts diff --git a/.server-changes/sso-entitlement.md b/.server-changes/sso-entitlement.md new file mode 100644 index 0000000000..4a039b6855 --- /dev/null +++ b/.server-changes/sso-entitlement.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +SSO and Directory Sync are no longer restricted to Enterprise plans — get in touch and we can turn them on for your organization whatever plan you're on. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index e58afdb65d..46abc2d4f2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -39,7 +39,7 @@ import { prisma } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; -import { getCurrentPlan } from "~/services/platform.v3.server"; +import { getSsoEntitlement } from "~/services/platform.v3.server"; import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins"; import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; import { flag } from "~/v3/featureFlags.server"; @@ -47,7 +47,6 @@ import { FEATURE_FLAG } from "~/v3/featureFlags"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { throwPermissionDenied } from "~/utils/permissionDenied"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }]; @@ -62,17 +61,10 @@ async function resolveOrg(slug: string) { }); } -function planAllowsSso(plan: unknown): boolean { - if (!plan || typeof plan !== "object") return false; - const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }).v3Subscription; - return subscription?.plan?.code === "enterprise"; -} - // Client-side upsell is cosmetic; gate real IdP mutations server-side. async function requireSsoEntitlement(orgId: string): Promise { - const plan = await getCurrentPlan(orgId); - if (!planAllowsSso(plan)) { - throw new Response("SSO requires an Enterprise plan", { status: 403 }); + if ((await getSsoEntitlement(orgId)) !== "entitled") { + throw new Response("This organization is not entitled to SSO", { status: 403 }); } } @@ -142,16 +134,14 @@ export const loader = dashboardLoader( throw new Response("Not Found", { status: 404 }); } - // Not Enterprise: render the upsell for every role, skip role check + - // queries, return empty data. - const plan = await getCurrentPlan(orgId); - if (!planAllowsSso(plan)) { + if ((await getSsoEntitlement(orgId)) !== "entitled") { return typedjson({ status: EMPTY_SSO_STATUS, orgTitle: context.orgTitle, jitRoles: [] as Role[], directorySync: EMPTY_DIRECTORY_SYNC_STATUS, hasSso: false, + isEntitled: false, }); } @@ -182,6 +172,7 @@ export const loader = dashboardLoader( jitRoles, directorySync, hasSso, + isEntitled: true, }); } ); @@ -374,11 +365,10 @@ function useOverrideDraft(serverValue: T): { } export default function Page() { - const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData(); + const { status, orgTitle, jitRoles, directorySync, hasSso, isEntitled } = + useTypedLoaderData(); const organization = useOrganization(); - const _plan = useCurrentPlan(); - const isEntitled = planAllowsSso(_plan); const activeConnections = status.connections.filter((c) => c.state === "active"); const hasActive = activeConnections.length > 0; diff --git a/apps/webapp/app/services/directorySyncEffects.server.ts b/apps/webapp/app/services/directorySyncEffects.server.ts index 24e1dade5a..555bb6f98a 100644 --- a/apps/webapp/app/services/directorySyncEffects.server.ts +++ b/apps/webapp/app/services/directorySyncEffects.server.ts @@ -8,6 +8,7 @@ import { removeOrgMemberForDirectory, } from "~/models/orgMember.server"; import { createPlatformNotification } from "~/services/platformNotifications.server"; +import { getSsoEntitlement, type SsoEntitlement } from "~/services/platform.v3.server"; const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected"; @@ -145,8 +146,37 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { } } +/** + * Applies membership effects, skipping any org that isn't entitled to SSO. + * + * An unreadable entitlement throws rather than skipping: effects are + * idempotent and the worker retries, so retrying is lossless where dropping + * would silently lose a directory change. + */ export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise { + const entitlements = new Map(); + for (const effect of effects) { + let entitlement = entitlements.get(effect.organizationId); + if (entitlement === undefined) { + entitlement = await getSsoEntitlement(effect.organizationId); + entitlements.set(effect.organizationId, entitlement); + } + + if (entitlement === "unknown") { + throw retryableEffectError( + `directory sync: could not read the SSO entitlement for organization ${effect.organizationId}` + ); + } + + if (entitlement === "not_entitled") { + logger.warn("Directory Sync: skipping effect for org without the SSO entitlement", { + organizationId: effect.organizationId, + kind: effect.kind, + }); + continue; + } + await applyEffect(effect); } } diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bded0b9206..f8b524e3cd 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -196,6 +196,11 @@ function initializePlatformCache() { fresh: 60_000, stale: 120_000, }), + ssoEntitlement: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, + stale: 120_000, + }), }); return cache; @@ -206,12 +211,23 @@ const platformCache = singleton("platformCache", initializePlatformCache); function invalidateBillingLimitCaches(organizationId: string) { platformCache.billingLimit.remove(organizationId).catch(() => {}); platformCache.entitlement.remove(organizationId).catch(() => {}); + platformCache.ssoEntitlement.remove(organizationId).catch(() => {}); } export function bustBillingLimitCaches(organizationId: string) { invalidateBillingLimitCaches(organizationId); } +/** + * Clears the caches whose value is derived from the org's plan. Call after a + * plan change — a downgrade can revoke SSO, and serving the previous decision + * for the stale TTL would keep a surface open that the new plan doesn't allow. + */ +function invalidatePlanDerivedCaches(organizationId: string) { + platformCache.entitlement.remove(organizationId).catch(() => {}); + platformCache.ssoEntitlement.remove(organizationId).catch(() => {}); +} + // Clear the cached promo-credits read so a just-granted code shows on the usage // page immediately rather than after the stale TTL. export function bustPromoCreditsCache(organizationId: string) { @@ -537,7 +553,7 @@ export async function setPlan( case "free_connected": { // Selecting Free provisions the plan directly, so any free result is a success. opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); const response = redirect(newProjectPath(organization, "You're on the Free plan.")); await opts?.onFreePlanProvisioned?.(response); return response; @@ -548,13 +564,13 @@ export async function setPlan( case "updated_subscription": { // Invalidate billing cache since subscription changed opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); 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(() => {}); + invalidatePlanDerivedCaches(organization.id); return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); } } @@ -757,6 +773,52 @@ export async function getEntitlement( return result.val; } +export type SsoEntitlement = "entitled" | "not_entitled" | "unknown"; + +/** + * Whether an org may configure and use SSO / Directory Sync. + * + * `unknown` means billing was configured but unreadable — callers decide: + * read paths show the upsell, mutations refuse, and the directory-sync + * worker throws so the effect is retried rather than silently dropped. + * + * Self-hosted deployments have no billing service, so the plugin's presence + * (plus the kill switch) is the only gate and this returns `entitled`. + * + * Loader errors are swallowed inside the loader for the same reason as + * `getEntitlement`: @unkey/cache passes the loader promise to waitUntil() + * with no .catch(), and returning undefined stops a transient billing + * failure from being cached as an access decision. The SWR read is guarded + * too, so a cache-infra failure resolves to `unknown` rather than rejecting + * into the settings loader and the directory-sync worker. + */ +export async function getSsoEntitlement(organizationId: string): Promise { + if (!client) return "entitled"; + + try { + const result = await platformCache.ssoEntitlement.swr(organizationId, async () => { + try { + const response = await client.currentPlan(organizationId); + if (!response.success) { + recordPlatformFailure("getSsoEntitlement", "no_success"); + return undefined; + } + return response.v3Subscription?.plan?.limits?.hasSso === true; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); + return undefined; + } + }); + + if (result.err || result.val === undefined) return "unknown"; + + return result.val ? "entitled" : "not_entitled"; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); + return "unknown"; + } +} + export type PromoCreditsData = { grantedCents: number; remainingCents: number; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 678eeb7603..0eb149f77e 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -117,7 +117,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.2.0", + "@trigger.dev/platform": "1.3.0", "@trigger.dev/plugins": "workspace:*", "@trigger.dev/rbac": "workspace:*", "@trigger.dev/redis-worker": "workspace:*", diff --git a/apps/webapp/test/directorySyncEffects.server.test.ts b/apps/webapp/test/directorySyncEffects.server.test.ts new file mode 100644 index 0000000000..e8f9bb933c --- /dev/null +++ b/apps/webapp/test/directorySyncEffects.server.test.ts @@ -0,0 +1,130 @@ +import type { DirectorySyncEffect } from "@trigger.dev/plugins"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); +vi.mock("~/services/platformNotifications.server", () => ({ + createPlatformNotification: vi.fn(), +})); + +const getSsoEntitlement = vi.fn(); +vi.mock("~/services/platform.v3.server", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, getSsoEntitlement: (orgId: string) => getSsoEntitlement(orgId) }; +}); + +const setUserRole = vi.fn(); +vi.mock("~/services/rbac.server", () => ({ + rbac: { setUserRole: (a: unknown) => setUserRole(a) }, +})); + +const ensureOrgMember = vi.fn(); +const ensureUserForDirectory = vi.fn(); +const removeOrgMemberForDirectory = vi.fn(); +vi.mock("~/models/orgMember.server", () => ({ + ensureOrgMember: (a: unknown) => ensureOrgMember(a), + ensureUserForDirectory: (a: unknown) => ensureUserForDirectory(a), + removeOrgMemberForDirectory: (a: unknown) => removeOrgMemberForDirectory(a), +})); + +import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; + +const ENTITLED_ORG = "org_entitled"; +const UNENTITLED_ORG = "org_unentitled"; + +function provision(organizationId: string, email = "someone@acme.com"): DirectorySyncEffect { + return { + kind: "provision", + userId: "user_1", + email, + firstName: null, + lastName: null, + organizationId, + roleId: null, + }; +} + +function deprovision(organizationId: string): DirectorySyncEffect { + return { kind: "deprovision", userId: "user_1", organizationId }; +} + +describe("applyDirectorySyncEffects — SSO entitlement gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + ensureOrgMember.mockResolvedValue(undefined); + removeOrgMemberForDirectory.mockResolvedValue({ removed: true }); + setUserRole.mockResolvedValue({ ok: true }); + }); + + it("applies effects for an entitled org", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + await applyDirectorySyncEffects([provision(ENTITLED_ORG)]); + + expect(ensureOrgMember).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ENTITLED_ORG, source: "directory_sync" }) + ); + }); + + it("skips provisioning for an org without the entitlement", async () => { + getSsoEntitlement.mockResolvedValue("not_entitled"); + + await applyDirectorySyncEffects([provision(UNENTITLED_ORG)]); + + expect(ensureOrgMember).not.toHaveBeenCalled(); + expect(ensureUserForDirectory).not.toHaveBeenCalled(); + }); + + it("skips deprovisioning too, so revocation cannot remove members", async () => { + getSsoEntitlement.mockResolvedValue("not_entitled"); + + await applyDirectorySyncEffects([deprovision(UNENTITLED_ORG)]); + + expect(removeOrgMemberForDirectory).not.toHaveBeenCalled(); + }); + + it("throws on an unreadable entitlement so the worker retries", async () => { + getSsoEntitlement.mockResolvedValue("unknown"); + + await expect(applyDirectorySyncEffects([provision(ENTITLED_ORG)])).rejects.toThrow( + /could not read the SSO entitlement/ + ); + + expect(ensureOrgMember).not.toHaveBeenCalled(); + }); + + it("marks the retry as a warning rather than a pageable error", async () => { + getSsoEntitlement.mockResolvedValue("unknown"); + + await applyDirectorySyncEffects([provision(ENTITLED_ORG)]).then( + () => expect.unreachable("should have thrown"), + (error) => expect(error).toMatchObject({ logLevel: "warn" }) + ); + }); + + it("resolves the entitlement once per org across a batch", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + await applyDirectorySyncEffects([ + provision(ENTITLED_ORG, "a@acme.com"), + provision(ENTITLED_ORG, "b@acme.com"), + provision(ENTITLED_ORG, "c@acme.com"), + ]); + + expect(getSsoEntitlement).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledTimes(3); + }); + + it("gates per org, so one unentitled org does not block another", async () => { + getSsoEntitlement.mockImplementation(async (orgId: string) => + orgId === ENTITLED_ORG ? "entitled" : "not_entitled" + ); + + await applyDirectorySyncEffects([provision(UNENTITLED_ORG), provision(ENTITLED_ORG)]); + + expect(ensureOrgMember).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ENTITLED_ORG }) + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e49d5239ae..204cae651c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,8 +480,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.2.0 - version: 1.2.0(zod@3.25.76) + specifier: 1.3.0 + version: 1.3.0(zod@3.25.76) '@trigger.dev/plugins': specifier: workspace:* version: link:../../packages/plugins @@ -7683,8 +7683,8 @@ packages: react: 18.3.1 react-dom: 18.3.1 - '@trigger.dev/platform@1.2.0': - resolution: {integrity: sha512-Wa/XlMlmo1vhol5DEBYW1gMW4wgUUqr/ClDQb4IgwrRdPeLLmTpIVsgMHmYJXkERqR04xVaX7wBibWl4sW+1Hg==} + '@trigger.dev/platform@1.3.0': + resolution: {integrity: sha512-DIJ5Sy4X5XKSHQdYA3GImkb0jdE9I/HTRRRB3c9CFuXjGzhK3rJkEzjIEbaNRjk6yAev67EVTWY8P+D+khorUw==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -22326,7 +22326,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@trigger.dev/platform@1.2.0(zod@3.25.76)': + '@trigger.dev/platform@1.3.0(zod@3.25.76)': dependencies: zod: 3.25.76 From e8a2dbd6053d195d738d270ac830d81c8e20f458 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:44:14 +0100 Subject: [PATCH 14/23] chore: ignore local docs/superpowers planning docs (#4395) Adds a gitignore rule for `**/docs/superpowers/` so locally-generated planning and design scratch docs under that path aren't committed; preventive only, no-op for existing tree. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1ee5643d57..7d9dc16904 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ ailogger-output.log # local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them .worktrees/ + +# local planning/design docs, not committed +**/docs/superpowers/ From 3e53404f408083a91ba923c9fb831bd965942461 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 15:41:47 +0100 Subject: [PATCH 15/23] feat(webapp): live-update the runs list on task pages (#4377) ## Summary The runs list on a task's page now updates live, matching the main Runs page. Run rows update their status, duration, and cost in place as runs progress, and a "N new runs" button appears in the header when newer runs come in so you can pull them into the list without a manual refresh. This applies to both standard and scheduled task pages. ## Design It reuses the Runs page's polling hook. A task page scopes its runs by the task in the URL path rather than a `tasks` query filter, so the hook now takes an optional task slug and scopes new-run detection to it. The "new runs" button sits in the header, outside the deferred runs table, so the count is lifted to the page and the click action is passed through a ref. That keeps the table streaming on first load instead of blocking the header on the runs query. When newer runs come in, a `1 new run` button appears in the task page header, to the left of the time filter. Clicking it pulls the new runs into the list. --- .../task-page-runs-live-updating.md | 6 + .../app/components/runs/v3/TaskRunsList.tsx | 117 ++++++++++++++++++ .../useRunsLiveReload.ts | 20 ++- .../route.tsx | 30 +++-- .../route.tsx | 32 +++-- 5 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 .server-changes/task-page-runs-live-updating.md create mode 100644 apps/webapp/app/components/runs/v3/TaskRunsList.tsx diff --git a/.server-changes/task-page-runs-live-updating.md b/.server-changes/task-page-runs-live-updating.md new file mode 100644 index 0000000000..a082fdfb3d --- /dev/null +++ b/.server-changes/task-page-runs-live-updating.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The runs list on a task's page now updates live — run statuses change and newly triggered runs appear without a manual refresh, matching the main Runs page. diff --git a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx new file mode 100644 index 0000000000..1b9830ec54 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx @@ -0,0 +1,117 @@ +import { useLocation, useNavigation, useRevalidator } from "@remix-run/react"; +import { type MutableRefObject, useEffect } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { PulsingDot } from "~/components/primitives/PulsingDot"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import type { NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; +import { useRunsLiveReload } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; +import { TaskRunsTable } from "./TaskRunsTable"; + +/** + * Compact "N new runs" button, shown in a task page's header to the left of the + * time filter when the live-reload hook has detected newer runs. + */ +export function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { + return ( + + + + ); +} + +/** + * Runs table with live updating, shared by the standard and scheduled task + * landing pages. Mirrors the Runs list page: active rows are patched in place + * (status/timing/cost). The "N new runs" count is surfaced to the top-bar + * button via `onNewRunsCountChange` (count drives visibility) and + * `showNewRunsRef` (the latest click action), since the button lives outside + * this deferred boundary. The task lives in the route path rather than a + * `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task. + */ +export function TaskRunsList({ + list, + taskSlug, + onNewRunsCountChange, + showNewRunsRef, +}: { + list: NextRunList; + taskSlug: string; + onNewRunsCountChange: (count: number) => void; + showNewRunsRef: MutableRefObject<() => void>; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const navigation = useNavigation(); + const location = useLocation(); + const { has, replace } = useSearchParams(); + const revalidator = useRevalidator(); + + // Loading a new version of this same page (time filter / pagination change). + const isLoading = + navigation.state === "loading" && + navigation.location !== undefined && + navigation.location.pathname === location.pathname && + navigation.location.search !== location.search; + + const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload( + { + runs: list.runs, + hasAnyRuns: list.hasAnyRuns, + isLoading, + organizationSlug: organization.slug, + projectSlug: project.slug, + environmentSlug: environment.slug, + taskSlug, + } + ); + + const onClickShowNewRuns = () => { + const isPaginated = has("cursor") || has("direction"); + dismissNewRuns(); + if (isPaginated) { + replace({ cursor: undefined, direction: undefined }); + return; + } + revalidator.revalidate(); + }; + + // Surface the banner to the top-bar button rendered by the page: keep the + // ref's action current, mirror the count up, and clear it when this boundary + // unmounts (e.g. the table re-suspends on a filter change). + useEffect(() => { + showNewRunsRef.current = onClickShowNewRuns; + }, [onClickShowNewRuns, showNewRunsRef]); + useEffect(() => { + onNewRunsCountChange(newRunsCount); + }, [newRunsCount, onNewRunsCountChange]); + useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]); + + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts index a8d26ac350..e443fb84cf 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts @@ -48,12 +48,22 @@ function isNewRunsCheckTick(tick: number) { function appendNewRunsSearchParams( searchParams: URLSearchParams, - { locationSearch, since }: { locationSearch: string; since: number } + { locationSearch, since, taskSlug }: { locationSearch: string; since: number; taskSlug?: string } ) { const filterParams = filterParamsWithoutPagination(locationSearch); for (const [key, value] of filterParams) { searchParams.append(key, value); } + // On the task landing pages the task lives in the route path, not the query + // string, so scope the new-runs count to this task explicitly. The task pages + // list every run of the task (their loaders apply no rootOnly filter), so + // force rootOnly off for the count too: a rootOnly preference persisted from + // the main Runs page would otherwise make the count skip child runs the list + // is showing, so the "N new runs" button could under-count or never appear. + if (taskSlug) { + searchParams.append("tasks", taskSlug); + searchParams.set("rootOnly", "false"); + } searchParams.set("includeNewRuns", "true"); searchParams.set("since", String(since)); } @@ -138,6 +148,7 @@ export function useRunsLiveReload({ organizationSlug, projectSlug, environmentSlug, + taskSlug, }: { runs: ListedRun[]; hasAnyRuns: boolean; @@ -145,6 +156,11 @@ export function useRunsLiveReload({ organizationSlug: string; projectSlug: string; environmentSlug: string; + /** + * When set, scopes new-run detection to this task. Used by the task landing + * pages, where the task is a route path param rather than a `tasks` filter. + */ + taskSlug?: string; }) { const location = useLocation(); const runsPollFetcher = useTypedFetcher(); @@ -230,6 +246,7 @@ export function useRunsLiveReload({ appendNewRunsSearchParams(searchParams, { locationSearch: location.search, since: knownNewestRunMs, + taskSlug, }); } @@ -242,6 +259,7 @@ export function useRunsLiveReload({ knownNewestRunMs, runsPollFetcher, runsResourcesBasePath, + taskSlug, ] ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 9ef03b5c18..398c613553 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -56,7 +56,6 @@ import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; -import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { ScheduleInspector } from "~/components/schedules/ScheduleInspector"; import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions"; import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar"; @@ -95,6 +94,7 @@ import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlu import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; +import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -181,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + includeHasAnyRuns: true, }) .catch(() => null); @@ -231,6 +232,13 @@ export default function Page() { !!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed; const isAtLimit = !!limits && limits.used >= limits.limit; + // New-runs banner state is lifted here so the button can live in the top bar, + // while the count/action originate from the live-reload hook inside the + // deferred runs table below. Count drives visibility; the ref exposes the + // click action (kept current by TaskRunsList each render). + const [newRunsCount, setNewRunsCount] = useState(0); + const showNewRunsRef = useRef<() => void>(() => {}); + return ( @@ -265,6 +273,9 @@ export default function Page() { onCreate={openCreateSchedule} disabled={isCreatingSchedule} /> + {newRunsCount > 0 ? ( + showNewRunsRef.current()} /> + ) : null} }> {(list) => list ? ( -
- -
+ ) : ( ) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index f38124f259..ac765406ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -1,7 +1,7 @@ import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; -import { Suspense, useMemo } from "react"; +import { Suspense, useMemo, useRef, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; @@ -29,7 +29,6 @@ import { } from "~/components/primitives/Resizable"; import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; -import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -52,6 +51,7 @@ import { v3TestTaskPath, } from "~/utils/pathBuilder"; import { parseFiniteInt } from "~/utils/searchParams"; +import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -121,6 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + includeHasAnyRuns: true, }) .catch(() => null); @@ -144,6 +145,13 @@ export default function Page() { }); const queuesPath = v3QueuesPath(organization, project, environment); + // New-runs banner state is lifted here so the button can live in the top bar, + // while the count/action originate from the live-reload hook inside the + // deferred runs table below. Count drives visibility; the ref exposes the + // click action (kept current by TaskRunsList each render). + const [newRunsCount, setNewRunsCount] = useState(0); + const showNewRunsRef = useRef<() => void>(() => {}); + return ( @@ -166,6 +174,9 @@ export default function Page() {
Runs
+ {newRunsCount > 0 ? ( + showNewRunsRef.current()} /> + ) : null} @@ -200,17 +211,12 @@ export default function Page() { }> {(list) => list ? ( -
- -
+ ) : ( ) From efd0ee8d74a2548e125d22ba801647a1b0f458cd Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:19:27 +0100 Subject: [PATCH 16/23] feat(core,sdk): support additional environment API keys (#4387) ## Summary Additional environment API keys can use SDK APIs that require public access tokens. The SDK detects the additional-key format and asks the Trigger.dev server to mint scoped tokens instead of attempting to sign them locally. Root environment keys retain their existing local-signing behavior. Trigger and batch clients also prefer server-issued tokens returned in response headers while preserving compatibility with older servers. ## Deployment notes This package update is safe to publish before servers expose additional key creation. Existing root keys continue to use the current path, while an additional key used with an older server fails with an actionable upgrade error. --- .changeset/friendly-keys-batch-tokens.md | 6 + packages/core/src/v3/apiClient/index.ts | 67 +++++- packages/core/src/v3/apiKeys.test.ts | 18 ++ packages/core/src/v3/apiKeys.ts | 11 + packages/core/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/ai.ts | 38 +++- packages/trigger-sdk/src/v3/auth.test.ts | 200 ++++++++++++++++++ packages/trigger-sdk/src/v3/auth.ts | 109 ++++++++-- .../src/v3/createStartSessionAction.test.ts | 38 ++++ 9 files changed, 464 insertions(+), 24 deletions(-) create mode 100644 .changeset/friendly-keys-batch-tokens.md create mode 100644 packages/core/src/v3/apiKeys.test.ts create mode 100644 packages/core/src/v3/apiKeys.ts create mode 100644 packages/trigger-sdk/src/v3/auth.test.ts diff --git a/.changeset/friendly-keys-batch-tokens.md b/.changeset/friendly-keys-batch-tokens.md new file mode 100644 index 0000000000..505edacd33 --- /dev/null +++ b/.changeset/friendly-keys-batch-tokens.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index deda5d01f9..5329f15fad 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { VERSION } from "../../version.js"; +import { isAdditionalApiKey } from "../apiKeys.js"; import type { ApiClientConfiguration } from "../apiClientManager-api.js"; import { generateJWT } from "../jwt.js"; import { @@ -201,6 +202,15 @@ export type { export * from "./getBranch.js"; +export type CreatePublicTokenRequestBody = { + scopes: string[]; + expirationTime?: string | number; + oneTimeUse?: boolean; + realtime?: { skipColumns?: string[] }; +}; + +const CreatePublicTokenResponseBody = z.object({ token: z.string() }); + /** * Trigger.dev v3 API client */ @@ -230,6 +240,21 @@ export class ApiClient { this.futureFlags = futureFlags; } + /** + * Key for signing a public access token locally. Only root keys can do this — + * an additional key isn't the environment's signing material, so a token + * signed with one would never verify. Throw rather than return a dead token. + */ + get #selfSigningKey(): string { + if (isAdditionalApiKey(this.accessToken)) { + throw new Error( + "This additional API key cannot self-sign public tokens, and the server did not return one. Upgrade the server or use the root API key." + ); + } + + return this.accessToken; + } + get fetchClient(): typeof fetch { const headers = this.#getHeaders(false); @@ -325,7 +350,7 @@ export class ApiClient { const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`read:runs:${data.id}`], @@ -359,11 +384,20 @@ export class ApiClient { ) .withResponse() .then(async ({ data, response }) => { + const jwtHeader = response.headers.get("x-trigger-jwt"); + + if (typeof jwtHeader === "string") { + return { + ...data, + publicAccessToken: jwtHeader, + }; + } + const claimsHeader = response.headers.get("x-trigger-jwt-claims"); const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`read:batch:${data.id}`], @@ -407,11 +441,20 @@ export class ApiClient { ) .withResponse() .then(async ({ data, response }) => { + const jwtHeader = response.headers.get("x-trigger-jwt"); + + if (typeof jwtHeader === "string") { + return { + ...data, + publicAccessToken: jwtHeader, + }; + } + const claimsHeader = response.headers.get("x-trigger-jwt-claims"); const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`read:batch:${data.id}`], @@ -1107,7 +1150,7 @@ export class ApiClient { const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`write:waitpoints:${data.id}`], @@ -1870,6 +1913,22 @@ export class ApiClient { ); } + async createPublicToken( + body: CreatePublicTokenRequestBody, + requestOptions?: ZodFetchOptions + ): Promise<{ token: string }> { + return zodfetch( + CreatePublicTokenResponseBody, + `${this.baseUrl}/api/v1/auth/public-tokens`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) { return zodfetch( RetrieveBatchV2Response, diff --git a/packages/core/src/v3/apiKeys.test.ts b/packages/core/src/v3/apiKeys.test.ts new file mode 100644 index 0000000000..8d0108fb63 --- /dev/null +++ b/packages/core/src/v3/apiKeys.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { isAdditionalApiKey } from "./apiKeys.js"; + +describe("isAdditionalApiKey", () => { + it.each(["dev", "stg", "prod", "preview"])("recognizes %s additional keys", (environment) => { + expect(isAdditionalApiKey(`tr_${environment}_sk_0123456789abcdefghijklmn`)).toBe(true); + }); + + it.each([ + "tr_prod_0123456789abcdefghijklmn", + "tr_prod_sk_too-short", + "tr_prod_sk_0123456789abcdefghijklmn_extra", + "tr_test_sk_0123456789abcdefghijklmn", + "tr_prod_sk_0123456789abcdefghijkl_", + ])("rejects %s", (key) => { + expect(isAdditionalApiKey(key)).toBe(false); + }); +}); diff --git a/packages/core/src/v3/apiKeys.ts b/packages/core/src/v3/apiKeys.ts new file mode 100644 index 0000000000..2571d66503 --- /dev/null +++ b/packages/core/src/v3/apiKeys.ts @@ -0,0 +1,11 @@ +const ADDITIONAL_API_KEY_PATTERN = /^tr_(dev|stg|prod|preview)_sk_[0-9a-zA-Z]{24}$/; + +/** + * Returns whether a key has the additional environment API key format. + * + * This is only a routing hint. It must never be used as a security boundary; + * servers authenticate additional keys by resolving their stored hash. + */ +export function isAdditionalApiKey(key: string): boolean { + return ADDITIONAL_API_KEY_PATTERN.test(key); +} diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 35dbfa287f..10551fca6f 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -30,6 +30,7 @@ export * from "./resource-catalog-api.js"; export * from "./types/index.js"; export { links } from "./links.js"; export * from "./jwt.js"; +export * from "./apiKeys.js"; export * from "./workloadDeploymentToken.js"; export * from "./idempotencyKeys.js"; export * from "./streams/asyncIterableStream.js"; diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 0931ff1d89..65ec72f430 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -14,6 +14,7 @@ import { type inferSchemaOut, InputStreamOncePromise, type InputStreamOnceResult, + isAdditionalApiKey, isSchemaZodEsque, logger, type MachinePresetName, @@ -10600,24 +10601,53 @@ async function mintPublicTokenWithOverride(args: { throw new Error("chat.createStartSessionAction: no API access token configured for JWT mint."); } const ctx: ChatStartSessionEndpointContext = { endpoint: "auth", chatId: args.chatId }; - const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}/api/v1/auth/jwt/claims`; + const scopes = [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`]; + const serverMint = isAdditionalApiKey(accessToken); + const endpoint = serverMint ? "/api/v1/auth/public-tokens" : "/api/v1/auth/jwt/claims"; + const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}${endpoint}`; const init: RequestInit = { method: "POST", headers: overrideRequestHeaders(accessToken), + ...(serverMint + ? { + body: JSON.stringify({ + scopes, + expirationTime: + args.expirationTime instanceof Date + ? Math.floor(args.expirationTime.getTime() / 1000) + : args.expirationTime, + }), + } + : {}), }; const response = args.fetchOverride ? await args.fetchOverride(url, init, ctx) : await fetch(url, init); if (!response.ok) { + // An additional API key cannot self-sign, so it must use the server mint + // endpoint. On a server too old to expose it, explain the recovery path + // instead of surfacing a bare 404 (mirrors auth.createServerPublicToken). + if (serverMint && response.status === 404) { + throw new Error( + "This additional API key cannot self-sign public tokens, and the server does not support public-token minting. Upgrade the server or use the root API key." + ); + } const text = await response.text().catch(() => ""); throw new Error(`auth.createPublicToken failed: ${response.status} ${text}`); } - const claims = (await response.json()) as Record; + const responseBody = (await response.json()) as Record; + if (serverMint) { + if (typeof responseBody.token !== "string") { + throw new Error("auth.createPublicToken failed: server response did not include a token"); + } + return responseBody.token; + } + return generateJWT({ secretKey: accessToken, payload: { - ...claims, - scopes: [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`], + ...responseBody, + scopes, }, expirationTime: args.expirationTime, }); diff --git a/packages/trigger-sdk/src/v3/auth.test.ts b/packages/trigger-sdk/src/v3/auth.test.ts new file mode 100644 index 0000000000..c32ce788a5 --- /dev/null +++ b/packages/trigger-sdk/src/v3/auth.test.ts @@ -0,0 +1,200 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { apiClientManager } from "@trigger.dev/core/v3"; +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { auth } from "./auth.js"; + +type ReceivedRequest = { + method: string; + url: string; + authorization?: string; + body: unknown; +}; + +describe("public token API key routing", () => { + let server: Server; + let baseUrl: string; + let requests: ReceivedRequest[]; + let publicTokenStatus: number; + + beforeEach(async () => { + requests = []; + publicTokenStatus = 200; + server = createServer((request, response) => { + void handleRequest(request, response, requests, () => publicTokenStatus); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("uses server minting for additional keys", async () => { + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken({ + scopes: { read: { runs: ["run_123"] } }, + expirationTime: new Date("2030-01-01T00:00:00.000Z"), + realtime: { skipColumns: ["payload"] }, + }) + ); + + expect(token).toBe("server-minted-token"); + expect(requests).toEqual([ + { + method: "POST", + url: "/api/v1/auth/public-tokens", + authorization: `Bearer ${key}`, + body: { + scopes: ["read:runs:run_123"], + expirationTime: 1893456000, + realtime: { skipColumns: ["payload"] }, + }, + }, + ]); + }); + + it("server-mints trigger tokens with one-time-use semantics", async () => { + const key = "tr_dev_sk_0123456789abcdefghijklmn"; + await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createTriggerPublicToken(["task-one", "task-two"], { multipleUse: true }) + ); + + expect(requests[0]?.body).toEqual({ + scopes: ["trigger:tasks:task-one", "trigger:tasks:task-two"], + oneTimeUse: false, + }); + }); + + it("server-mints batch trigger tokens for additional keys", async () => { + const key = "tr_stg_sk_0123456789abcdefghijklmn"; + await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createBatchTriggerPublicToken("batch-task", { + expirationTime: "2h", + multipleUse: false, + realtime: { skipColumns: ["payload"] }, + }) + ); + + expect(requests).toEqual([ + { + method: "POST", + url: "/api/v1/auth/public-tokens", + authorization: `Bearer ${key}`, + body: { + scopes: ["batchTrigger:tasks:batch-task"], + expirationTime: "2h", + oneTimeUse: true, + realtime: { skipColumns: ["payload"] }, + }, + }, + ]); + }); + + it("keeps root key self-minting unchanged", async () => { + const key = "tr_prod_0123456789abcdefghijklmn"; + const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken({ scopes: { read: { runs: true } } }) + ); + + expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]); + const validation = await validateJWT(token, key); + expect(validation.ok).toBe(true); + if (!validation.ok) return; + expect(validation.payload).toMatchObject({ + sub: "env_test", + pub: true, + scopes: ["read:runs"], + }); + }); + + it.each([ + ["no options at all", undefined], + ["an empty scopes object", {}], + ["a scope group with nothing selected", { read: {} }], + ])("keeps root-key behavior unchanged for %s", async (_label, scopes) => { + const key = "tr_prod_0123456789abcdefghijklmn"; + const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken(scopes === undefined ? undefined : { scopes }) + ); + + expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]); + const validation = await validateJWT(token, key); + expect(validation.ok).toBe(true); + }); + + it.each([ + ["no options at all", undefined], + ["an empty scopes object", {}], + ["a scope group with nothing selected", { read: {} }], + ])("rejects %s clearly for additional keys", async (_label, scopes) => { + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + const promise = apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken(scopes === undefined ? undefined : { scopes }) + ); + + await expect(promise).rejects.toThrow( + "requires at least one scope when using an additional API key" + ); + expect(requests).toEqual([]); + }); + + it("explains how to recover when the server lacks the mint endpoint", async () => { + publicTokenStatus = 404; + const promise = apiClientManager.runWithConfig( + { + baseURL: baseUrl, + accessToken: "tr_prod_sk_0123456789abcdefghijklmn", + }, + () => auth.createPublicToken({ scopes: { read: { runs: true } } }) + ); + + await expect(promise).rejects.toThrow("Upgrade the server or use the root API key"); + }); +}); + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + requests: ReceivedRequest[], + publicTokenStatus: () => number +) { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const rawBody = Buffer.concat(chunks).toString(); + requests.push({ + method: request.method ?? "", + url: request.url ?? "", + authorization: request.headers.authorization, + body: rawBody ? JSON.parse(rawBody) : undefined, + }); + + if (request.url === "/api/v1/auth/jwt/claims") { + return json(response, { sub: "env_test", pub: true }); + } + if (request.url === "/api/v1/auth/public-tokens") { + const status = publicTokenStatus(); + return json( + response, + status === 200 ? { token: "server-minted-token" } : { error: "Not found" }, + status + ); + } + + return json(response, { error: "Not found" }, 404); +} + +function json(response: ServerResponse, body: unknown, status = 200) { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index 5e76765956..d26a08fa87 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -1,8 +1,11 @@ import { type RealtimeRunSkipColumns, + type ApiClient, type ApiClientConfiguration, + type CreatePublicTokenRequestBody, apiClientManager, generateJWT as internal_generateJWT, + isAdditionalApiKey, } from "@trigger.dev/core/v3"; import "@trigger.dev/core/v3/sdk-scope-storage"; @@ -102,7 +105,9 @@ export type PublicTokenPermissions = { export type CreatePublicTokenOptions = { /** - * A collection of permission scopes to be granted to the token. + * A collection of permission scopes to be granted to the token. This remains + * optional for root API key compatibility; additional API keys require at + * least one scope. * * @example * @@ -117,7 +122,8 @@ export type CreatePublicTokenOptions = { scopes?: PublicTokenPermissions; /** - * The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string. + * The expiration time for the token: a duration string, a `Date`, or a Unix + * timestamp in **seconds**. * * @example * @@ -146,6 +152,35 @@ export type CreatePublicTokenOptions = { }; }; +// A `Date` becomes Unix seconds; duration strings and numbers pass through. A +// bare number is already Unix seconds, matching how the local signing path +// reads it, so both key types treat the option the same. +function serverExpirationTime( + expirationTime: number | Date | string | undefined +): number | string | undefined { + return expirationTime instanceof Date + ? Math.floor(expirationTime.getTime() / 1000) + : expirationTime; +} + +async function createServerPublicToken( + apiClient: ApiClient, + body: CreatePublicTokenRequestBody +): Promise { + try { + const result = await apiClient.createPublicToken(body); + return result.token; + } catch (error) { + if (error !== null && typeof error === "object" && "status" in error && error.status === 404) { + throw new Error( + "This additional API key cannot self-sign public tokens, and the server does not support public-token minting. Upgrade the server or use the root API key." + ); + } + + throw error; + } +} + /** * Creates a public token using the provided options. * @@ -170,15 +205,34 @@ export type CreatePublicTokenOptions = { * ``` */ async function createPublicToken(options?: CreatePublicTokenOptions): Promise { + const scopes = options?.scopes ? flattenScopes(options.scopes) : []; const apiClient = apiClientManager.clientOrThrow(); + if (isAdditionalApiKey(apiClient.accessToken)) { + // Additional keys cannot self-sign, and the server rejects empty scope + // lists because they cannot authorize anything. Keep the legacy root-key + // behaviour unchanged while failing clearly for this new credential type. + if (scopes.length === 0) { + throw new Error( + "auth.createPublicToken() requires at least one scope when using an additional API key. " + + 'For example: auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })' + ); + } + + return createServerPublicToken(apiClient, { + scopes, + expirationTime: serverExpirationTime(options?.expirationTime), + realtime: options?.realtime, + }); + } + const claims = await apiClient.generateJWTClaims(); return await internal_generateJWT({ secretKey: apiClient.accessToken, payload: { ...claims, - scopes: options?.scopes ? flattenScopes(options.scopes) : undefined, + scopes: options?.scopes ? scopes : undefined, realtime: options?.realtime, }, expirationTime: options?.expirationTime, @@ -199,7 +253,8 @@ async function withPublicToken(options: CreatePublicTokenOptions, fn: () => Prom export type CreateTriggerTokenOptions = { /** - * The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string. + * The expiration time for the token: a duration string, a `Date`, or a Unix + * timestamp in **seconds**. * * @example * @@ -271,6 +326,21 @@ async function createTriggerPublicToken( options?: CreateTriggerTokenOptions ): Promise { const apiClient = apiClientManager.clientOrThrow(); + const scopes = flattenScopes({ + trigger: { + tasks: task, + }, + }); + const oneTimeUse = typeof options?.multipleUse === "boolean" ? !options.multipleUse : true; + + if (isAdditionalApiKey(apiClient.accessToken)) { + return createServerPublicToken(apiClient, { + scopes, + expirationTime: serverExpirationTime(options?.expirationTime), + oneTimeUse, + realtime: options?.realtime, + }); + } const claims = await apiClient.generateJWTClaims(); @@ -278,13 +348,9 @@ async function createTriggerPublicToken( secretKey: apiClient.accessToken, payload: { ...claims, - otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true, + otu: oneTimeUse, realtime: options?.realtime, - scopes: flattenScopes({ - trigger: { - tasks: task, - }, - }), + scopes, }, expirationTime: options?.expirationTime, }); @@ -343,6 +409,21 @@ async function createBatchTriggerPublicToken( options?: CreateTriggerTokenOptions ): Promise { const apiClient = apiClientManager.clientOrThrow(); + const scopes = flattenScopes({ + batchTrigger: { + tasks: task, + }, + }); + const oneTimeUse = typeof options?.multipleUse === "boolean" ? !options.multipleUse : true; + + if (isAdditionalApiKey(apiClient.accessToken)) { + return createServerPublicToken(apiClient, { + scopes, + expirationTime: serverExpirationTime(options?.expirationTime), + oneTimeUse, + realtime: options?.realtime, + }); + } const claims = await apiClient.generateJWTClaims(); @@ -350,13 +431,9 @@ async function createBatchTriggerPublicToken( secretKey: apiClient.accessToken, payload: { ...claims, - otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true, + otu: oneTimeUse, realtime: options?.realtime, - scopes: flattenScopes({ - batchTrigger: { - tasks: task, - }, - }), + scopes, }, expirationTime: options?.expirationTime, }); diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index d1a6a9b61d..ca18ce5998 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -132,6 +132,44 @@ describe("chat.createStartSessionAction — runtime", () => { expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1"); }); + it("server-mints override tokens for additional API keys", async () => { + const requests: Array<{ url: string; body: unknown }> = []; + const start = chat.createStartSessionAction("fake-chat", { + apiClient: { + baseURL: "https://example.invalid", + accessToken: "tr_prod_sk_0123456789abcdefghijklmn", + }, + tokenTTL: "30m", + fetch: async (url, init, context) => { + requests.push({ + url, + body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, + }); + + if (context.endpoint === "sessions") { + return Response.json({ + id: "session_fixture", + runId: "run_fixture", + publicAccessToken: "session-token", + }); + } + + return Response.json({ token: "server-minted-token" }); + }, + }); + + const result = await start({ chatId: "chat-additional-key" }); + + expect(result.publicAccessToken).toBe("server-minted-token"); + expect(requests[1]).toEqual({ + url: "https://example.invalid/api/v1/auth/public-tokens", + body: { + scopes: ["read:sessions:chat-additional-key", "write:sessions:chat-additional-key"], + expirationTime: "30m", + }, + }); + }); + it("keeps session-level metadata distinct from per-turn clientData", async () => { installStartFixture(); From d30ee6e5700ecc7eaaff0c92bc05d1132573b3a8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 16:29:36 +0100 Subject: [PATCH 17/23] feat(webapp): favorite pages and sidebar customization (#4375) ## Summary Favorite any dashboard page and it appears in a new "Favorites" section at the top of the side menu. The star next to the page title (or Option+F) saves the exact view, filters and tabs included, with a name derived from the URL ("Runs: Completed successfully, last 7d", "Run: 05hrqq9n") that you can rename inline from each item's hover menu. The sidebar is customizable too: "Customize sidebar" (on section header menus and in each "More" menu) opens a modal where you can reorder sections, drag items into a new order, hide items behind a per-section "More" popover, and rename or remove favorites. Changes apply on Confirm, Reset restores the default layout without touching favorites, and everything is stored per user in dashboard preferences. ## Screenshots | Favorites in the side menu | Customize sidebar modal | | --- | --- | | ![Favorites section with rename and remove menu](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/favorites-side-menu.png) | ![Customize sidebar modal](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/customize-sidebar-modal.png) | ![Favorite star and tooltip in the page header](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/star-tooltip.png) ## Design notes - Favorite links carry a small marker search param so the favorite, not its identical main menu item, highlights as active. Markers from shared or stale links are cleaned on load, and changing any filter hands the highlight back to the regular menu item. - Preference writes are serialized with a row lock: several writers (debounced collapse and width saves, favorite toggles, the customize modal) can land concurrently and would otherwise clobber each other's read-modify-write of the JSON column. - Option+F is matched on `event.code` with a raw listener because macOS reports Option-modified letters as symbols, which the `event.key` based shortcut hook can't capture. Verified end-to-end in the browser: star toggle and shortcut, instant section appearance, inline rename and staged modal removal, filter-aware labels and unique active states, shared-link normalization, drag reordering, and persistence across reloads. --- ...avorite-pages-and-sidebar-customization.md | 6 + apps/webapp/app/assets/icons/CrossIcon.tsx | 14 + .../webapp/app/assets/icons/EyeClosedIcon.tsx | 35 + apps/webapp/app/assets/icons/EyeOpenIcon.tsx | 27 + apps/webapp/app/assets/icons/RenameIcon.tsx | 27 + .../app/assets/icons/SidebarCustomizeIcon.tsx | 26 + apps/webapp/app/components/Shortcuts.tsx | 4 + .../navigation/CustomizeSidebarDialog.tsx | 506 ++++++++++ .../components/navigation/DashboardList.tsx | 1 + .../navigation/FavoritePageButton.tsx | 144 +++ .../navigation/FavoritesSection.tsx | 198 ++++ .../app/components/navigation/SideMenu.tsx | 921 +++++++++++++----- .../components/navigation/SideMenuItem.tsx | 85 +- .../components/navigation/SideMenuSection.tsx | 29 +- .../components/navigation/favoritePages.tsx | 511 ++++++++++ .../components/navigation/sideMenuTypes.ts | 56 ++ .../app/components/primitives/PageHeader.tsx | 34 +- .../route.tsx | 13 +- .../resources.preferences.favorites.tsx | 80 ++ .../routes/resources.preferences.sidemenu.tsx | 64 +- .../services/dashboardPreferences.server.ts | 415 ++++++-- 21 files changed, 2838 insertions(+), 358 deletions(-) create mode 100644 .server-changes/favorite-pages-and-sidebar-customization.md create mode 100644 apps/webapp/app/assets/icons/CrossIcon.tsx create mode 100644 apps/webapp/app/assets/icons/EyeClosedIcon.tsx create mode 100644 apps/webapp/app/assets/icons/EyeOpenIcon.tsx create mode 100644 apps/webapp/app/assets/icons/RenameIcon.tsx create mode 100644 apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx create mode 100644 apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx create mode 100644 apps/webapp/app/components/navigation/FavoritePageButton.tsx create mode 100644 apps/webapp/app/components/navigation/FavoritesSection.tsx create mode 100644 apps/webapp/app/components/navigation/favoritePages.tsx create mode 100644 apps/webapp/app/routes/resources.preferences.favorites.tsx diff --git a/.server-changes/favorite-pages-and-sidebar-customization.md b/.server-changes/favorite-pages-and-sidebar-customization.md new file mode 100644 index 0000000000..d4468b4df5 --- /dev/null +++ b/.server-changes/favorite-pages-and-sidebar-customization.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Favorite any dashboard page to a new Favorites section in the side menu, and customize the sidebar by renaming favorites, hiding items, and reordering items and sections. diff --git a/apps/webapp/app/assets/icons/CrossIcon.tsx b/apps/webapp/app/assets/icons/CrossIcon.tsx new file mode 100644 index 0000000000..fa0c714602 --- /dev/null +++ b/apps/webapp/app/assets/icons/CrossIcon.tsx @@ -0,0 +1,14 @@ +export function CrossIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EyeClosedIcon.tsx b/apps/webapp/app/assets/icons/EyeClosedIcon.tsx new file mode 100644 index 0000000000..c83d249926 --- /dev/null +++ b/apps/webapp/app/assets/icons/EyeClosedIcon.tsx @@ -0,0 +1,35 @@ +export function EyeClosedIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EyeOpenIcon.tsx b/apps/webapp/app/assets/icons/EyeOpenIcon.tsx new file mode 100644 index 0000000000..e00cfa0fcf --- /dev/null +++ b/apps/webapp/app/assets/icons/EyeOpenIcon.tsx @@ -0,0 +1,27 @@ +export function EyeOpenIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RenameIcon.tsx b/apps/webapp/app/assets/icons/RenameIcon.tsx new file mode 100644 index 0000000000..6514e0b09c --- /dev/null +++ b/apps/webapp/app/assets/icons/RenameIcon.tsx @@ -0,0 +1,27 @@ +export function RenameIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx new file mode 100644 index 0000000000..0960e4a201 --- /dev/null +++ b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx @@ -0,0 +1,26 @@ +export function SidebarCustomizeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 87e5d08f37..c4ce2db6d0 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -73,6 +73,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx new file mode 100644 index 0000000000..d2c18442e3 --- /dev/null +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -0,0 +1,506 @@ +import { DialogClose } from "@radix-ui/react-dialog"; +import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/20/solid"; +import { GripVerticalIcon } from "lucide-react"; +import { useState } from "react"; +import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout"; +import { CrossIcon } from "~/assets/icons/CrossIcon"; +import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon"; +import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; +import { FormError } from "../primitives/FormError"; +import { Header3 } from "../primitives/Headers"; +import { Icon, type RenderIcon } from "../primitives/Icon"; +import { Input } from "../primitives/Input"; +import { isItemHidden, orderByPreference } from "./sideMenuTypes"; + +export type CustomizeSidebarItem = { + id: string; + name: string; + icon: RenderIcon; + iconClassName?: string; + defaultHidden?: boolean; + /** Favorites get an inline-editable name in the modal. */ + isFavorite?: boolean; +}; + +export type CustomizeSidebarSection = { + id: string; + title: string; + /** Items in DEFAULT order (favorites: saved order — that is their default). */ + items: CustomizeSidebarItem[]; +}; + +type SavedPreferences = { + sectionOrder?: string[]; + hiddenItems?: Record; + sectionItemOrder?: Record; +}; + +/** What Confirm produces; null clears a stored preference back to its default. */ +export type SidebarCustomizationPayload = { + sectionOrder: string[] | null; + hiddenItems: Record | null; + sectionItemOrder: Record | null; + favorites?: Array<{ id: string; label: string }>; + removedFavoriteIds?: string[]; +}; + +type DialogState = { + sectionOrder: string[]; + /** section id -> item ids in order */ + itemOrders: Record; + /** item id -> effective hidden */ + hidden: Record; + /** favorite id -> label being edited */ + labels: Record; + /** favorite ids staged for removal; applied on Confirm */ + removed: string[]; +}; + +const FAVORITES_SECTION_ID = "favorites"; +const ROW_HEIGHT = 44; + +function buildState( + sections: CustomizeSidebarSection[], + prefs: SavedPreferences | undefined +): DialogState { + const orderedSections = prefs ? orderByPreference(sections, prefs.sectionOrder) : sections; + + const itemOrders: Record = {}; + const hidden: Record = {}; + const labels: Record = {}; + + for (const section of sections) { + // Favorites' array order is canonical (already applied), so saved item order only applies to + // the static sections. + const orderedItems = + prefs && section.id !== FAVORITES_SECTION_ID + ? orderByPreference(section.items, prefs.sectionItemOrder?.[section.id]) + : section.items; + itemOrders[section.id] = orderedItems.map((item) => item.id); + + for (const item of section.items) { + hidden[item.id] = prefs + ? isItemHidden(item, prefs.hiddenItems) + : (item.defaultHidden ?? false); + if (item.isFavorite) { + labels[item.id] = item.name; + } + } + } + + return { + sectionOrder: orderedSections.map((section) => section.id), + itemOrders, + hidden, + labels, + removed: [], + }; +} + +function arraysEqual(a: string[], b: string[]) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** + * The "Customize sidebar" modal: reorder sections (arrows), reorder items (drag), hide/show items + * (eye), and rename favorites inline. Nothing is applied until Confirm; Reset restores the default + * layout without touching which pages are favorited. + */ +export function CustomizeSidebarDialog({ + sections, + prefs, + onConfirm, + isConfirming, + confirmError, +}: { + sections: CustomizeSidebarSection[]; + prefs: SavedPreferences | undefined; + /** + * Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. The + * parent submits the payload and closes the dialog once the save lands (or reports back via + * `confirmError`), so a failed save never silently reads as a successful one. + */ + onConfirm: (payload: SidebarCustomizationPayload) => void; + /** True from Confirm until the save (and the refreshed side menu data) lands. */ + isConfirming: boolean; + /** Save failure to surface next to Confirm; the dialog stays open for a retry. */ + confirmError?: string; +}) { + const [state, setState] = useState(() => buildState(sections, prefs)); + + // The Favorites section disappears with its last staged-removed favorite, matching the side + // menu (which hides the section when empty) + const displayedSections = (current: DialogState) => + current.sectionOrder + .map((id) => sections.find((section) => section.id === id)) + .filter((section): section is CustomizeSidebarSection => section !== undefined) + .filter( + (section) => + section.id !== FAVORITES_SECTION_ID || + section.items.some((item) => !current.removed.includes(item.id)) + ); + + const orderedSections = displayedSections(state); + + const moveSection = (sectionId: string, direction: -1 | 1) => { + setState((current) => { + // Swap with the DISPLAYED neighbor: a hidden Favorites entry may still sit in + // sectionOrder between two visible sections + const displayed = displayedSections(current).map((section) => section.id); + const neighborId = displayed[displayed.indexOf(sectionId) + direction]; + if (!neighborId) return current; + const next = [...current.sectionOrder]; + const a = next.indexOf(sectionId); + const b = next.indexOf(neighborId); + [next[a], next[b]] = [next[b], next[a]]; + return { ...current, sectionOrder: next }; + }); + }; + + const reorderItems = (sectionId: string, itemIds: string[]) => { + setState((current) => ({ + ...current, + itemOrders: { ...current.itemOrders, [sectionId]: itemIds }, + })); + }; + + const toggleHidden = (itemId: string) => { + setState((current) => ({ + ...current, + hidden: { ...current.hidden, [itemId]: !current.hidden[itemId] }, + })); + }; + + const setLabel = (itemId: string, label: string) => { + setState((current) => ({ ...current, labels: { ...current.labels, [itemId]: label } })); + }; + + const removeFavorite = (itemId: string) => { + setState((current) => ({ ...current, removed: [...current.removed, itemId] })); + }; + + // Reset restores the default layout (positions + visibility) but never touches favorite names + // or staged removals; Cancel is the way out of those + const reset = () => + setState((current) => ({ + ...buildState(sections, undefined), + labels: current.labels, + removed: current.removed, + })); + + const hasBlankLabels = sections.some((section) => + section.items.some( + (item) => + item.isFavorite && + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim().length === 0 + ) + ); + + const confirm = () => { + const defaults = buildState(sections, undefined); + + const hiddenOverrides: Record = {}; + for (const section of sections) { + for (const item of section.items) { + if (state.removed.includes(item.id)) continue; + const isHidden = state.hidden[item.id] ?? false; + if (isHidden !== (item.defaultHidden ?? false)) { + hiddenOverrides[item.id] = isHidden; + } + } + } + + const sectionItemOrder: Record = {}; + for (const section of sections) { + if (section.id === FAVORITES_SECTION_ID) continue; + const order = state.itemOrders[section.id] ?? []; + if (!arraysEqual(order, defaults.itemOrders[section.id] ?? [])) { + sectionItemOrder[section.id] = order; + } + } + + const favoritesSection = sections.find((section) => section.id === FAVORITES_SECTION_ID); + const favoriteOrder = (state.itemOrders[FAVORITES_SECTION_ID] ?? []).filter( + (id) => !state.removed.includes(id) + ); + const favoritesChanged = + favoritesSection !== undefined && + (state.removed.length > 0 || + !arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) || + favoritesSection.items.some( + (item) => + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim() !== item.name + )); + + // Parts equal to the defaults are sent as null so the stored preference is cleared, not pinned + const payload: SidebarCustomizationPayload = { + sectionOrder: arraysEqual(state.sectionOrder, defaults.sectionOrder) + ? null + : state.sectionOrder, + hiddenItems: Object.keys(hiddenOverrides).length > 0 ? hiddenOverrides : null, + sectionItemOrder: Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : null, + favorites: favoritesChanged + ? favoriteOrder.map((id) => ({ id, label: state.labels[id] ?? "" })) + : undefined, + removedFavoriteIds: state.removed.length > 0 ? state.removed : undefined, + }; + + onConfirm(payload); + }; + + return ( + + Customize sidebar + {/* Bleeds through the container's right padding (-mr-4/pr-4) so the scrollbar sits at the + modal edge, and through the vertical grid gaps (-mt-1.25/-mb-4) so the scrollport (and + scrollbar) starts at the header divider and ends at the footer border. pt-3/pb-3 are + INSIDE the scrollport: resting gaps around the list that content scrolls through. */} +
+ {orderedSections.map((section, index) => ( +
+
+ {section.title} +
+ moveSection(section.id, -1)} + > + + + moveSection(section.id, 1)} + > + + +
+
+ item.id)).filter( + (id) => !state.removed.includes(id) + )} + hidden={state.hidden} + labels={state.labels} + onReorder={(itemIds) => reorderItems(section.id, itemIds)} + onToggleHidden={toggleHidden} + onLabelChange={setLabel} + onRemove={removeFavorite} + /> +
+ ))} +
+ {/* Negative margins stretch the top divider across the modal's full width */} + +
+ + + + +
+
+ {confirmError && !isConfirming && ( + {confirmError} + )} + +
+
+
+ ); +} + +function SectionMoveButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SectionItemList({ + section, + order, + hidden, + labels, + onReorder, + onToggleHidden, + onLabelChange, + onRemove, +}: { + section: CustomizeSidebarSection; + order: string[]; + hidden: Record; + labels: Record; + onReorder: (itemIds: string[]) => void; + onToggleHidden: (itemId: string) => void; + onLabelChange: (itemId: string, label: string) => void; + onRemove: (itemId: string) => void; +}) { + const { width, containerRef } = useContainerWidth({ initialWidth: 416 }); + + const items = order + .map((id) => section.items.find((item) => item.id === id)) + .filter((item): item is CustomizeSidebarItem => item !== undefined); + + const layout = items.map((item, index) => ({ i: item.id, x: 0, y: index, w: 1, h: 1 })); + + const handleDragStop = (nextLayout: Layout) => { + const sorted = [...nextLayout].sort((a, b) => a.y - b.y).map((entry) => entry.i); + if (!arraysEqual(sorted, order)) { + onReorder(sorted); + } + }; + + const renderRow = (item: CustomizeSidebarItem, options: { draggable: boolean }) => ( + onToggleHidden(item.id)} + onLabelChange={(label) => onLabelChange(item.id, label)} + onRemove={() => onRemove(item.id)} + /> + ); + + return ( +
}> + {items.length >= 2 ? ( + + {items.map((item) => ( +
{renderRow(item, { draggable: true })}
+ ))} +
+ ) : ( + items.map((item) =>
{renderRow(item, { draggable: false })}
) + )} +
+ ); +} + +function ModalItemRow({ + item, + isHidden, + label, + draggable, + onToggleHidden, + onLabelChange, + onRemove, +}: { + item: CustomizeSidebarItem; + isHidden: boolean; + label: string | undefined; + draggable: boolean; + onToggleHidden: () => void; + onLabelChange: (label: string) => void; + onRemove: () => void; +}) { + return ( +
+
+ + {item.isFavorite ? ( + <> + onLabelChange(e.target.value)} + variant="medium" + maxLength={64} + containerClassName="max-w-60" + aria-label={`Rename ${item.name}`} + /> + {(label ?? item.name).trim().length === 0 && ( + Name can't be blank + )} + + ) : ( + {item.name} + )} +
+
+ {item.isFavorite && ( + + )} + + {draggable ? ( +
+ +
+ ) : ( +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx index 7f3b6e3bac..39a7de00c1 100644 --- a/apps/webapp/app/components/navigation/DashboardList.tsx +++ b/apps/webapp/app/components/navigation/DashboardList.tsx @@ -184,6 +184,7 @@ function DashboardChildMenuItem({ to={item.path} isCollapsed={isCollapsed} disableIconHover + yieldActiveToFavorite action={ showDragHandle ? (
diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx new file mode 100644 index 0000000000..c0d03453b3 --- /dev/null +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -0,0 +1,144 @@ +import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline"; +import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid"; +import { useFetcher, useLocation, useSearchParams } from "@remix-run/react"; +import { useEffect } from "react"; +import { useIsImpersonating } from "~/hooks/useOrganizations"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useOptionalUser } from "~/hooks/useUser"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { ShortcutKey } from "../primitives/ShortcutKey"; +import { SimpleTooltip } from "../primitives/Tooltip"; +import { + buildFavoriteLabel, + canonicalFavoriteUrl, + FAVORITE_SEARCH_PARAM, + FAVORITES_ACTION_PATH, + favoritePageUrl, + resolvePageMeta, + useFavorites, +} from "./favoritePages"; + +/** + * The star in the page header that favorites the current page (full URL, including filters and + * tabs) to the side menu. Toggled by click or Option+F. + */ +export function FavoritePageButton({ + pageTitle, + className, +}: { + pageTitle?: string; + className?: string; +}) { + const user = useOptionalUser(); + const isImpersonating = useIsImpersonating(); + const location = useLocation(); + const favorites = useFavorites(); + const fetcher = useFetcher(); + const [, setSearchParams] = useSearchParams(); + + // The marker param and pagination position never count toward URL identity, so paging through + // a favorited view keeps the same favorite (and never saves a soon-stale cursor) + const url = favoritePageUrl(location.pathname, location.search); + + // A marker that isn't one of this user's favorites came from a shared link (or a favorite + // that's since been removed): clean it from the URL so the page behaves like a normal visit. + const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); + const hasForeignMarker = + user !== undefined && marker !== null && !favorites.some((f) => f.id === marker); + + useEffect(() => { + if (!hasForeignMarker) return; + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + next.delete(FAVORITE_SEARCH_PARAM); + return next; + }, + { replace: true, preventScrollReset: true } + ); + }, [hasForeignMarker, setSearchParams]); + const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url); + const isFavorited = existing !== undefined; + // The tooltip names the favorite: its custom name once saved, else the label saving would use + // (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d") + const pageName = + existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle); + + const toggle = () => { + if (existing) { + fetcher.submit( + { intent: "remove", id: existing.id }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } else { + fetcher.submit( + { + intent: "add", + id: crypto.randomUUID(), + url, + label: buildFavoriteLabel(location.pathname, location.search, pageTitle), + icon: resolvePageMeta(location.pathname).icon, + }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } + }; + + const showButton = user !== undefined && !isImpersonating; + + // Option+F reports event.key "ƒ" on macOS, but the hotkeys matcher falls back to the physical + // event.code ("KeyF"), so the standard hook captures it; exact modifier matching keeps the + // bare "f" filter shortcut separate. + useShortcutKeys({ + shortcut: { key: "f", modifiers: ["alt"] }, + action: (event) => { + event.preventDefault(); + toggle(); + }, + disabled: !showButton, + }); + + if (!showButton) { + return null; + } + + const tooltipLabel = isFavorited + ? `Remove ${pageName} from favorites` + : `Add ${pageName} to favorites`; + + return ( + +
@@ -1097,10 +1270,274 @@ export function SideMenu({
+ { + setCustomizeOpen(open); + if (!open) { + // Cancel/ESC during a pending confirm abandons the wait; a still-running save is + // harmless (the menu revalidates whenever it lands) + setCustomizeConfirmPending(false); + setCustomizeError(undefined); + } + }} + > + {/* Mounted only while open so the modal state re-seeds from current preferences each time */} + {isCustomizeOpen && ( + + )} +
); } +/** A section built from {@link SideMenuItemConfig}s with the user's order/hidden prefs applied. */ +function CustomizableSideMenuSection({ + section, + itemOrder, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, + onCustomize, +}: { + section: SideMenuSectionConfig; + itemOrder: string[] | undefined; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; +}) { + const orderedItems = orderByPreference(section.items, itemOrder); + const visibleItems = orderedItems.filter((item) => !isItemHidden(item, hiddenItems)); + const moreItems = orderedItems.filter((item) => isItemHidden(item, hiddenItems)); + + return ( + + {visibleItems.map((item) => ( + + + {item.after} + + ))} + {moreItems.length > 0 && ( + ({ + id: item.id, + name: item.name, + icon: item.icon, + to: item.to, + }))} + isCollapsed={isCollapsed} + onCustomize={onCustomize} + /> + )} + + ); +} + +/** The user's favorited pages; hidden favorites collapse into the trailing "More" item. */ +function FavoritesSideMenuSection({ + favorites, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, + onCustomize, + onRemoveFavorite, + onRenameFavorite, +}: { + favorites: FavoritePage[]; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; + onRemoveFavorite: (id: string) => void; + onRenameFavorite: (id: string, label: string) => void; +}) { + const visible = favorites.filter((favorite) => !(hiddenItems?.[favorite.id] ?? false)); + const hidden = favorites.filter((favorite) => hiddenItems?.[favorite.id] ?? false); + + return ( + + {visible.map((favorite) => ( + + ))} + {hidden.length > 0 && ( + ({ + id: favorite.id, + name: favorite.label, + icon: favoritePageIcon(favorite.icon), + iconClassName: favoritePageIconClassName(favorite.icon), + to: favoriteLinkTo(favorite), + }))} + isCollapsed={isCollapsed} + onCustomize={onCustomize} + /> + )} + + ); +} + +/** + * The trailing "More" item of a section: a popover listing that section's hidden items. Only + * rendered when the section has hidden items. + */ +function SideMenuMoreItem({ + items, + isCollapsed, + onCustomize, +}: { + items: Array<{ id: string; name: string; icon: RenderIcon; iconClassName?: string; to: string }>; + isCollapsed: boolean; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; +}) { + const [isOpen, setOpen] = useState(false); + const navigation = useNavigation(); + + // Watch search too: navigating to a favorite can change only the search on the same pathname + useEffect(() => { + setOpen(false); + }, [navigation.location?.pathname, navigation.location?.search]); + + return ( + + + + + More + + + } + content="More" + side="right" + sideOffset={8} + hidden={!isCollapsed} + asChild + tabbable + disableHoverableContent + /> + +
+ {items.map((item) => ( + + ))} +
+ {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} + {onCustomize && ( +
+ { + setOpen(false); + onCustomize(); + }} + /> +
+ )} +
+
+ ); +} + +/** The ellipsis on section headers (visible on hover) opening the sidebar customization menu. */ +function SectionHeaderMenu({ onCustomize }: { onCustomize: () => void }) { + const [isOpen, setOpen] = useState(false); + + return ( + + + + + + {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} +
+ { + setOpen(false); + onCustomize(); + }} + /> +
+
+
+ ); +} + function V3DeprecationPanel({ isCollapsed, isV3, diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index f17051266d..035fcc5437 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -3,6 +3,9 @@ import { type ButtonHTMLAttributes, forwardRef, type ReactNode, + useEffect, + useRef, + useState, } from "react"; import { Link } from "@remix-run/react"; import { motion } from "framer-motion"; @@ -10,6 +13,60 @@ import { usePathName } from "~/hooks/usePathName"; import { cn } from "~/utils/cn"; import { type RenderIcon, Icon } from "../primitives/Icon"; import { SimpleTooltip } from "../primitives/Tooltip"; +import { useActiveFavoriteId } from "./favoritePages"; + +/** Right-edge fade shown instead of a hard clip, only while the label actually overflows. */ +const LABEL_OVERFLOW_MASK = "linear-gradient(to right, black calc(100% - 1.5rem), transparent)"; + +/** + * A menu label that fades out at its right edge when (and only when) the text overflows. Text + * that fits renders exactly as before, with no mask. Overflow is re-measured when the element + * resizes (e.g. while drag-resizing the menu) and when the label text changes. + */ +export function SideMenuLabel({ + children, + className, + style, +}: { + children: ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const ref = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const checkRef = useRef<() => void>(() => {}); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth + 1); + checkRef.current = check; + check(); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Re-measure when the text changes (a rename can flip overflow without resizing the element) + useEffect(() => { + checkRef.current(); + }, [children]); + + return ( + + {children} + + ); +} export function SideMenuItem({ icon, @@ -27,6 +84,8 @@ export function SideMenuItem({ action, disableIconHover = false, indented = false, + isActive: isActiveOverride, + yieldActiveToFavorite = false, "data-action": dataAction, }: { icon?: RenderIcon; @@ -45,10 +104,24 @@ export function SideMenuItem({ disableIconHover?: boolean; /** Indented variant for grouped sub-items; only applied when the menu is expanded. */ indented?: boolean; + /** Overrides the default pathname === to active check (e.g. favorites match on full URL). */ + isActive?: boolean; + /** + * In menus that render the Favorites section (the main project menu), an active favorite owns + * the highlight, so the plain item yields its active state to it. Menus without a favorites + * list (org settings, account) must not set this: they have no favorite item to carry the + * highlight instead. + */ + yieldActiveToFavorite?: boolean; "data-action"?: string; }) { const pathName = usePathName(); - const isActive = pathName === to; + // Only the user's OWN favorites own a view (via the marker param); markers from shared links + // don't count (see useActiveFavoriteId). + const activeFavoriteId = useActiveFavoriteId(); + const isActive = + isActiveOverride ?? + (pathName === to && (!yieldActiveToFavorite || activeFavoriteId === undefined)); const isIndented = indented && !isCollapsed; @@ -92,14 +165,14 @@ export function SideMenuItem({ className="flex w-full min-w-0 items-center justify-between" style={{ opacity: "var(--sm-label-opacity, 1)" }} > - {name} - + {badge && !isCollapsed && (
{badge}
)} @@ -189,9 +262,9 @@ export const SideMenuItemButton = forwardRef< icon={icon} className="size-5 shrink-0 text-text-dimmed group-hover/menuitem:text-text-bright" /> - + {name} - + {trailing && {trailing}} ); diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 8225d9e701..4f0d37e271 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -12,6 +12,11 @@ type Props = { itemSpacingClassName?: string; /** Optional action element (e.g., + button) to render on the right side of the header */ headerAction?: React.ReactNode; + /** + * Optional menu (e.g. an ellipsis popover) overlaid on the right of the header. Only visible + * while hovering the header row, or while its popover is open. + */ + headerMenu?: React.ReactNode; }; /** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */ @@ -23,6 +28,7 @@ export function SideMenuSection({ isSideMenuCollapsed = false, itemSpacingClassName = "space-y-px", headerAction, + headerMenu, }: Props) { const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); const contentRef = useRef(null); @@ -45,7 +51,7 @@ export function SideMenuSection({ return (
{/* Header container - stays in DOM to preserve height */} -
+
{/* Header fades out as the menu narrows via --sm-label-opacity (falls back to 1 unset). Hover background and text color snap (no transition), matching the nav items. @@ -53,8 +59,9 @@ export function SideMenuSection({ + {headerMenu !== undefined && + !isSideMenuCollapsed && ( + // Outer div fades with the labels (inline style would defeat the hover opacity classes + // on the inner div, so they're split). +
+ {/* focus-within keeps the trigger visible for keyboard users tabbing onto it */} +
+ {headerMenu} +
+
+ )} {/* Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded. */} diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx new file mode 100644 index 0000000000..4439aadedf --- /dev/null +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -0,0 +1,511 @@ +import { BeakerIcon } from "@heroicons/react/24/outline"; +import { IconChartHistogram } from "@tabler/icons-react"; +import { useFetchers, useLocation } from "@remix-run/react"; +import { ClockIcon } from "~/assets/icons/ClockIcon"; +import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; +import { TaskIconSmall } from "~/assets/icons/TaskIcon"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon"; +import { AIPenIcon } from "~/assets/icons/AIPenIcon"; +import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; +import { BatchesIcon } from "~/assets/icons/BatchesIcon"; +import { BellIcon } from "~/assets/icons/BellIcon"; +import { Box3DIcon } from "~/assets/icons/Box3DIcon"; +import { BugIcon } from "~/assets/icons/BugIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; +import { ChartArrowIcon } from "~/assets/icons/ChartArrowIcon"; +import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; +import { DialIcon } from "~/assets/icons/DialIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; +import { IDIcon } from "~/assets/icons/IDIcon"; +import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; +import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { LogsIcon } from "~/assets/icons/LogsIcon"; +import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { QueuesIcon } from "~/assets/icons/QueuesIcon"; +import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { ShieldIcon } from "~/assets/icons/ShieldIcon"; +import { SlackIcon } from "~/assets/icons/SlackIcon"; +import { SlidersIcon } from "~/assets/icons/SlidersIcon"; +import { StarIcon } from "~/assets/icons/StarIcon"; +import { TasksIcon } from "~/assets/icons/TasksIcon"; +import { UsageIcon } from "~/assets/icons/UsageIcon"; +import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { useOptionalUser } from "~/hooks/useUser"; +import { type FavoritePage } from "~/services/dashboardPreferences.server"; +import { type RenderIcon } from "../primitives/Icon"; + +export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites"; + +/** + * Marker search param appended to favorite links. It makes a favorited URL distinct from its + * plain counterpart, so only the favorite (never the matching main menu item) highlights as + * active, and the marker identifies WHICH favorite when several share a pathname. + */ +export const FAVORITE_SEARCH_PARAM = "fav"; + +/** + * Icons a favorited page can be saved with, keyed by a stable string so preferences never store + * component references, plus the icon color used when the favorite is the active page and an + * optional size override for icons drawn without internal padding (brand logos). Unknown keys + * fall back to the star. + */ +const FAVORITE_PAGE_ICONS: Record< + string, + { icon: RenderIcon; activeColor: string; className?: string } +> = { + tasks: { icon: TasksIcon, activeColor: "text-tasks" }, + // Task detail pages carry their task-type icon, matching TaskTriggerSourceIcon + "task-standard": { icon: TaskIconSmall, activeColor: "text-tasks" }, + "task-scheduled": { icon: ClockIcon, activeColor: "text-schedules" }, + "task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" }, + runs: { icon: RunsIcon, activeColor: "text-runs" }, + sessions: { icon: AIChatIcon, activeColor: "text-sessions" }, + prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" }, + models: { icon: Box3DIcon, activeColor: "text-models" }, + logs: { icon: LogsIcon, activeColor: "text-logs" }, + errors: { icon: BugIcon, activeColor: "text-errors" }, + query: { icon: CodeSquareIcon, activeColor: "text-query" }, + queues: { icon: QueuesIcon, activeColor: "text-queues" }, + dashboards: { icon: ChartBarIcon, activeColor: "text-metrics" }, + "run-metrics": { icon: ChartArrowIcon, activeColor: "text-runs" }, + "ai-metrics": { icon: AIMetricsIcon, activeColor: "text-aiMetrics" }, + "custom-dashboard": { icon: IconChartHistogram, activeColor: "text-text-bright" }, + deployments: { icon: DeploymentsIcon, activeColor: "text-deployments" }, + "environment-variables": { icon: IDIcon, activeColor: "text-environmentVariables" }, + branches: { icon: BranchEnvironmentIconSmall, activeColor: "text-previewBranches" }, + regions: { icon: GlobeLinesIcon, activeColor: "text-regions" }, + waitpoints: { icon: WaitpointTokenIcon, activeColor: "text-sky-500" }, + batches: { icon: BatchesIcon, activeColor: "text-batches" }, + "bulk-actions": { icon: ListCheckedIcon, activeColor: "text-text-bright" }, + apikeys: { icon: KeyIcon, activeColor: "text-text-bright" }, + alerts: { icon: BellIcon, activeColor: "text-text-bright" }, + concurrency: { icon: ConcurrencyIcon, activeColor: "text-text-bright" }, + limits: { icon: DialIcon, activeColor: "text-text-bright" }, + schedules: { icon: ClockIcon, activeColor: "text-schedules" }, + test: { icon: BeakerIcon, activeColor: "text-text-bright" }, + "project-settings": { icon: SlidersIcon, activeColor: "text-text-bright" }, + integrations: { icon: IntegrationsIcon, activeColor: "text-text-bright" }, + // Brand logos have no internal padding, so they render one step smaller (matching the org menu) + slack: { icon: SlackIcon, activeColor: "text-text-bright", className: "size-4" }, + vercel: { icon: VercelLogo, activeColor: "text-text-bright", className: "size-4" }, + project: { icon: FolderOpenIcon, activeColor: "text-text-bright" }, + "org-settings": { icon: SlidersIcon, activeColor: "text-text-bright" }, + team: { icon: UserGroupIcon, activeColor: "text-text-bright" }, + billing: { icon: CreditCardIcon, activeColor: "text-text-bright" }, + usage: { icon: UsageIcon, activeColor: "text-text-bright" }, + roles: { icon: RolesIcon, activeColor: "text-text-bright" }, + sso: { icon: PadlockIcon, activeColor: "text-text-bright" }, + "private-connections": { icon: ChainLinkIcon, activeColor: "text-text-bright" }, + account: { icon: AvatarCircleIcon, activeColor: "text-text-bright" }, + tokens: { icon: ShieldIcon, activeColor: "text-text-bright" }, + security: { icon: PadlockIcon, activeColor: "text-text-bright" }, + page: { icon: StarIcon, activeColor: "text-text-bright" }, +}; + +export function favoritePageIcon(iconKey: string | undefined): RenderIcon { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.icon : undefined) ?? StarIcon; +} + +export function favoritePageActiveColor(iconKey: string | undefined): string { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.activeColor : undefined) ?? "text-text-bright"; +} + +/** Size override for favorite icons that need one (see FAVORITE_PAGE_ICONS). */ +export function favoritePageIconClassName(iconKey: string | undefined): string | undefined { + return iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.className : undefined; +} + +/** Href for a favorite: its saved URL plus the marker param (see FAVORITE_SEARCH_PARAM). */ +export function favoriteLinkTo(favorite: FavoritePage): string { + const [path, search = ""] = favorite.url.split("?"); + const params = new URLSearchParams(search); + params.set(FAVORITE_SEARCH_PARAM, favorite.id); + return `${path}?${params.toString()}`; +} + +/** Pagination position params: never part of a favorite's identity (see favoritePageUrl). */ +const PAGINATION_PARAMS = ["cursor", "direction", "page"]; + +/** + * The canonical URL a favorite saves and matches against: the path and search minus the favorite + * marker (presentation-only) and the pagination position (cursors go stale, and page N of a view + * is not a different view). A favorite pins filters and tabs, never a transient page of them. + */ +export function favoritePageUrl(pathname: string, search: string): string { + const params = new URLSearchParams(search); + params.delete(FAVORITE_SEARCH_PARAM); + for (const param of PAGINATION_PARAMS) { + params.delete(param); + } + const result = params.toString(); + return pathname + (result.length > 0 ? `?${result}` : ""); +} + +/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate + * pagination stripping). */ +export function canonicalFavoriteUrl(url: string): string { + const [pathname, search = ""] = url.split("?"); + return favoritePageUrl(pathname, search); +} + +/** + * A favorite is active only while the URL is the view it saved: its marker param is present AND + * the canonical URL still matches. Changing any filter on the page diverges the URL from the + * favorite, so it deactivates (and the regular menu item takes over) — but paging within the + * view keeps it active, matching what the favorite pins. + */ +export function isFavoriteActive( + favorite: FavoritePage, + pathname: string, + search: string +): boolean { + return ( + new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id && + canonicalFavoriteUrl(favorite.url) === favoritePageUrl(pathname, search) + ); +} + +/** + * The id of the favorite driving the current view: the URL's marker param, but only when it + * belongs to one of the current user's favorites AND the URL still matches that favorite's + * saved view. A marker from someone else's shared link, a removed favorite's stale link, or a + * view whose filters have since been changed resolves to undefined, so regular menu + * highlighting applies. + */ +export function useActiveFavoriteId(): string | undefined { + const location = useLocation(); + const favorites = useFavorites(); + + const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); + if (!marker) return undefined; + const favorite = favorites.find((f) => f.id === marker); + if (!favorite) return undefined; + return isFavoriteActive(favorite, location.pathname, location.search) ? marker : undefined; +} + +type PageMeta = { + /** Key into FAVORITE_PAGE_ICONS. */ + icon: string; + /** The page's name as shown in navigation, e.g. "Queues". */ + name: string; + /** Singular label-prefix for detail pages, e.g. "Queue" -> "Queue: my-queue". */ + singular?: string; + /** + * Entity name taken from the URL, used verbatim as the label. For detail pages whose header + * title is composed JSX (task/agent pages render an icon + slug), so no plain-text title + * reaches the star. The icon already conveys the type, so no prefix is added. + */ + entityName?: string; +}; + +const ENV_PAGE_META: Record = { + "": { icon: "tasks", name: "Tasks", singular: "Task" }, + runs: { icon: "runs", name: "Runs", singular: "Run" }, + sessions: { icon: "sessions", name: "Sessions", singular: "Session" }, + prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" }, + models: { icon: "models", name: "Models", singular: "Model" }, + logs: { icon: "logs", name: "Logs" }, + errors: { icon: "errors", name: "Errors", singular: "Error" }, + query: { icon: "query", name: "Query" }, + queues: { icon: "queues", name: "Queues", singular: "Queue" }, + dashboards: { icon: "dashboards", name: "Dashboards", singular: "Dashboard" }, + deployments: { icon: "deployments", name: "Deploys", singular: "Deploy" }, + "environment-variables": { icon: "environment-variables", name: "Environment variables" }, + branches: { icon: "branches", name: "Preview branches", singular: "Branch" }, + regions: { icon: "regions", name: "Regions" }, + waitpoints: { icon: "waitpoints", name: "Waitpoint tokens", singular: "Waitpoint" }, + batches: { icon: "batches", name: "Batches", singular: "Batch" }, + "bulk-actions": { icon: "bulk-actions", name: "Bulk actions", singular: "Bulk action" }, + apikeys: { icon: "apikeys", name: "API keys" }, + alerts: { icon: "alerts", name: "Alerts", singular: "Alert" }, + concurrency: { icon: "concurrency", name: "Concurrency" }, + limits: { icon: "limits", name: "Limits" }, + schedules: { icon: "schedules", name: "Schedules", singular: "Schedule" }, + test: { icon: "test", name: "Test", singular: "Test" }, + // The playground route is the Test page too (its header reads "Test") + playground: { icon: "test", name: "Test", singular: "Test" }, +}; + +const ORG_SETTINGS_PAGE_META: Record = { + "": { icon: "org-settings", name: "Organization settings" }, + team: { icon: "team", name: "Team" }, + billing: { icon: "billing", name: "Billing" }, + "billing-limits": { icon: "alerts", name: "Billing alerts" }, + usage: { icon: "usage", name: "Usage" }, + roles: { icon: "roles", name: "Roles" }, + sso: { icon: "sso", name: "SSO" }, + "private-connections": { icon: "private-connections", name: "Private connections" }, + integrations: { icon: "integrations", name: "Integrations" }, + danger: { icon: "org-settings", name: "Danger zone" }, +}; + +const ACCOUNT_PAGE_META: Record = { + "": { icon: "account", name: "Profile" }, + tokens: { icon: "tokens", name: "Personal Access Tokens" }, + security: { icon: "security", name: "Security" }, +}; + +/** Best-effort icon + name for any dashboard page, derived from its URL shape. */ +export function resolvePageMeta(pathname: string): PageMeta { + const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/); + if (envMatch) { + const segments = (envMatch[1] ?? "").split("/").filter(Boolean); + const first = segments[0] ?? ""; + if (first === "settings") { + return segments[1] === "integrations" + ? { icon: "integrations", name: "Integrations" } + : { icon: "project-settings", name: "Project settings" }; + } + if (first === "dashboards") { + // The built-in metric dashboards and custom dashboards have their own identities (and icons) + if (segments[1] === "overview") return { icon: "run-metrics", name: "Run metrics" }; + if (segments[1] === "llm") return { icon: "ai-metrics", name: "AI metrics" }; + if (segments[1] === "custom") { + return { icon: "custom-dashboard", name: "Dashboards", singular: "Dashboard" }; + } + } + + // Task detail: /tasks/{standard|scheduled}/{slug}. The slug is the only place the task name + // exists (the page header renders it as JSX), so it becomes the label. + if (first === "tasks" && segments[2]) { + const slug = decodeURIComponent(segments[2]); + return segments[1] === "scheduled" + ? { icon: "task-scheduled", name: "Scheduled task", entityName: slug } + : { icon: "task-standard", name: "Standard task", entityName: slug }; + } + + // Agent tasks live outside /tasks: /agents/{slug} + if (first === "agents") { + return segments[1] + ? { + icon: "task-agent", + name: "Agent task", + entityName: decodeURIComponent(segments[1]), + } + : { icon: "task-agent", name: "Agents" }; + } + + return ENV_PAGE_META[first] ?? { icon: "page", name: "Page" }; + } + + const orgSettingsMatch = pathname.match(/^\/orgs\/[^/]+\/settings(?:\/([^?]*))?$/); + if (orgSettingsMatch) { + const segments = (orgSettingsMatch[1] ?? "").split("/").filter(Boolean); + if (segments[0] === "integrations" && segments[1] === "slack") { + return { icon: "slack", name: "Slack integration" }; + } + if (segments[0] === "integrations" && segments[1] === "vercel") { + return { icon: "vercel", name: "Vercel integration" }; + } + return ORG_SETTINGS_PAGE_META[segments[0] ?? ""] ?? { icon: "org-settings", name: "Settings" }; + } + + if (/^\/orgs\/[^/]+\/projects\/[^/]+/.test(pathname)) { + return { icon: "project", name: "Project" }; + } + + if (/^\/orgs\/[^/]+/.test(pathname)) { + return { icon: "project", name: "Projects" }; + } + + const accountMatch = pathname.match(/^\/account(?:\/([^?]*))?$/); + if (accountMatch) { + const segments = (accountMatch[1] ?? "").split("/").filter(Boolean); + return ACCOUNT_PAGE_META[segments[0] ?? ""] ?? { icon: "account", name: "Account" }; + } + + return { icon: "page", name: "Page" }; +} + +const MAX_LABEL_LENGTH = 50; + +function truncateLabel(label: string): string { + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label; +} + +/** + * Short id for a detail page whose last URL segment is a friendly id ("run_cmryyza…05hrqq9n"). + * Uses the same 8-character tail the dashboard tables display, so the label matches what the + * user sees elsewhere. + */ +function detailIdFromPath(pathname: string): string | undefined { + const segments = pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + if (last && /^(run|batch|session|deployment|schedule|waitpoint)_[a-z0-9]{8,}$/i.test(last)) { + return last.slice(-8); + } + return undefined; +} + +/** Task type filter on the Tasks page (?types=…) becomes the whole favorite name. */ +const TASK_TYPE_LABELS: Record = { + AGENT: "Agent tasks", + STANDARD: "Standard tasks", + SCHEDULED: "Scheduled tasks", +}; + +/** "COMPLETED_SUCCESSFULLY" -> "Completed successfully", "history" -> "History". */ +function humanizeValue(value: string): string { + const lowered = value.toLowerCase().replaceAll("_", " "); + return lowered.charAt(0).toUpperCase() + lowered.slice(1); +} + +/** Pagination/UI-state params that never describe what the user filtered. */ +const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, ...PAGINATION_PARAMS, "span"]; + +/** + * Summarize a filtered view's search params into a short, selective descriptor for the favorite + * label ("Completed successfully, last 7d +2"). The best-known filters are named (at most two); + * everything else only counts toward a "+N" so heavily filtered views stay readable. + */ +function describeFilters(search: string): string | undefined { + const params = new URLSearchParams(search); + for (const param of NON_FILTER_PARAMS) { + params.delete(param); + } + + const parts: string[] = []; + const consumed = new Set(); + + const take = (key: string, describe: (values: string[]) => string | undefined) => { + const values = params.getAll(key).filter((value) => value.length > 0); + if (values.length === 0) return; + consumed.add(key); + const described = describe(values); + if (described) parts.push(described); + }; + + // Priority order: the filters most likely to identify the view come first + take("statuses", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} statuses`)); + take("levels", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} levels`)); + take("tasks", (v) => (v.length === 1 ? v[0] : `${v.length} tasks`)); + take("queues", (v) => (v.length === 1 ? v[0].replace(/^task\//, "") : `${v.length} queues`)); + take("tags", (v) => (v.length === 1 ? v[0] : `${v.length} tags`)); + take("period", (v) => `last ${v[0]}`); + if (params.has("from") || params.has("to")) { + consumed.add("from"); + consumed.add("to"); + parts.push("custom range"); + } + take("versions", (v) => (v.length === 1 ? v[0] : `${v.length} versions`)); + take("machines", (v) => (v.length === 1 ? v[0] : `${v.length} machines`)); + take("tab", (v) => humanizeValue(v[0])); + // The runs list appends rootOnly=false by default; only the non-default value is a filter + take("rootOnly", (v) => (v[0] === "true" ? "root only" : undefined)); + + const remaining = new Set([...params.keys()].filter((key) => !consumed.has(key))).size; + + const MAX_NAMED_PARTS = 2; + const shown = parts.slice(0, MAX_NAMED_PARTS); + const extra = parts.length - shown.length + remaining; + + if (shown.length === 0) { + return extra > 0 ? `${extra} filter${extra === 1 ? "" : "s"}` : undefined; + } + return shown.join(", ") + (extra > 0 ? ` +${extra}` : ""); +} + +/** + * Compose the default side menu label for a favorited page. Plain list pages keep their nav + * name ("Queues"); detail pages get an identifying prefix ("Queue: email-queue", or the short + * id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs: + * Completed successfully, last 7d"). Users can always rename. + */ +export function buildFavoriteLabel( + pathname: string, + search: string, + pageTitle: string | undefined +): string { + const meta = resolvePageMeta(pathname); + const title = pageTitle?.trim(); + const prefix = meta.singular ?? meta.name; + + // Generic titles ("Runs", "Run") identify nothing on their own; prefer ids/filters from the URL + const isGenericTitle = + !title || + title.toLowerCase() === meta.name.toLowerCase() || + title.toLowerCase() === prefix.toLowerCase(); + + if (isGenericTitle) { + // Named entity from the URL (task/agent slug) is the label on its own; its icon carries the type + if (meta.entityName) return truncateLabel(meta.entityName); + + // The Tasks page filtered to a single task type takes that type as the whole name + if (meta.icon === "tasks") { + const types = new URLSearchParams(search).getAll("types"); + if (types.length === 1 && TASK_TYPE_LABELS[types[0]]) { + return TASK_TYPE_LABELS[types[0]]; + } + } + + const detailId = detailIdFromPath(pathname); + if (detailId) return `${prefix}: ${detailId}`; + + const filters = describeFilters(search); + return truncateLabel(filters ? `${meta.name}: ${filters}` : meta.name); + } + + const label = title.toLowerCase().startsWith(prefix.toLowerCase()) + ? title + : `${prefix}: ${title}`; + return truncateLabel(label); +} + +/** + * The user's favorited pages with any in-flight mutations applied, so the star button and the + * side menu section update instantly and stay in sync while the server round-trip completes. + */ +export function useFavorites(): FavoritePage[] { + const user = useOptionalUser(); + const fetchers = useFetchers(); + + let favorites = user?.dashboardPreferences.sideMenu?.favorites ?? []; + + for (const fetcher of fetchers) { + if (fetcher.formAction !== FAVORITES_ACTION_PATH || !fetcher.formData) continue; + + const intent = fetcher.formData.get("intent"); + const id = fetcher.formData.get("id"); + if (typeof id !== "string") continue; + + switch (intent) { + case "add": { + const url = fetcher.formData.get("url"); + const label = fetcher.formData.get("label"); + const icon = fetcher.formData.get("icon"); + if (typeof url !== "string" || typeof label !== "string") break; + if (!favorites.some((f) => f.url === url)) { + // Newest favorites go to the top of the section (matches addFavorite server-side) + favorites = [ + { id, url, label, icon: typeof icon === "string" ? icon : undefined }, + ...favorites, + ]; + } + break; + } + case "remove": { + favorites = favorites.filter((f) => f.id !== id); + break; + } + case "rename": { + const label = fetcher.formData.get("label"); + if (typeof label !== "string") break; + favorites = favorites.map((f) => (f.id === id ? { ...f, label } : f)); + break; + } + } + } + + return favorites; +} diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index bb245f4bcb..508c412117 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -2,6 +2,7 @@ import { z } from "zod"; // Valid section IDs that can have their collapsed state toggled export const SideMenuSectionIdSchema = z.enum([ + "favorites", "ai", "manage", "metrics", @@ -12,3 +13,58 @@ export const SideMenuSectionIdSchema = z.enum([ // Inferred type from the schema export type SideMenuSectionId = z.infer; + +// Size popover items to match the side-menu items, overriding the smaller small-menu-item +// defaults via tailwind-merge; icon carries the default dimmed color. +export const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; +export const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + +/** Default top-to-bottom order of the customizable side menu sections. */ +export const DEFAULT_SECTION_ORDER: SideMenuSectionId[] = [ + "favorites", + "ai", + "metrics", + "deployments", + "manage", +]; + +/** + * Order entries by a saved preference. Entries missing from the saved order (e.g. a section or + * item that shipped after the user customized) are inserted at their default position relative + * to the entries around them, not dumped at the end — so "Favorites" still lands above "AI" for + * users who saved an order before favorites existed. + */ +export function orderByPreference( + entries: T[], + savedOrder: string[] | undefined +): T[] { + if (!savedOrder || savedOrder.length === 0) return entries; + + const defaultIndex = new Map(entries.map((entry, index) => [entry.id, index])); + // Set-dedupe: a corrupted saved order with duplicate ids must not render an entry twice + const orderedIds = [...new Set(savedOrder.filter((id) => defaultIndex.has(id)))]; + const missingIds = entries.map((entry) => entry.id).filter((id) => !orderedIds.includes(id)); + + for (const id of missingIds) { + const idDefault = defaultIndex.get(id) ?? 0; + let insertAt = orderedIds.length; + for (let i = 0; i < orderedIds.length; i++) { + if ((defaultIndex.get(orderedIds[i]) ?? 0) > idDefault) { + insertAt = i; + break; + } + } + orderedIds.splice(insertAt, 0, id); + } + + const byId = new Map(entries.map((entry) => [entry.id, entry])); + return orderedIds.map((id) => byId.get(id)!); +} + +/** Effective hidden state for a menu item: the user's override wins, else the item's default. */ +export function isItemHidden( + item: { id: string; defaultHidden?: boolean }, + hiddenItems: Record | undefined +): boolean { + return hiddenItems?.[item.id] ?? item.defaultHidden ?? false; +} diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index a7c68f059f..691b9323ba 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -7,6 +7,7 @@ import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; import { SimpleTooltip } from "./Tooltip"; import { DashboardAgentLauncher } from "../dashboard-agent/dashboardAgentLauncher"; +import { FavoritePageButton } from "../navigation/FavoritePageButton"; type WithChildren = { children: React.ReactNode; @@ -46,8 +47,10 @@ type PageTitleProps = { }; export function PageTitle({ title, backButton, accessory }: PageTitleProps) { + const titleText = typeof title === "string" ? title : undefined; + return ( -
+
{backButton && (
)} {title} - {accessory !== undefined && - (typeof accessory === "string" ? ( - } - content={accessory} - className="max-w-xs" - disableHoverableContent - /> - ) : ( - accessory - ))} + {accessory !== undefined && ( + // ml-px optically evens the accessory against the title's tight text edge + + {typeof accessory === "string" ? ( + } + content={accessory} + className="max-w-xs" + disableHoverableContent + /> + ) : ( + accessory + )} + + )} + {/* -ml-1 pulls the star's button box near-flush: its inner padding then provides the + visual gap, matching the title-to-accessory spacing while hovered */} +
); } 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 8aa6427afb..153004813a 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 @@ -48,7 +48,8 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.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"; +import { removeFavoritesByUrlSubstring } from "~/services/dashboardPreferences.server"; +import { requireUser } from "~/services/session.server"; import { EnvironmentParamSchema, queryPath, @@ -115,10 +116,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params); - const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); if (!project) { throw new Response("Project not found", { status: 404 }); } @@ -144,6 +145,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { where: { id: dashboard.id }, }); + // Drop any favorites pointing at this dashboard so the side menu doesn't keep a dead link + await removeFavoritesByUrlSubstring({ + user, + substring: `/dashboards/custom/${dashboard.friendlyId}`, + }); + return redirectWithSuccessMessage( v3BuiltInDashboardPath( { slug: organizationSlug }, diff --git a/apps/webapp/app/routes/resources.preferences.favorites.tsx b/apps/webapp/app/routes/resources.preferences.favorites.tsx new file mode 100644 index 0000000000..071d264493 --- /dev/null +++ b/apps/webapp/app/routes/resources.preferences.favorites.tsx @@ -0,0 +1,80 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { + addFavorite, + removeFavorite, + renameFavorite, +} from "~/services/dashboardPreferences.server"; +import { logger } from "~/services/logger.server"; +import { requireUser } from "~/services/session.server"; + +const FavoriteLabel = z + .string() + .transform((value) => value.trim()) + .pipe(z.string().min(1).max(64)); + +const RequestSchema = z.discriminatedUnion("intent", [ + z.object({ + intent: z.literal("add"), + id: z.string().min(1).max(64), + // App-relative URL only ("/..." but not protocol-relative "//...") + url: z + .string() + .min(1) + .max(2048) + .refine((url) => url.startsWith("/") && !url.startsWith("//"), { + message: "URL must be app-relative", + }), + label: FavoriteLabel, + icon: z + .string() + .regex(/^[a-z0-9-]+$/) + .max(64) + .optional(), + }), + z.object({ + intent: z.literal("remove"), + id: z.string().min(1).max(64), + }), + z.object({ + intent: z.literal("rename"), + id: z.string().min(1).max(64), + label: FavoriteLabel, + }), +]); + +export async function action({ request }: ActionFunctionArgs) { + const user = await requireUser(request); + + const formData = await request.formData(); + const result = RequestSchema.safeParse(Object.fromEntries(formData)); + + if (!result.success) { + return json({ success: false, error: "Invalid request data" }, { status: 400 }); + } + + // Errors come back as a response (never a throw, which would escalate a preferences write to + // the error boundary); the side menu's optimistic entries revert when the fetcher settles. + try { + switch (result.data.intent) { + case "add": { + const { id, url, label, icon } = result.data; + await addFavorite({ user, favorite: { id, url, label, icon } }); + break; + } + case "remove": { + await removeFavorite({ user, id: result.data.id }); + break; + } + case "rename": { + await renameFavorite({ user, id: result.data.id, label: result.data.label }); + break; + } + } + } catch (error) { + logger.error("Failed to update favorites", { error: String(error) }); + return json({ success: false, error: "Failed to save preferences" }, { status: 500 }); + } + + return json({ success: true }); +} diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx index 4e43269d0e..35b11a4cf1 100644 --- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx +++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx @@ -4,7 +4,12 @@ import { SideMenuSectionIdSchema, type SideMenuSectionId, } from "~/components/navigation/sideMenuTypes"; -import { updateItemOrder, updateSideMenuPreferences } from "~/services/dashboardPreferences.server"; +import { + updateItemOrder, + updateSideMenuCustomization, + updateSideMenuPreferences, +} from "~/services/dashboardPreferences.server"; +import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; // Transforms form data string "true"/"false" to boolean, or undefined if not present @@ -22,11 +27,32 @@ const RequestSchema = z.object({ organizationId: z.string().optional(), listId: z.string().optional(), itemOrder: z.string().optional(), // JSON-encoded string[] + customization: z.string().optional(), // JSON-encoded CustomizationSchema +}); + +// Payload of the "Customize sidebar" modal. For the nullable fields, null resets to default and +// an absent field leaves the stored value unchanged. +const CustomizationSchema = z.object({ + sectionOrder: z.array(z.string().max(64)).max(50).nullish(), + hiddenItems: z.record(z.string().max(64), z.boolean()).nullish(), + sectionItemOrder: z.record(z.string().max(64), z.array(z.string().max(64)).max(100)).nullish(), + favorites: z + .array(z.object({ id: z.string().max(64), label: z.string().max(64) })) + .max(100) + .optional(), + removedFavoriteIds: z.array(z.string().max(64)).max(100).optional(), }); export async function action({ request }: ActionFunctionArgs) { const user = await requireUser(request); + // Every writer below deliberately skips impersonated sessions so an admin's browsing can't + // rewrite the customer's saved layout. That skip is a no-op, not a failed save, so report it as + // success: it must not reach the callers that surface write failures to the user. + if (user.isImpersonating) { + return json({ success: true }); + } + const formData = await request.formData(); const rawData = Object.fromEntries(formData); @@ -35,6 +61,42 @@ export async function action({ request }: ActionFunctionArgs) { return json({ success: false, error: "Invalid request data" }, { status: 400 }); } + // Handle a "Customize sidebar" modal submit + if (result.data.customization) { + let parsed: unknown; + try { + parsed = JSON.parse(result.data.customization); + } catch { + parsed = null; + } + const customizationResult = CustomizationSchema.safeParse(parsed); + if (!customizationResult.success) { + return json({ success: false, error: "Invalid request data" }, { status: 400 }); + } + const { sectionOrder, hiddenItems, sectionItemOrder, favorites, removedFavoriteIds } = + customizationResult.data; + // The modal keeps its "Confirm" pending until this responds, so failures must come back as a + // response (never a throw, which would escalate a preferences write to the error boundary). + try { + const updated = await updateSideMenuCustomization({ + user, + sectionOrder, + hiddenItems, + sectionItemOrder, + favorites, + removedFavoriteIds, + }); + // undefined means nothing was written (impersonating, or the user row is gone) + if (!updated) { + return json({ success: false, error: "Failed to save preferences" }, { status: 500 }); + } + } catch (error) { + logger.error("Failed to save sidebar customization", { error: String(error) }); + return json({ success: false, error: "Failed to save preferences" }, { status: 500 }); + } + return json({ success: true }); + } + // Handle item order update if (result.data.organizationId && result.data.listId && result.data.itemOrder) { let parsed: unknown; diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index a8b9149eff..9f5f84fe2e 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -1,8 +1,21 @@ import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $transaction, prisma } from "~/db.server"; import { logger } from "./logger.server"; import { type UserFromSession } from "./session.server"; +const FavoritePage = z.object({ + /** Stable id, generated client-side when the page is favorited. */ + id: z.string(), + /** App-relative URL including any search params (filters, tabs). */ + url: z.string(), + /** Display label shown in the side menu; user-renamable. */ + label: z.string(), + /** Key into the favorite page icon registry. */ + icon: z.string().optional(), +}); + +export type FavoritePage = z.infer; + const SideMenuPreferences = z.object({ isCollapsed: z.boolean().default(false), /** Expanded side menu width in px, set by the resize handle. */ @@ -18,6 +31,14 @@ const SideMenuPreferences = z.object({ }) ) .optional(), + /** Pages the user favorited, in display order. */ + favorites: z.array(FavoritePage).optional(), + /** Custom top-to-bottom order of side menu sections (section ids). */ + sectionOrder: z.array(z.string()).optional(), + /** Per-item visibility overrides (item id -> hidden). Items absent fall back to their default. */ + hiddenItems: z.record(z.string(), z.boolean()).optional(), + /** Custom item order within a section (section id -> item ids). */ + sectionItemOrder: z.record(z.string(), z.array(z.string())).optional(), }); export type SideMenuPreferences = z.infer; @@ -59,6 +80,51 @@ export function getDashboardPreferences(data?: any | null): DashboardPreferences return result.data; } +/** + * Every preference writer is a read-modify-write over one JSON column, and several fire + * concurrently (debounced collapse/width, favorite toggles, the customize modal, dashboard + * reorders). Each write re-reads the row under a FOR UPDATE lock so concurrent writers + * serialize instead of clobbering each other's fields with stale reads — without the lock, a + * debounced collapse write could resurrect customizations the modal's Reset just cleared. + * + * Return undefined from `mutate` to skip the write (no-op update). + */ +async function mutateDashboardPreferences( + userId: string, + mutate: (current: DashboardPreferences) => DashboardPreferences | undefined +) { + return await $transaction( + prisma, + "mutateDashboardPreferences", + async (tx) => { + const rows = await tx.$queryRaw>` + SELECT "dashboardPreferences" FROM "User" WHERE id = ${userId} FOR UPDATE + `; + if (rows.length === 0) { + return undefined; + } + + const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences)); + if (!updated) { + return undefined; + } + + return await tx.user.update({ + where: { + id: userId, + }, + data: { + dashboardPreferences: updated, + }, + }); + }, + // Concurrent writers queue on the row lock, so under load (several debounced writes plus a + // revalidation burst) a transaction can time out acquiring a connection or the lock; those + // codes are retriable and preference writes are idempotent. + { maxRetries: 3 } + ); +} + export async function updateCurrentProjectEnvironmentId({ user, projectId, @@ -72,7 +138,9 @@ export async function updateCurrentProjectEnvironmentId({ return; } - //only update if the existing preferences are different + // Fast path: this runs on nearly every navigation (env layout loader), so skip the locked + // transaction when the session snapshot already matches. The in-transaction check below stays + // authoritative for the rare stale-snapshot case. if ( user.dashboardPreferences.currentProjectId === projectId && user.dashboardPreferences.projects[projectId]?.currentEnvironment?.id === environmentId @@ -80,26 +148,26 @@ export async function updateCurrentProjectEnvironmentId({ return; } - //ok we need to update the preferences - const updatedPreferences: DashboardPreferences = { - ...user.dashboardPreferences, - currentProjectId: projectId, - projects: { - ...user.dashboardPreferences.projects, - [projectId]: { - ...user.dashboardPreferences.projects[projectId], - currentEnvironment: { id: environmentId }, - }, - }, - }; + return mutateDashboardPreferences(user.id, (prefs) => { + //only update if the existing preferences are different + if ( + prefs.currentProjectId === projectId && + prefs.projects[projectId]?.currentEnvironment?.id === environmentId + ) { + return undefined; + } - return prisma.user.update({ - where: { - id: user.id, - }, - data: { - dashboardPreferences: updatedPreferences, - }, + return { + ...prefs, + currentProjectId: projectId, + projects: { + ...prefs.projects, + [projectId]: { + ...prefs.projects[projectId], + currentEnvironment: { id: environmentId }, + }, + }, + }; }); } @@ -108,19 +176,10 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) { return; } - const updatedPreferences: DashboardPreferences = { - ...user.dashboardPreferences, + return mutateDashboardPreferences(user.id, (prefs) => ({ + ...prefs, currentProjectId: undefined, - }; - - return prisma.user.update({ - where: { - id: user.id, - }, - data: { - dashboardPreferences: updatedPreferences, - }, - }); + })); } export async function updateSideMenuPreferences({ @@ -140,48 +199,234 @@ export async function updateSideMenuPreferences({ return; } - // Parse with schema to apply defaults, then overlay any new values - const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); + return mutateDashboardPreferences(user.id, (prefs) => { + // Parse with schema to apply defaults, then overlay any new values + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + + // Build the updated collapsedSections map + let updatedCollapsedSections = { ...currentSideMenu.collapsedSections }; + + if (sectionCollapsed) { + updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed; + } + + const updatedSideMenu = SideMenuPreferences.parse({ + ...currentSideMenu, + ...(isCollapsed !== undefined && { isCollapsed }), + ...(width !== undefined && { width }), + collapsedSections: updatedCollapsedSections, + }); + + // Only update if something changed + const hasCollapsedSectionsChanged = + JSON.stringify(updatedSideMenu.collapsedSections) !== + JSON.stringify(currentSideMenu.collapsedSections); + + if ( + updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && + updatedSideMenu.width === currentSideMenu.width && + !hasCollapsedSectionsChanged + ) { + return undefined; + } - // Build the updated collapsedSections map - let updatedCollapsedSections = { ...currentSideMenu.collapsedSections }; + return { ...prefs, sideMenu: updatedSideMenu }; + }); +} - if (sectionCollapsed) { - updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed; +/** The most favorites a user can save; a sanity cap, not a product limit. */ +const MAX_FAVORITES = 50; + +export async function addFavorite({ + user, + favorite, +}: { + user: UserFromSession; + favorite: FavoritePage; +}) { + if (user.isImpersonating) { + return; } - const updatedSideMenu = SideMenuPreferences.parse({ - ...currentSideMenu, - ...(isCollapsed !== undefined && { isCollapsed }), - ...(width !== undefined && { width }), - collapsedSections: updatedCollapsedSections, + return mutateDashboardPreferences(user.id, (prefs) => { + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + + // The star is a toggle keyed on the exact URL, so an existing entry means we're already done + if (favorites.some((f) => f.url === favorite.url)) { + return undefined; + } + + if (favorites.length >= MAX_FAVORITES) { + return undefined; + } + + // Newest favorites go to the top of the section + return { + ...prefs, + sideMenu: { ...currentSideMenu, favorites: [favorite, ...favorites] }, + }; }); +} - // Only update if something changed - const hasCollapsedSectionsChanged = - JSON.stringify(updatedSideMenu.collapsedSections) !== - JSON.stringify(currentSideMenu.collapsedSections); +export async function removeFavorite({ user, id }: { user: UserFromSession; id: string }) { + if (user.isImpersonating) { + return; + } - if ( - updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && - updatedSideMenu.width === currentSideMenu.width && - !hasCollapsedSectionsChanged - ) { + return mutateDashboardPreferences(user.id, (prefs) => { + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + const remaining = favorites.filter((f) => f.id !== id); + + if (remaining.length === favorites.length) { + return undefined; + } + + return { + ...prefs, + sideMenu: { + ...currentSideMenu, + favorites: remaining.length > 0 ? remaining : undefined, + }, + }; + }); +} + +/** + * Remove any favorites whose URL contains the given substring. Used when the favorited entity + * itself is deleted (e.g. a custom dashboard's friendly id) so the side menu doesn't keep a + * dead link. + */ +export async function removeFavoritesByUrlSubstring({ + user, + substring, +}: { + user: UserFromSession; + substring: string; +}) { + if (user.isImpersonating) { return; } - const updatedPreferences: DashboardPreferences = { - ...user.dashboardPreferences, - sideMenu: updatedSideMenu, - }; + return mutateDashboardPreferences(user.id, (prefs) => { + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + const remaining = favorites.filter((favorite) => !favorite.url.includes(substring)); - return prisma.user.update({ - where: { - id: user.id, - }, - data: { - dashboardPreferences: updatedPreferences, - }, + if (remaining.length === favorites.length) { + return undefined; + } + + return { + ...prefs, + sideMenu: { + ...currentSideMenu, + favorites: remaining.length > 0 ? remaining : undefined, + }, + }; + }); +} + +export async function renameFavorite({ + user, + id, + label, +}: { + user: UserFromSession; + id: string; + label: string; +}) { + if (user.isImpersonating) { + return; + } + + return mutateDashboardPreferences(user.id, (prefs) => { + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + + const favorite = favorites.find((f) => f.id === id); + if (!favorite || favorite.label === label) { + return undefined; + } + + return { + ...prefs, + sideMenu: { + ...currentSideMenu, + favorites: favorites.map((f) => (f.id === id ? { ...f, label } : f)), + }, + }; + }); +} + +export async function updateSideMenuCustomization({ + user, + sectionOrder, + hiddenItems, + sectionItemOrder, + favorites, + removedFavoriteIds, +}: { + user: UserFromSession; + /** undefined = leave unchanged, null = reset to default */ + sectionOrder?: string[] | null; + /** undefined = leave unchanged, null = reset to default */ + hiddenItems?: Record | null; + /** undefined = leave unchanged, null = reset to default */ + sectionItemOrder?: Record | null; + /** Full favorites arrangement: new order + labels. undefined = leave unchanged. */ + favorites?: Array<{ id: string; label: string }>; + /** Favorites deleted from the customize modal. */ + removedFavoriteIds?: string[]; +}) { + if (user.isImpersonating) { + return; + } + + return mutateDashboardPreferences(user.id, (prefs) => { + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + const next: SideMenuPreferences = { ...currentSideMenu }; + + if (sectionOrder !== undefined) { + next.sectionOrder = sectionOrder && sectionOrder.length > 0 ? sectionOrder : undefined; + } + + if (hiddenItems !== undefined) { + next.hiddenItems = + hiddenItems && Object.keys(hiddenItems).length > 0 ? hiddenItems : undefined; + } + + if (sectionItemOrder !== undefined) { + next.sectionItemOrder = + sectionItemOrder && Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : undefined; + } + + if (favorites !== undefined || removedFavoriteIds !== undefined) { + const removed = new Set(removedFavoriteIds ?? []); + const current = (currentSideMenu.favorites ?? []).filter((f) => !removed.has(f.id)); + const byId = new Map(current.map((f) => [f.id, f])); + const rearranged: FavoritePage[] = []; + + for (const { id, label } of favorites ?? []) { + const existing = byId.get(id); + if (!existing) continue; + const trimmed = label.trim(); + rearranged.push({ ...existing, label: trimmed.length > 0 ? trimmed : existing.label }); + byId.delete(id); + } + + // Favorites the payload didn't mention (e.g. added mid-edit) keep their place at the end + for (const favorite of current) { + if (byId.has(favorite.id)) { + rearranged.push(favorite); + } + } + + next.favorites = rearranged.length > 0 ? rearranged : undefined; + } + + return { ...prefs, sideMenu: SideMenuPreferences.parse(next) }; }); } @@ -209,34 +454,24 @@ export async function updateItemOrder({ return; } - const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); - const currentOrg = currentSideMenu.organizations?.[organizationId]; - - const updatedSideMenu = SideMenuPreferences.parse({ - ...currentSideMenu, - organizations: { - ...currentSideMenu.organizations, - [organizationId]: { - ...currentOrg, - orderedItems: { - ...currentOrg?.orderedItems, - [listId]: order, + return mutateDashboardPreferences(user.id, (prefs) => { + const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {}); + const currentOrg = currentSideMenu.organizations?.[organizationId]; + + const updatedSideMenu = SideMenuPreferences.parse({ + ...currentSideMenu, + organizations: { + ...currentSideMenu.organizations, + [organizationId]: { + ...currentOrg, + orderedItems: { + ...currentOrg?.orderedItems, + [listId]: order, + }, }, }, - }, - }); + }); - const updatedPreferences: DashboardPreferences = { - ...user.dashboardPreferences, - sideMenu: updatedSideMenu, - }; - - return prisma.user.update({ - where: { - id: user.id, - }, - data: { - dashboardPreferences: updatedPreferences, - }, + return { ...prefs, sideMenu: updatedSideMenu }; }); } From 73eb4c5c1623717502622c135de9004f97b7e6c9 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 16:34:44 +0100 Subject: [PATCH 18/23] feat(webapp): Improve the Integrations page layout (#4379) ## Summary The project Integrations page now uses the same settings layout as the org SSO page: a centered column of titled rows with dividers, instead of headings over bordered boxes. GitHub, Vercel and build settings read as one consistent list, and the page titles itself "Integrations". Confirmations persist rather than vanishing once you move past them (`GitHub app: Installed`, `Vercel project: Connected`), plan-gated rows offer an Upgrade button instead of a dead toggle, a disabled toggle explains why in place and highlights the control that unlocks it, and warnings are rows with a hazard icon and their recovery action on the right. Copy throughout leads with the outcome instead of restating the field label. Two fixes along the way: a nested `
` in the Vercel panel that failed hydration and silently truncated the page, and every settings row carrying a few pixels more space above its title than below its description. ### Before CleanShot 2026-07-26 at 21 56
42@2x ### After CleanShot 2026-07-26 at 19 14
28@2x --- .../app/assets/icons/PadlockRoundedIcon.tsx | 34 + .../app/components/BlankStatePanels.tsx | 14 +- .../integrations/VercelBuildSettings.tsx | 507 +++++++++----- .../integrations/VercelOnboardingModal.tsx | 72 +- .../app/components/primitives/Buttons.tsx | 11 + .../app/components/primitives/Select.tsx | 7 +- .../components/primitives/SettingsLayout.tsx | 42 +- .../route.tsx | 339 +++++----- .../route.tsx | 16 +- ...cts.$projectParam.env.$envParam.github.tsx | 515 ++++++++++----- ...cts.$projectParam.env.$envParam.vercel.tsx | 618 +++++++++--------- .../app/routes/storybook.buttons/route.tsx | 12 + .../app/routes/storybook.icons/route.tsx | 2 + apps/webapp/app/tailwind.css | 10 + 14 files changed, 1351 insertions(+), 848 deletions(-) create mode 100644 apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx diff --git a/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx b/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx new file mode 100644 index 0000000000..56f2543fc3 --- /dev/null +++ b/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx @@ -0,0 +1,34 @@ +export function PadlockRoundedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 5d166158e0..5fde85cc05 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -701,14 +701,12 @@ function DeploymentOnboardingSteps() { Deploy automatically with every push. Read the{" "} full guide. -
- -
+ diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index 1abd54b012..fc45d2f601 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -1,5 +1,12 @@ import { Switch } from "~/components/primitives/Switch"; +import { LinkButton } from "~/components/primitives/Buttons"; import { Label } from "~/components/primitives/Label"; +import { + SettingsRow, + SettingsRowDescription, + SettingsRowTitle, +} from "~/components/primitives/SettingsLayout"; +import { cn } from "~/utils/cn"; import { Hint } from "~/components/primitives/Hint"; import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; @@ -31,6 +38,7 @@ type BuildSettingsFieldsProps = { currentTriggerVersionFetchFailed?: boolean; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ hideSectionToggles?: boolean; + layout?: "settings" | "card"; }; export function BuildSettingsFields({ @@ -48,187 +56,326 @@ export function BuildSettingsFields({ currentTriggerVersion, currentTriggerVersionFetchFailed, hideSectionToggles, + layout = "card", }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s)); + const envVarSections = + layout === "settings" ? ( + <> + + Configure env vars + + ) : undefined + } + /> + {availableEnvSlugs.map((slug) => ( + { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> + ))} + + + {availableEnvSlugs.map((slug) => { + const pullOff = !pullEnvVarsBeforeBuild.includes(slug); + return ( + { + onDiscoverEnvVarsChange( + checked ? [...discoverEnvVars, slug] : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> + ); + })} + + ) : null; + + const atomicSections = + layout === "settings" ? ( + <> + { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> + } + > +
+ Atomic deployments + + Promotes your Vercel deployment and your tasks together in Production, so your app + never runs against a mismatched task version. Requires turning off "Auto-assign Custom + Production Domains" on your Vercel project, which Trigger.dev does for you.{" "} + + Learn more + + . + + {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in + Vercel production. + + )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read TRIGGER_VERSION{" "} + from Vercel. Check the Vercel dashboard to confirm the production pin. + + )} +
+
+ + {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && ( + + } + /> + )} + + ) : null; + return ( <> + {envVarSections} {/* Pull env vars before build */} -
-
-
- - {!hideSectionToggles && availableEnvSlugs.length > 1 && ( - 0 && - enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) - } - onCheckedChange={(checked) => { - onPullEnvVarsChange(checked ? [...enabledSlugs] : []); - }} - /> - )} -
- - Select which environments should pull environment variables from Vercel before each - build.{" "} - {envVarsConfigLink && ( - <> - Configure which variables to pull. - - )} - -
-
- {availableEnvSlugs.map((slug) => { - const envType = envSlugToType(slug); - const disabled = isSlugDisabled(slug); - const disabledReason = disabledEnvSlugs?.[slug]; - const row = ( -
-
- - - {environmentFullTitle({ type: envType })} - -
+ {layout === "card" && ( +
+
+
+ + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( 0 && + enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) + } onCheckedChange={(checked) => { - onPullEnvVarsChange( - checked - ? [...pullEnvVarsBeforeBuild, slug] - : pullEnvVarsBeforeBuild.filter((s) => s !== slug) - ); + onPullEnvVarsChange(checked ? [...enabledSlugs] : []); }} /> -
- ); - if (disabled && disabledReason) { - return ; - } - return row; - })} + )} +
+ + Select which environments should pull environment variables from Vercel before each + build.{" "} + {envVarsConfigLink && ( + <> + Configure which variables to pull. + + )} + +
+
+ {availableEnvSlugs.map((slug) => { + const envType = envSlugToType(slug); + const disabled = isSlugDisabled(slug); + const disabledReason = disabledEnvSlugs?.[slug]; + const row = ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> +
+ ); + if (disabled && disabledReason) { + return ( + + ); + } + return row; + })} +
-
+ )} {/* Discover new env vars */} -
-
-
- - {!hideSectionToggles && availableEnvSlugs.length > 1 && ( - 0 && - enabledSlugs.every( - (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) - ) && - enabledSlugs.some((s) => discoverEnvVars.includes(s)) - } - disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} - onCheckedChange={(checked) => { - onDiscoverEnvVarsChange( - checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : [] - ); - }} - /> - )} -
- - Select which environments should automatically discover and create new environment - variables from Vercel during builds. - -
-
- {availableEnvSlugs.map((slug) => { - const envType = envSlugToType(slug); - const disabled = isSlugDisabled(slug); - const disabledReason = disabledEnvSlugs?.[slug]; - const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); - const row = ( -
-
- - - {environmentFullTitle({ type: envType })} - -
+ {layout === "card" && ( +
+
+
+ + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( 0 && + enabledSlugs.every( + (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) + ) && + enabledSlugs.some((s) => discoverEnvVars.includes(s)) + } + disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} onCheckedChange={(checked) => { onDiscoverEnvVarsChange( - checked - ? [...discoverEnvVars, slug] - : discoverEnvVars.filter((s) => s !== slug) + checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : [] ); }} /> -
- ); - if (disabled && disabledReason) { - return ; - } - return row; - })} + )} +
+ + Select which environments should automatically discover and create new environment + variables from Vercel during builds. + +
+
+ {availableEnvSlugs.map((slug) => { + const envType = envSlugToType(slug); + const disabled = isSlugDisabled(slug); + const disabledReason = disabledEnvSlugs?.[slug]; + const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); + const row = ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onDiscoverEnvVarsChange( + checked + ? [...discoverEnvVars, slug] + : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> +
+ ); + if (disabled && disabledReason) { + return ( + + ); + } + return row; + })} +
-
+ )} + + {atomicSections} {/* Atomic deployments */} -
-
- - { - onAtomicBuildsChange(checked ? ["prod"] : []); - }} - /> -
- - When enabled, production deployments wait for Vercel deployment to complete before - promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom Production - Domains" option in your Vercel project settings to perform staged deployments.{" "} - - Learn more - - . - - {currentTriggerVersion && ( + {layout === "card" && ( +
+
+ + { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> +
- Currently pinned to{" "} - {currentTriggerVersion} in Vercel - production. + When enabled, production deployments wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom + Production Domains" option in your Vercel project settings to perform staged + deployments.{" "} + + Learn more + + . - )} - {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( - - Couldn't read TRIGGER_VERSION from - Vercel — check the Vercel dashboard to confirm the production pin. - - )} -
+ {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in Vercel + production. + + )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read TRIGGER_VERSION from + Vercel — check the Vercel dashboard to confirm the production pin. + + )} +
+ )} {/* Auto promotion — only visible when atomic deployments are on */} - {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && ( + {layout === "card" && atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
@@ -248,3 +395,59 @@ export function BuildSettingsFields({ ); } + +function EnvToggleRow({ + slug, + checked, + disabled, + disabledReason, + unlockHint, + unlockTarget, + onCheckedChange, +}: { + slug: EnvSlug; + checked: boolean; + disabled: boolean; + disabledReason?: string; + unlockHint?: string; + unlockTarget?: string; + onCheckedChange: (checked: boolean) => void; +}) { + const envType = envSlugToType(slug); + + return ( + + + + } + > +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ {disabled && disabledReason ? ( + {disabledReason} + ) : null} +
+
+ ); +} diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index 85c7816b73..cd5f9eee50 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -13,6 +13,7 @@ import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header3 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; +import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; @@ -797,10 +798,8 @@ export function VercelOnboardingModal({
{showProjectSelection && (
- Select Vercel Project - - Choose which Vercel project to connect with this Trigger.dev project. Your API keys - will be automatically synced to Vercel. + + Choose the Vercel project to pair with this Trigger.dev project. {availableProjects.length === 0 ? ( @@ -808,41 +807,42 @@ export function VercelOnboardingModal({ No Vercel projects found. Please create a project in Vercel first. ) : ( - + + + + Your{" "} + + TRIGGER_SECRET_KEY + {" "} + is synced to Vercel for each environment once connected. + + )} {projectSelectionError && {projectSelectionError}} - - Once connected, your{" "} - - TRIGGER_SECRET_KEY - {" "} - will be automatically synced to Vercel for each environment. - - @@ -866,7 +866,7 @@ export function VercelOnboardingModal({
} cancelButton={ - } diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f374382..6075d52f1b 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -89,6 +89,13 @@ const theme = { shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60", icon: "text-text-bright", }, + warning: { + textColor: "text-warning transition group-disabled/button:text-warning/60", + button: + "bg-warning/10 border border-warning/20 group-hover/button:bg-warning/20 group-hover/button:border-warning/40 group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + shortcut: "border-warning/40 text-warning group-hover/button:border-warning/60", + icon: "text-warning", + }, docs: { textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80", button: @@ -133,6 +140,10 @@ const variant = { "danger/medium": createVariant("medium", "danger"), "danger/large": createVariant("large", "danger"), "danger/extra-large": createVariant("extra-large", "danger"), + "warning/small": createVariant("small", "warning"), + "warning/medium": createVariant("medium", "warning"), + "warning/large": createVariant("large", "warning"), + "warning/extra-large": createVariant("extra-large", "warning"), "docs/small": createVariant("small", "docs"), "docs/medium": createVariant("medium", "docs"), "docs/large": createVariant("large", "docs"), diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 66b291b752..3c4c8faad0 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -38,6 +38,9 @@ const variants = { "secondary/small": { button: cn(sizes.small.button, style.secondary.button), }, + "secondary/medium": { + button: cn(sizes.medium.button, style.secondary.button), + }, "tertiary/small": { button: cn(sizes.small.button, style.tertiary.button), }, @@ -692,11 +695,11 @@ export function ComboBox({ ...props }: ComboBoxProps) { return ( -
+
} - className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden" + className="flex-1 border-0 bg-transparent text-xs text-text-dimmed outline-hidden focus:border-0 focus:ring-0" {...props} /> {shortcut && ( diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index 7d6484fb46..dae340e983 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -1,3 +1,4 @@ +import { ExclamationCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { type ReactNode } from "react"; import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; import { cn } from "~/utils/cn"; @@ -100,7 +101,10 @@ export function SettingsRowTitle({ htmlFor?: string; className?: string; }) { - const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className); + const classes = cn( + "block font-sans text-sm font-semibold leading-tight text-text-bright", + className + ); return htmlFor ? (
@@ -335,6 +341,12 @@ export default function Story() { /> This is a delete button +
diff --git a/apps/webapp/app/routes/storybook.icons/route.tsx b/apps/webapp/app/routes/storybook.icons/route.tsx index a465e05fb5..36acd29b23 100644 --- a/apps/webapp/app/routes/storybook.icons/route.tsx +++ b/apps/webapp/app/routes/storybook.icons/route.tsx @@ -93,6 +93,7 @@ import { MoveUpIcon } from "~/assets/icons/MoveUpIcon"; import { NodejsLogoIcon } from "~/assets/icons/NodejsLogoIcon"; import { OneTreeIcon } from "~/assets/icons/OneTreeIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { PadlockRoundedIcon } from "~/assets/icons/PadlockRoundedIcon"; import { PauseIcon } from "~/assets/icons/PauseIcon"; import { PlaygroundIcon } from "~/assets/icons/PlaygroundIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; @@ -233,6 +234,7 @@ const icons: IconEntry[] = [ { name: "OneTreeIcon", render: simple(OneTreeIcon) }, { name: "OpenAIIcon", render: simple(OpenAIIcon) }, { name: "PadlockIcon", render: simple(PadlockIcon) }, + { name: "PadlockRoundedIcon", render: simple(PadlockRoundedIcon) }, { name: "PauseIcon", render: simple(PauseIcon) }, { name: "PerplexityIcon", render: simple(PerplexityIcon) }, { name: "PlaygroundIcon", render: simple(PlaygroundIcon) }, diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 2bc91d7732..ab845b6335 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -639,3 +639,13 @@ @apply leading-relaxed; } } + +form:has(.unlock-hint-staging-env:hover) [data-unlock-target="staging-env"], +form:has(.unlock-hint-pull-prod:hover) [data-unlock-target="pull-prod"], +form:has(.unlock-hint-pull-stg:hover) [data-unlock-target="pull-stg"], +form:has(.unlock-hint-pull-preview:hover) [data-unlock-target="pull-preview"], +form:has(.unlock-hint-pull-dev:hover) [data-unlock-target="pull-dev"] { + border-radius: 0.25rem; + outline: 2px dashed var(--color-warning); + outline-offset: 2px; +} From d189ce17d385b33ef44d384a9715b85ac75be139 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:40:31 +0100 Subject: [PATCH 19/23] chore: release v4.5.8 (#4364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary 2 new features, 9 improvements, 3 bug fixes. ## Highlights - Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. ([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387)) ## Improvements - Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output. ([#4348](https://github.com/triggerdotdev/trigger.dev/pull/4348)) ## Server changes These changes affect the self-hosted Docker image and Trigger.dev Cloud: - Favorite any dashboard page to a new Favorites section in the side menu, and customize the sidebar by renaming favorites, hiding items, and reordering items and sections. ([#4375](https://github.com/triggerdotdev/trigger.dev/pull/4375)) - List API endpoints now clamp the page size to a maximum of 100. Requests asking for a larger page size return up to 100 items and keep paginating, rather than pulling an unbounded page. ([#4360](https://github.com/triggerdotdev/trigger.dev/pull/4360)) - Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit. ([#4328](https://github.com/triggerdotdev/trigger.dev/pull/4328)) - When you create a Personal Access Token, the generated token now shows its first and last few characters instead of being fully hidden, so you can confirm you copied the right value. ([#4363](https://github.com/triggerdotdev/trigger.dev/pull/4363)) - Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch. ([#4341](https://github.com/triggerdotdev/trigger.dev/pull/4341)) - Realtime run subscriptions can now be configured to read run data straight from the primary database, so a run's latest state is never served from a lagging replica. Off by default; replica reads are unchanged unless you turn it on. ([#4378](https://github.com/triggerdotdev/trigger.dev/pull/4378)) - SSO and Directory Sync are no longer restricted to Enterprise plans — get in touch and we can turn them on for your organization whatever plan you're on. ([#4393](https://github.com/triggerdotdev/trigger.dev/pull/4393)) - Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor. ([#4350](https://github.com/triggerdotdev/trigger.dev/pull/4350)) - The runs list on a task's page now updates live — run statuses change and newly triggered runs appear without a manual refresh, matching the main Runs page. ([#4377](https://github.com/triggerdotdev/trigger.dev/pull/4377)) - Speed up the Batches list page for environments with a large number of batches, which could previously time out while loading. ([#4361](https://github.com/triggerdotdev/trigger.dev/pull/4361)) - Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs. ([#4346](https://github.com/triggerdotdev/trigger.dev/pull/4346)) - The tasks page no longer runs two queries whose results were never displayed, cutting wasted work on every page load and removing a source of hidden server errors ([#4380](https://github.com/triggerdotdev/trigger.dev/pull/4380))
Raw changeset output # Releases ## @trigger.dev/build@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.8` ## trigger.dev@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.8` - `@trigger.dev/build@4.5.8` - `@trigger.dev/schema-to-json@4.5.8` ## @trigger.dev/core@4.5.8 ### Patch Changes - Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. ([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387)) ## @trigger.dev/python@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/sdk@4.5.8` - `@trigger.dev/core@4.5.8` - `@trigger.dev/build@4.5.8` ## @trigger.dev/react-hooks@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.8` ## @trigger.dev/redis-worker@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.8` ## @trigger.dev/rsc@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.8` ## @trigger.dev/schema-to-json@4.5.8 ### Patch Changes - Updated dependencies: - `@trigger.dev/core@4.5.8` ## @trigger.dev/sdk@4.5.8 ### Patch Changes - Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output. ([#4348](https://github.com/triggerdotdev/trigger.dev/pull/4348)) - Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. ([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387)) - Updated dependencies: - `@trigger.dev/core@4.5.8`
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- ...gent-preserve-partial-on-stream-failure.md | 5 ---- .changeset/friendly-keys-batch-tokens.md | 6 ----- .../batch-list-created-at-index.md | 6 ----- .../cap-list-endpoint-page-size.md | 6 ----- .server-changes/default-billing-alerts.md | 6 ----- ...entrypoint-no-log-db-connection-strings.md | 6 ----- ...avorite-pages-and-sidebar-customization.md | 6 ----- .../pat-token-reveal-last-chars.md | 6 ----- .../realtime-emission-fanout-metrics.md | 6 ----- .../realtime-run-reads-from-primary.md | 6 ----- .server-changes/sso-entitlement.md | 6 ----- .../supervisor-outbound-request-metrics.md | 6 ----- ...task-list-remove-unused-metrics-queries.md | 6 ----- .../task-page-runs-live-updating.md | 6 ----- hosting/k8s/helm/Chart.yaml | 4 ++-- packages/build/CHANGELOG.md | 7 ++++++ packages/build/package.json | 4 ++-- packages/cli-v3/CHANGELOG.md | 9 +++++++ packages/cli-v3/package.json | 8 +++---- packages/core/CHANGELOG.md | 6 +++++ packages/core/package.json | 2 +- packages/python/CHANGELOG.md | 9 +++++++ packages/python/package.json | 12 +++++----- packages/react-hooks/CHANGELOG.md | 7 ++++++ packages/react-hooks/package.json | 4 ++-- packages/redis-worker/CHANGELOG.md | 7 ++++++ packages/redis-worker/package.json | 4 ++-- packages/rsc/CHANGELOG.md | 7 ++++++ packages/rsc/package.json | 6 ++--- packages/schema-to-json/CHANGELOG.md | 7 ++++++ packages/schema-to-json/package.json | 2 +- packages/trigger-sdk/CHANGELOG.md | 9 +++++++ packages/trigger-sdk/package.json | 4 ++-- pnpm-lock.yaml | 24 +++++++++---------- 34 files changed, 105 insertions(+), 120 deletions(-) delete mode 100644 .changeset/chat-agent-preserve-partial-on-stream-failure.md delete mode 100644 .changeset/friendly-keys-batch-tokens.md delete mode 100644 .server-changes/batch-list-created-at-index.md delete mode 100644 .server-changes/cap-list-endpoint-page-size.md delete mode 100644 .server-changes/default-billing-alerts.md delete mode 100644 .server-changes/entrypoint-no-log-db-connection-strings.md delete mode 100644 .server-changes/favorite-pages-and-sidebar-customization.md delete mode 100644 .server-changes/pat-token-reveal-last-chars.md delete mode 100644 .server-changes/realtime-emission-fanout-metrics.md delete mode 100644 .server-changes/realtime-run-reads-from-primary.md delete mode 100644 .server-changes/sso-entitlement.md delete mode 100644 .server-changes/supervisor-outbound-request-metrics.md delete mode 100644 .server-changes/task-list-remove-unused-metrics-queries.md delete mode 100644 .server-changes/task-page-runs-live-updating.md diff --git a/.changeset/chat-agent-preserve-partial-on-stream-failure.md b/.changeset/chat-agent-preserve-partial-on-stream-failure.md deleted file mode 100644 index 3e7defddec..0000000000 --- a/.changeset/chat-agent-preserve-partial-on-stream-failure.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output. diff --git a/.changeset/friendly-keys-batch-tokens.md b/.changeset/friendly-keys-batch-tokens.md deleted file mode 100644 index 505edacd33..0000000000 --- a/.changeset/friendly-keys-batch-tokens.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@trigger.dev/core": patch -"@trigger.dev/sdk": patch ---- - -Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. diff --git a/.server-changes/batch-list-created-at-index.md b/.server-changes/batch-list-created-at-index.md deleted file mode 100644 index b5a2438661..0000000000 --- a/.server-changes/batch-list-created-at-index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Speed up the Batches list page for environments with a large number of batches, which could previously time out while loading. diff --git a/.server-changes/cap-list-endpoint-page-size.md b/.server-changes/cap-list-endpoint-page-size.md deleted file mode 100644 index 2649f30578..0000000000 --- a/.server-changes/cap-list-endpoint-page-size.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -List API endpoints now clamp the page size to a maximum of 100. Requests asking for a larger page size return up to 100 items and keep paginating, rather than pulling an unbounded page. diff --git a/.server-changes/default-billing-alerts.md b/.server-changes/default-billing-alerts.md deleted file mode 100644 index e828c50605..0000000000 --- a/.server-changes/default-billing-alerts.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit. diff --git a/.server-changes/entrypoint-no-log-db-connection-strings.md b/.server-changes/entrypoint-no-log-db-connection-strings.md deleted file mode 100644 index 611408cf14..0000000000 --- a/.server-changes/entrypoint-no-log-db-connection-strings.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs. diff --git a/.server-changes/favorite-pages-and-sidebar-customization.md b/.server-changes/favorite-pages-and-sidebar-customization.md deleted file mode 100644 index d4468b4df5..0000000000 --- a/.server-changes/favorite-pages-and-sidebar-customization.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Favorite any dashboard page to a new Favorites section in the side menu, and customize the sidebar by renaming favorites, hiding items, and reordering items and sections. diff --git a/.server-changes/pat-token-reveal-last-chars.md b/.server-changes/pat-token-reveal-last-chars.md deleted file mode 100644 index 29797f73d3..0000000000 --- a/.server-changes/pat-token-reveal-last-chars.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -When you create a Personal Access Token, the generated token now shows its first and last few characters instead of being fully hidden, so you can confirm you copied the right value. diff --git a/.server-changes/realtime-emission-fanout-metrics.md b/.server-changes/realtime-emission-fanout-metrics.md deleted file mode 100644 index bd9ef83afb..0000000000 --- a/.server-changes/realtime-emission-fanout-metrics.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch. diff --git a/.server-changes/realtime-run-reads-from-primary.md b/.server-changes/realtime-run-reads-from-primary.md deleted file mode 100644 index c52ea44078..0000000000 --- a/.server-changes/realtime-run-reads-from-primary.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Realtime run subscriptions can now be configured to read run data straight from the primary database, so a run's latest state is never served from a lagging replica. Off by default; replica reads are unchanged unless you turn it on. diff --git a/.server-changes/sso-entitlement.md b/.server-changes/sso-entitlement.md deleted file mode 100644 index 4a039b6855..0000000000 --- a/.server-changes/sso-entitlement.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -SSO and Directory Sync are no longer restricted to Enterprise plans — get in touch and we can turn them on for your organization whatever plan you're on. diff --git a/.server-changes/supervisor-outbound-request-metrics.md b/.server-changes/supervisor-outbound-request-metrics.md deleted file mode 100644 index c66a499b81..0000000000 --- a/.server-changes/supervisor-outbound-request-metrics.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: supervisor -type: improvement ---- - -Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor. diff --git a/.server-changes/task-list-remove-unused-metrics-queries.md b/.server-changes/task-list-remove-unused-metrics-queries.md deleted file mode 100644 index 23164a205c..0000000000 --- a/.server-changes/task-list-remove-unused-metrics-queries.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -The tasks page no longer runs two queries whose results were never displayed, cutting wasted work on every page load and removing a source of hidden server errors diff --git a/.server-changes/task-page-runs-live-updating.md b/.server-changes/task-page-runs-live-updating.md deleted file mode 100644 index a082fdfb3d..0000000000 --- a/.server-changes/task-page-runs-live-updating.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -The runs list on a task's page now updates live — run statuses change and newly triggered runs appear without a manual refresh, matching the main Runs page. diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index e40b51845c..69538c1a23 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: trigger description: The official Trigger.dev Helm chart type: application -version: 4.5.7 -appVersion: v4.5.7 +version: 4.5.8 +appVersion: v4.5.8 home: https://trigger.dev sources: - https://github.com/triggerdotdev/trigger.dev diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index c8bbf39dd0..10f6729754 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/build +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index e598555da8..39c7e5cd70 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/build", - "version": "4.5.7", + "version": "4.5.8", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -79,7 +79,7 @@ }, "dependencies": { "@prisma/config": "^6.10.0", - "@trigger.dev/core": "workspace:4.5.7", + "@trigger.dev/core": "workspace:4.5.8", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index 5840b31cdf..c3821d48b1 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,14 @@ # trigger.dev +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + - `@trigger.dev/build@4.5.8` + - `@trigger.dev/schema-to-json@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index c6d8d9719d..9dba77e6af 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "4.5.7", + "version": "4.5.8", "description": "A Command-Line Interface for Trigger.dev projects", "type": "module", "license": "MIT", @@ -96,9 +96,9 @@ "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "1.41.1", "@s2-dev/streamstore": "^0.22.10", - "@trigger.dev/build": "workspace:4.5.7", - "@trigger.dev/core": "workspace:4.5.7", - "@trigger.dev/schema-to-json": "workspace:4.5.7", + "@trigger.dev/build": "workspace:4.5.8", + "@trigger.dev/core": "workspace:4.5.8", + "@trigger.dev/schema-to-json": "workspace:4.5.8", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 6c2fb47dd0..9122293530 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # internal-platform +## 4.5.8 + +### Patch Changes + +- Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. ([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387)) + ## 4.5.7 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index f0c3adfcf2..c99f83fe65 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "4.5.7", + "version": "4.5.8", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "publishConfig": { diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index bd9c54d71c..ef31e38a05 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/sdk@4.5.8` + - `@trigger.dev/core@4.5.8` + - `@trigger.dev/build@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index bebd4ade64..3b1fbb6782 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/python", - "version": "4.5.7", + "version": "4.5.8", "description": "Python runtime and build extension for Trigger.dev", "license": "MIT", "publishConfig": { @@ -45,7 +45,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.5.7", + "@trigger.dev/core": "workspace:4.5.8", "tinyexec": "^0.3.2" }, "devDependencies": { @@ -56,12 +56,12 @@ "tsx": "4.17.0", "esbuild": "^0.23.0", "@arethetypeswrong/cli": "^0.18.5", - "@trigger.dev/build": "workspace:4.5.7", - "@trigger.dev/sdk": "workspace:4.5.7" + "@trigger.dev/build": "workspace:4.5.8", + "@trigger.dev/sdk": "workspace:4.5.8" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.5.7", - "@trigger.dev/build": "workspace:^4.5.7" + "@trigger.dev/sdk": "workspace:^4.5.8", + "@trigger.dev/build": "workspace:^4.5.8" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index f422e8cb94..78140e5a18 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react-hooks +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index b0b0ae668d..48a5b67a6b 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react-hooks", - "version": "4.5.7", + "version": "4.5.8", "description": "trigger.dev react hooks", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.5.7", + "@trigger.dev/core": "workspace:^4.5.8", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index 5ddc4f32d4..1fbed6edc3 100644 --- a/packages/redis-worker/CHANGELOG.md +++ b/packages/redis-worker/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/redis-worker +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index edd27f40e4..7d2ff4dcee 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/redis-worker", - "version": "4.5.7", + "version": "4.5.8", "description": "Redis worker for trigger.dev", "license": "MIT", "publishConfig": { @@ -23,7 +23,7 @@ "test": "vitest --sequence.concurrent=false --no-file-parallelism" }, "dependencies": { - "@trigger.dev/core": "workspace:4.5.7", + "@trigger.dev/core": "workspace:4.5.8", "lodash.omit": "^4.5.0", "nanoid": "^5.0.7", "p-limit": "^6.2.0", diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md index 89b0364191..f30b7980f3 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index 46b17e36ba..b216d219d8 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/rsc", - "version": "4.5.7", + "version": "4.5.8", "description": "trigger.dev rsc", "license": "MIT", "publishConfig": { @@ -37,14 +37,14 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.5.7", + "@trigger.dev/core": "workspace:^4.5.8", "mlly": "^1.7.1", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", - "@trigger.dev/build": "workspace:^4.5.7", + "@trigger.dev/build": "workspace:^4.5.8", "@types/node": "^24.13.3", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/schema-to-json/CHANGELOG.md b/packages/schema-to-json/CHANGELOG.md index 50c400b068..6f5a7cc5ba 100644 --- a/packages/schema-to-json/CHANGELOG.md +++ b/packages/schema-to-json/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/schema-to-json +## 4.5.8 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json index 03e9c93621..66aa20c358 100644 --- a/packages/schema-to-json/package.json +++ b/packages/schema-to-json/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/schema-to-json", - "version": "4.5.7", + "version": "4.5.8", "description": "Convert various schema validation libraries to JSON Schema", "license": "MIT", "publishConfig": { diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index ecb8fc3bac..fa4b2ddb99 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/sdk +## 4.5.8 + +### Patch Changes + +- Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output. ([#4348](https://github.com/triggerdotdev/trigger.dev/pull/4348)) +- Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. ([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387)) +- Updated dependencies: + - `@trigger.dev/core@4.5.8` + ## 4.5.7 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 50141b73cd..41b5591c8a 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "4.5.7", + "version": "4.5.8", "description": "trigger.dev Node.JS SDK", "license": "MIT", "publishConfig": { @@ -77,7 +77,7 @@ "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", - "@trigger.dev/core": "workspace:4.5.7", + "@trigger.dev/core": "workspace:4.5.8", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 204cae651c..2849a27dd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1419,7 +1419,7 @@ importers: specifier: ^6.10.0 version: 6.19.0(magicast@0.3.5) '@trigger.dev/core': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../core mlly: specifier: ^1.7.1 @@ -1495,13 +1495,13 @@ importers: specifier: ^0.22.10 version: 0.22.10(supports-color@10.0.0) '@trigger.dev/build': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../build '@trigger.dev/core': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../core '@trigger.dev/schema-to-json': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../schema-to-json ansi-escapes: specifier: ^7.0.0 @@ -1888,7 +1888,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1898,10 +1898,10 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@trigger.dev/build': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../trigger-sdk '@types/node': specifier: 24.13.3 @@ -1925,7 +1925,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.5.7 + specifier: workspace:^4.5.8 version: link:../core react: specifier: 18.3.1 @@ -1959,7 +1959,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../core cron-parser: specifier: ^4.9.0 @@ -2008,7 +2008,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.5.7 + specifier: workspace:^4.5.8 version: link:../core mlly: specifier: ^1.7.1 @@ -2024,7 +2024,7 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@trigger.dev/build': - specifier: workspace:^4.5.7 + specifier: workspace:^4.5.8 version: link:../build '@types/node': specifier: 24.13.3 @@ -2103,7 +2103,7 @@ importers: specifier: 1.41.1 version: 1.41.1 '@trigger.dev/core': - specifier: workspace:4.5.7 + specifier: workspace:4.5.8 version: link:../core chalk: specifier: ^5.2.0 From fc576436e24c0fa4d29fffa2a50bc627b3626711 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 27 Jul 2026 16:59:43 +0100 Subject: [PATCH 20/23] perf(run-ops-database): index BatchTaskRun for the batches list on the dedicated schema (#4396) ## Summary The batches list page orders by `(createdAt DESC, id DESC)`, which is why [#4361](https://github.com/triggerdotdev/trigger.dev/pull/4361) added a matching index on `BatchTaskRun`. That index only landed in `@trigger.dev/database`. The dedicated run-ops database has its own migration history, so it never received the index. `BatchListPresenter` reads both databases and merges, so for environments whose batches live in the dedicated database the page kept falling back to a scan and in-memory sort, which is the exact behaviour #4361 set out to fix. ## Fix Adds the index to the run-ops schema with its own migration. `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, so it is a no-op where the index already exists and still records its ledger row. The second half is the interesting part. Because the two packages own separate migration histories, a run-graph schema change has to be authored twice, and nothing made the miss visible: the run-ops status check truthfully reports "up to date" against its own history, so the apply step just skips. `schemaParity.test.ts` compares the physical shape of every model the run-ops schema declares against its counterpart in `@trigger.dev/database`: scalar fields with their attributes, plus `@@index`, `@@unique`, `@@id` and `@@map`. Relation navigation fields are excluded, since the run-ops schema deliberately drops relations that would cross a database boundary while keeping the scalar FK column. A field counts as a relation when its type resolves to a model name, which keeps enum-typed columns in scope. Two models are listed as run-ops-only: `CompletedWaitpoint` and `WaitpointRunConnection`, both explicit FK-free replacements for a control-plane implicit many-to-many, since an implicit m2m carries a foreign key that cannot resolve across databases. The test also asserts that exception list is exhaustive, so a new unpaired model fails rather than being silently skipped. Confirmed the guard actually fails: reverting the index turns `BatchTaskRun` red with the missing `@@index` named in the diff. --- .../migration.sql | 1 + .../run-ops-database/prisma/schema.prisma | 1 + .../run-ops-database/src/schemaParity.test.ts | 135 ++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 internal-packages/run-ops-database/prisma/migrations/20260727120000_add_batch_task_run_created_at_dashboard_index/migration.sql create mode 100644 internal-packages/run-ops-database/src/schemaParity.test.ts diff --git a/internal-packages/run-ops-database/prisma/migrations/20260727120000_add_batch_task_run_created_at_dashboard_index/migration.sql b/internal-packages/run-ops-database/prisma/migrations/20260727120000_add_batch_task_run_created_at_dashboard_index/migration.sql new file mode 100644 index 0000000000..0d6776d9a7 --- /dev/null +++ b/internal-packages/run-ops-database/prisma/migrations/20260727120000_add_batch_task_run_created_at_dashboard_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_createdAt_id_idx" ON "public"."BatchTaskRun"("runtimeEnvironmentId", "createdAt" DESC, "id" DESC); diff --git a/internal-packages/run-ops-database/prisma/schema.prisma b/internal-packages/run-ops-database/prisma/schema.prisma index 8e49fc854e..4750efa392 100644 --- a/internal-packages/run-ops-database/prisma/schema.prisma +++ b/internal-packages/run-ops-database/prisma/schema.prisma @@ -827,6 +827,7 @@ model BatchTaskRun { @@index([dependentTaskAttemptId]) // This is for the batch list dashboard page @@index([runtimeEnvironmentId, id(sort: Desc)]) + @@index([runtimeEnvironmentId, createdAt(sort: Desc), id(sort: Desc)]) } enum BatchTaskRunStatus { diff --git a/internal-packages/run-ops-database/src/schemaParity.test.ts b/internal-packages/run-ops-database/src/schemaParity.test.ts new file mode 100644 index 0000000000..763fba73e0 --- /dev/null +++ b/internal-packages/run-ops-database/src/schemaParity.test.ts @@ -0,0 +1,135 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * Guards the double-authoring rule for run-graph schema changes. + * + * The run-ops database is on its own migration history, so a change to a run-graph model has to be + * written twice: once in `@trigger.dev/database` (which targets the control-plane and legacy RDS + * databases) and once here. Nothing about the two histories forces that, and when it is missed the + * run-ops status job truthfully reports "up to date" against its own history — so the change simply + * never lands, silently. + * + * This compares the PHYSICAL shape of every model the run-ops schema declares against its + * control-plane counterpart: scalar fields with their attributes, plus `@@index` / `@@unique` / + * `@@id` / `@@map`. Relation navigation fields are excluded, because the run-ops schema deliberately + * drops relations that would cross into the control-plane database while keeping the scalar FK + * column. + */ + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const RUN_OPS_SCHEMA = resolve(packageRoot, "prisma/schema.prisma"); +const CONTROL_PLANE_SCHEMA = resolve(packageRoot, "../database/prisma/schema.prisma"); + +/** + * Models that exist only in the run-ops schema, with no control-plane counterpart to compare + * against. Both replace a control-plane implicit many-to-many relation with an explicit, FK-free + * join model, because an implicit m2m carries a foreign key that cannot resolve across databases. + */ +const RUN_OPS_ONLY_MODELS = new Set(["CompletedWaitpoint", "WaitpointRunConnection"]); + +type Schema = { + models: Map; + modelNames: Set; +}; + +function parseSchema(path: string): Schema { + const lines = readFileSync(path, "utf8").split("\n"); + const models = new Map(); + let current: string | null = null; + let body: string[] = []; + + for (const line of lines) { + const open = line.match(/^model\s+(\w+)\s*\{/); + if (open) { + current = open[1]!; + body = []; + continue; + } + if (current !== null && /^\}/.test(line)) { + models.set(current, body); + current = null; + continue; + } + if (current !== null) { + const trimmed = line.trim(); + if (trimmed.length > 0 && !trimmed.startsWith("//")) { + body.push(trimmed); + } + } + } + + return { models, modelNames: new Set(models.keys()) }; +} + +/** + * Keeps the lines that describe physical database structure and drops the ones that describe Prisma + * relations. A field is a relation navigation field when its type resolves to a model, so scalar FK + * columns (`String`) and enum-typed columns are both retained. + */ +function physicalLines(body: string[], modelNames: Set): Set { + const kept = new Set(); + + for (const line of body) { + if (line.startsWith("@@")) { + if (/^@@(index|unique|id|map)\b/.test(line)) { + kept.add(line.replace(/\s+/g, " ")); + } + continue; + } + + const field = line.match(/^(\w+)\s+(\w+)(\[\]|\?)?/); + if (!field) continue; + + const [, , type] = field; + if (modelNames.has(type!)) continue; + if (line.includes("@relation(")) continue; + + kept.add(line.replace(/\s+/g, " ")); + } + + return kept; +} + +describe("run-ops schema parity with the control-plane schema", () => { + const runOps = parseSchema(RUN_OPS_SCHEMA); + const controlPlane = parseSchema(CONTROL_PLANE_SCHEMA); + + const sharedModels = [...runOps.models.keys()] + .filter((name) => !RUN_OPS_ONLY_MODELS.has(name)) + .sort(); + + it("finds models to compare", () => { + expect(sharedModels.length).toBeGreaterThan(10); + }); + + it("declares every run-ops-only model in the exception list, and no others", () => { + const missingFromControlPlane = [...runOps.models.keys()] + .filter((name) => !controlPlane.models.has(name)) + .sort(); + + expect(missingFromControlPlane).toEqual([...RUN_OPS_ONLY_MODELS].sort()); + }); + + it.each(sharedModels)("%s has the same physical shape in both schemas", (modelName) => { + const runOpsBody = runOps.models.get(modelName); + const controlPlaneBody = controlPlane.models.get(modelName); + + expect(runOpsBody, `${modelName} missing from the run-ops schema`).toBeDefined(); + expect(controlPlaneBody, `${modelName} missing from the control-plane schema`).toBeDefined(); + + const runOpsShape = physicalLines(runOpsBody!, runOps.modelNames); + const controlPlaneShape = physicalLines(controlPlaneBody!, controlPlane.modelNames); + + const onlyInControlPlane = [...controlPlaneShape].filter((l) => !runOpsShape.has(l)).sort(); + const onlyInRunOps = [...runOpsShape].filter((l) => !controlPlaneShape.has(l)).sort(); + + expect( + { onlyInControlPlane, onlyInRunOps }, + `${modelName} differs physically between the two schemas. A run-graph change was written to ` + + `one schema and not the other; add the missing side plus a migration in that package.` + ).toEqual({ onlyInControlPlane: [], onlyInRunOps: [] }); + }); +}); From 3ed48516dff5d27c85a2a689996e8707481a9783 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Jul 2026 17:22:46 +0100 Subject: [PATCH 21/23] ci: let the claude bot trigger the PR audit workflows (#4392) ## Summary PRs opened by the claude GitHub app fail both the agent instructions audit and the REVIEW.md drift audit before Claude gets a chance to run. `claude-code-action` refuses any actor whose account type is not `User` unless the actor is listed in `allowed_bots`: ``` Workflow initiated by non-human actor: claude (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots. ``` So those PRs land with two permanently red checks and no audit coverage at all. Both workflows already allowlist Devin; this adds the claude app alongside it. ## Why this does not open the workflows up to outside contributors `allowed_bots` is only consulted for non-`User` actors. Humans, contributor or maintainer, take the separate write-permission path and are unaffected by what is in the list. Beyond that, both jobs are guarded by `github.event.pull_request.head.repo.full_name == github.repository`, so a fork PR skips the job entirely, and they trigger on `pull_request` rather than `pull_request_target`, so a fork-triggered run would get no API key and a read-only token anyway. The bot is named explicitly instead of using `"*"`, which would let every bot trigger these audits, dependabot's PR stream included. --- .github/workflows/check-review-md.yml | 2 +- .github/workflows/claude-md-audit.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-review-md.yml b/.github/workflows/check-review-md.yml index 06f0d2d82c..79b9d7eacd 100644 --- a/.github/workflows/check-review-md.yml +++ b/.github/workflows/check-review-md.yml @@ -38,7 +38,7 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} use_sticky_comment: true - allowed_bots: "devin-ai-integration[bot]" + allowed_bots: "devin-ai-integration[bot],claude[bot]" claude_args: | --max-turns 30 diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml index aa62444c9d..d1b1bbdb45 100644 --- a/.github/workflows/claude-md-audit.yml +++ b/.github/workflows/claude-md-audit.yml @@ -40,7 +40,7 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} use_sticky_comment: true - allowed_bots: "devin-ai-integration[bot]" + allowed_bots: "devin-ai-integration[bot],claude[bot]" claude_args: | --max-turns 25 From ec562c0e68c1ef3f82227b865b2edc8f900695db Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:00:48 +0100 Subject: [PATCH 22/23] fix(webapp): remove unused Electric sync trace routes (#4400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Eric Allam** · [Slack thread](https://triggerdotdev.slack.com/archives/C0AU83M3136/p1785222101937829?thread_ts=1785207509.304669&cid=C0AU83M3136)_ Removes two dead Remix routes and the helpers only they used. `app/routes/sync.traces.runs.$traceId.ts` (`/sync/traces/runs/:traceId`) and `app/routes/sync.traces.$traceId.ts` (`/sync/traces/:traceId`) were added with the original ElectricSQL run page and lost their only consumers when the dashboard hooks that called them were deleted. Nothing in the repo references either route today. Also removed, because the deleted routes were their only callers: - `OtelTraceIdSchema`, `RESERVED_ELECTRIC_SHAPE_PARAMS`, `TraceScope`, `buildElectricTraceWhereClause` from `app/v3/electricShape.server.ts` (the file stays — `UNSAFE_REALTIME_TAG_CHARS` / `sanitizeRealtimeTagForSql` / `sanitizeRealtimeTagsForSql` are still used by `realtime.v1.runs.ts` and `realtimeClient.server.ts`) - the loader-specific cases in `apps/webapp/test/spanTraceRoutes.replicaLag.test.ts` and `internal-packages/run-store/src/runOpsStore.routesSpanTraceReadView.replicaLag.test.ts` `app/utils/longPollingFetch.ts` is untouched — `realtimeClient.server.ts` still uses it. `runOpsStore.ts` / `PostgresRunStore.ts` are untouched too; the unrouted-lookup mechanism there is generic and stays. As a plain code fact: the run lookup these loaders performed keyed on `TaskRun.traceId` alone, which is not an index-backed query shape. That is noted only as context for why the code is not worth keeping around unused. ### Judgement call worth a maintainer's opinion The request was specifically about `/sync/traces/runs/:traceId`, the route that looks up a run by `traceId`. This PR **also** deletes its sibling `/sync/traces/:traceId`. The reasoning: - both routes came in with the same ElectricSQL run-page work - both lost their only consumers in the same later commit - neither has any caller anywhere in the repo - they share the same helper module, so keeping one means keeping the helpers half-used If you would rather keep the sibling, reverting just that one file deletion is easy and does not affect the rest of this PR — say the word and I will restore it along with the helpers it needs. ## ✅ 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 --- ## Testing Verification run locally from the repo root: | Command | Result | | --- | --- | | `pnpm run format` | clean, no changes produced | | `pnpm run lint:fix` | clean | | `pnpm run lint` | pass (exit 0, no findings) | | `pnpm run typecheck --filter webapp` | pass | | `pnpm run typecheck --filter @internal/run-store` | pass | A ripgrep sweep for `sync.traces`, `sync/traces`, `syncTraceRunsLoader`, `buildElectricTraceWhereClause`, `OtelTraceIdSchema` and `RESERVED_ELECTRIC_SHAPE_PARAMS` (excluding `node_modules`) returns zero hits. **Not fully verified:** both edited test files are testcontainers suites and need a Docker runtime, which was not available in my environment. I confirmed each file *collects* correctly with exactly the three intended remaining tests and no import errors — notably, dropping the `session.server` / `controlPlaneResolver.server` / `longPollingFetch` / `env.server` mocks does not break module loading for the surviving loaders. The assertions themselves then failed only on `Could not find a working container runtime strategy`. CI should be the real signal here. Per `apps/webapp/CLAUDE.md`, `pnpm run build --filter webapp` was deliberately not run. --- ## Changelog Removed two unused sync routes left over from the original ElectricSQL run page, along with the helpers and tests that existed only to serve them. No behaviour change — neither route had any caller. --- ## Screenshots _n/a — no user-visible surface changes._ 💯 --------- Co-authored-by: Claude --- .../remove-unused-trace-sync-route.md | 6 + .../webapp/app/routes/sync.traces.$traceId.ts | 95 -------------- .../app/routes/sync.traces.runs.$traceId.ts | 117 ----------------- apps/webapp/app/v3/electricShape.server.ts | 44 ------- .../test/spanTraceRoutes.replicaLag.test.ts | 120 +----------------- ...routesSpanTraceReadView.replicaLag.test.ts | 54 +------- 6 files changed, 14 insertions(+), 422 deletions(-) create mode 100644 .server-changes/remove-unused-trace-sync-route.md delete mode 100644 apps/webapp/app/routes/sync.traces.$traceId.ts delete mode 100644 apps/webapp/app/routes/sync.traces.runs.$traceId.ts diff --git a/.server-changes/remove-unused-trace-sync-route.md b/.server-changes/remove-unused-trace-sync-route.md new file mode 100644 index 0000000000..b2e857405a --- /dev/null +++ b/.server-changes/remove-unused-trace-sync-route.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Removed an unused internal dashboard sync route that is no longer part of any product surface. diff --git a/apps/webapp/app/routes/sync.traces.$traceId.ts b/apps/webapp/app/routes/sync.traces.$traceId.ts deleted file mode 100644 index d7695014b5..0000000000 --- a/apps/webapp/app/routes/sync.traces.$traceId.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { z } from "zod"; -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { getUserId } from "~/services/session.server"; -import { longPollingFetch } from "~/utils/longPollingFetch"; -import { - OtelTraceIdSchema, - RESERVED_ELECTRIC_SHAPE_PARAMS, - buildElectricTraceWhereClause, -} from "~/v3/electricShape.server"; - -const Params = z.object({ - traceId: OtelTraceIdSchema, -}); - -export async function loader({ params, request }: LoaderFunctionArgs) { - try { - const userId = await getUserId(request); - - const parsedParams = Params.safeParse(params); - if (!parsedParams.success) { - // Treat a malformed traceId as not-found rather than 400 to avoid - // signalling the validator. - return new Response("Not found", { status: 404 }); - } - const { traceId } = parsedParams.data; - - logger.log(`/sync/traces/${traceId}`, { userId }); - - if (!userId) { - return new Response("No user found in cookie", { status: 401 }); - } - - const trace = await $replica.taskEvent.findFirst({ - select: { - organizationId: true, - }, - where: { - traceId, - }, - }); - - if (!trace) { - return new Response("No trace found", { status: 404 }); - } - - const member = await $replica.orgMember.findFirst({ - where: { - organizationId: trace.organizationId, - userId, - }, - }); - - if (!member) { - return new Response("Not a member of this org", { status: 401 }); - } - - const url = new URL(request.url); - const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskEvent"`); - // Strip params we set ourselves so the caller can't override them. - url.searchParams.forEach((value, key) => { - if (RESERVED_ELECTRIC_SHAPE_PARAMS.has(key)) return; - originUrl.searchParams.set(key, value); - }); - - originUrl.searchParams.set( - "where", - buildElectricTraceWhereClause({ - traceId, - scope: { column: "organizationId", id: trace.organizationId }, - }) - ); - - const finalUrl = originUrl.toString(); - - logger.log("Fetching trace data", { url: finalUrl }); - - return longPollingFetch(finalUrl); - } catch (error) { - if (error instanceof Response) { - // Error responses from longPollingFetch - return error; - } else if (error instanceof TypeError) { - // Unexpected errors - logger.error("Unexpected error in loader:", { error: error.message }); - return new Response("An unexpected error occurred", { status: 500 }); - } else { - // Unknown errors - logger.error("Unknown error occurred in loader, not Error", { error: JSON.stringify(error) }); - return new Response("An unknown error occurred", { status: 500 }); - } - } -} diff --git a/apps/webapp/app/routes/sync.traces.runs.$traceId.ts b/apps/webapp/app/routes/sync.traces.runs.$traceId.ts deleted file mode 100644 index 24870cf2f4..0000000000 --- a/apps/webapp/app/routes/sync.traces.runs.$traceId.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { z } from "zod"; -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { getUserId } from "~/services/session.server"; -import { longPollingFetch } from "~/utils/longPollingFetch"; -import { - OtelTraceIdSchema, - RESERVED_ELECTRIC_SHAPE_PARAMS, - buildElectricTraceWhereClause, -} from "~/v3/electricShape.server"; -import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; -import { runStore } from "~/v3/runStore.server"; - -const Params = z.object({ - traceId: OtelTraceIdSchema, -}); - -export async function loader({ params, request }: LoaderFunctionArgs) { - try { - const userId = await getUserId(request); - - const parsedParams = Params.safeParse(params); - if (!parsedParams.success) { - return new Response("Not found", { status: 404 }); - } - const { traceId } = parsedParams.data; - - logger.log(`/sync/runs/${traceId}`, { userId }); - - if (!userId) { - return new Response("No user found in cookie", { status: 401 }); - } - - let run = await runStore.findRun( - { - traceId, - }, - { - select: { - projectId: true, - runtimeEnvironmentId: true, - }, - }, - $replica - ); - - if (!run) { - // Read-your-writes: a just-created run may not have replicated yet. Re-read the owning - // primary before 404ing so a live run's realtime trace feed isn't spuriously not-found. - run = await runStore.findRunOnPrimary( - { traceId }, - { select: { projectId: true, runtimeEnvironmentId: true } } - ); - } - - if (!run) { - return new Response("No run found", { status: 404 }); - } - - const resolvedEnv = await controlPlaneResolver.resolveEnv(run.runtimeEnvironmentId); - - if (!resolvedEnv) { - return new Response("No run found", { status: 404 }); - } - - const member = await $replica.orgMember.findFirst({ - where: { - organizationId: resolvedEnv.organizationId, - userId, - }, - }); - - if (!member) { - return new Response("Not a member of this org", { status: 401 }); - } - - const url = new URL(request.url); - const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskRun"`); - // Strip params we set ourselves so the caller can't override them. - url.searchParams.forEach((value, key) => { - if (RESERVED_ELECTRIC_SHAPE_PARAMS.has(key)) return; - originUrl.searchParams.set(key, value); - }); - - originUrl.searchParams.set( - "where", - // Scope by non-null projectId, not the nullable organizationId (legacy - // rows would vanish). Tenant-safe: membership was verified against this - // project's org and a trace's runs all live in one project. - buildElectricTraceWhereClause({ - traceId, - scope: { column: "projectId", id: run.projectId }, - }) - ); - - const finalUrl = originUrl.toString(); - - logger.log("Fetching trace runs data", { url: finalUrl }); - - return longPollingFetch(finalUrl); - } catch (error) { - if (error instanceof Response) { - // Error responses from longPollingFetch - return error; - } else if (error instanceof TypeError) { - // Unexpected errors - logger.error("Unexpected error in loader:", { error: error.message }); - return new Response("An unexpected error occurred", { status: 500 }); - } else { - // Unknown errors - logger.error("Unknown error occurred in loader, not Error", { error: JSON.stringify(error) }); - return new Response("An unknown error occurred", { status: 500 }); - } - } -} diff --git a/apps/webapp/app/v3/electricShape.server.ts b/apps/webapp/app/v3/electricShape.server.ts index c430cc0273..65d52032af 100644 --- a/apps/webapp/app/v3/electricShape.server.ts +++ b/apps/webapp/app/v3/electricShape.server.ts @@ -1,47 +1,3 @@ -import { z } from "zod"; - -/** - * OTel trace IDs are 32 lowercase hex chars. The traceparent parser only - * checks the dash-delimited format, so crafted ids can be persisted and later - * interpolated into shape `where` clauses. Validate here to close the SQLi vector. - */ -export const OtelTraceIdSchema = z - .string() - .regex(/^[0-9a-f]{32}$/, "traceId must be 32 lowercase hex characters"); - -/** Params the sync routes set themselves; stripped from incoming requests. */ -export const RESERVED_ELECTRIC_SHAPE_PARAMS = new Set(["where", "table", "columns"]); - -const CUID_LIKE = /^[a-z][a-z0-9_]*$/i; - -/** - * Tenant column a trace shape is scoped by. TaskEvent scopes by non-null - * organizationId; TaskRun scopes by non-null projectId (its organizationId is - * nullable). The column is from this fixed union, never user input, so it's - * safe to interpolate. - */ -export type TraceScope = - | { column: "organizationId"; id: string } - | { column: "projectId"; id: string }; - -/** - * Build the Electric Shape `where` clause for the trace sync routes. Both ids - * are re-validated as defense-in-depth so a missed call site can't bypass scope. - */ -export function buildElectricTraceWhereClause(args: { - traceId: string; - scope: TraceScope; -}): string { - const { traceId, scope } = args; - if (!OtelTraceIdSchema.safeParse(traceId).success) { - throw new Error("buildElectricTraceWhereClause: unsafe traceId"); - } - if (!CUID_LIKE.test(scope.id)) { - throw new Error("buildElectricTraceWhereClause: unsafe scope id"); - } - return `"traceId"='${traceId}' AND "${scope.column}"='${scope.id}'`; -} - /** * Characters rejected in realtime tag values — the single source of truth * shared by the apiBuilder Zod refine (`realtime.v1.runs.ts`) and the runtime diff --git a/apps/webapp/test/spanTraceRoutes.replicaLag.test.ts b/apps/webapp/test/spanTraceRoutes.replicaLag.test.ts index 6b8dac2793..3c81ab3c5e 100644 --- a/apps/webapp/test/spanTraceRoutes.replicaLag.test.ts +++ b/apps/webapp/test/spanTraceRoutes.replicaLag.test.ts @@ -2,12 +2,11 @@ // route caller (never a reimplemented read) against a real split Postgres with the owning LEGACY replica // FROZEN via laggingReplica. The routes pass a branded $replica, so reads stay on the owning replica. // Only orthogonal webapp singletons are mocked (bearer auth, mollifier buffer, ClickHouse repository, -// formatters, session cookie, control-plane resolver, longPollingFetch). +// formatters). // // Properties per read: the spans/trace findResource reads emit a RETRYABLE 404 (x-should-retry:true) on a // replica+buffer miss and return 200 once the replica catches up (proven with a caught-up store); the -// triggeredRuns list simply omits a just-triggered child under lag (200, eventually consistent); the sync -// trace-runs loader recovers a live run via a primary re-read on a replica miss and still 404s a truly-absent run. +// triggeredRuns list simply omits a just-triggered child under lag (200, eventually consistent). import { describe, expect, vi } from "vitest"; import { heteroRunOpsPostgresTest, laggingReplica } from "@internal/testcontainers"; @@ -20,20 +19,15 @@ vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); // ---- Hoisted holders wired into the mocked module singletons before each loader call. ------------- // The branded `$replica` marker uses the global-registry symbol the run-store brands replicas with -// (readReplicaClient.ts) so the routing store keeps the read on the owning REPLICA. It also carries a -// live `orgMember` delegate (pointed at the real prisma14) because the sync route reads -// `$replica.orgMember.findFirst` directly — orthogonal to the run read under test. +// (readReplicaClient.ts) so the routing store keeps the read on the owning REPLICA. const { holder } = vi.hoisted(() => { const REPLICA_BRAND = Symbol.for("trigger.dev/run-store/read-replica"); return { holder: { REPLICA_BRAND, store: undefined as unknown, - replicaMarker: undefined as unknown, environment: undefined as unknown, bufferResult: null as unknown, - userId: undefined as unknown, - resolvedEnv: undefined as unknown, span: undefined as unknown, traceSummary: undefined as unknown, }, @@ -57,7 +51,7 @@ vi.mock("~/v3/runStore.server", () => ({ ), })); -// `$replica` brand marker (routes it to the owning replica) + a live orgMember delegate for the sync route. +// `$replica` brand marker: routes the read to the owning replica. vi.mock("~/db.server", () => ({ prisma: {}, $replica: new Proxy( @@ -65,8 +59,7 @@ vi.mock("~/db.server", () => ({ { get(_t, prop) { if (prop === holder.REPLICA_BRAND) return true; - const marker = holder.replicaMarker as Record | undefined; - return marker ? marker[prop] : undefined; + return undefined; }, } ), @@ -108,27 +101,8 @@ vi.mock("~/v3/mollifier/syntheticApiResponses.server", () => ({ buildSyntheticTraceBody: (r: unknown) => ({ synthetic: true, run: r }), })); -// Sync-route peripherals (orthogonal to the run read under test). -vi.mock("~/services/session.server", () => ({ - getUserId: async () => holder.userId, -})); -vi.mock("~/v3/runOpsMigration/controlPlaneResolver.server", () => ({ - controlPlaneResolver: { resolveEnv: async () => holder.resolvedEnv }, -})); -vi.mock("~/utils/longPollingFetch", () => ({ - longPollingFetch: async () => - new Response("shape-stream-ok", { status: 200, headers: { "x-longpoll": "hit" } }), -})); -// Ensure ELECTRIC_ORIGIN is defined for the sync route's happy path; keep every other env value real -// so the heavy apiBuilder import graph still loads. -vi.mock("~/env.server", async (importOriginal) => { - const actual = (await importOriginal()) as { env: Record }; - return { env: { ...actual.env, ELECTRIC_ORIGIN: "http://electric.test" } }; -}); - import { loader as spansLoader } from "~/routes/api.v1.runs.$runId.spans.$spanId"; import { loader as traceLoader } from "~/routes/api.v1.runs.$runId.trace"; -import { loader as syncTraceRunsLoader } from "~/routes/sync.traces.runs.$traceId"; // A cuid (25 chars after `run_`) classifies LEGACY, so both the create and the friendlyId/traceId // reads route to the legacy (control-plane) store — the store that owns these runs. @@ -266,13 +240,6 @@ function traceRequest(runId: string) { context: {} as never, }; } -function syncRequest(traceId: string) { - return { - request: new Request(`https://app.trigger.dev/sync/traces/runs/${traceId}?live=true`), - params: { traceId }, - context: {} as never, - } as never; -} describe("run-trace/span-detail route loaders under a lagging replica", () => { // spans loader — findResource findRun ($replica) @@ -466,81 +433,4 @@ describe("run-trace/span-detail route loaders under a lagging replica", () => { expect(body2.trace).toEqual({ rootSpanId: `span_${suffix}`, spans: [] }); } ); - - // sync trace-runs loader — findRun by traceId ($replica), with a primary fallback - heteroRunOpsPostgresTest( - "sync trace-runs loader: a live run lagging the replica is recovered via the primary fallback", - async ({ prisma14, prisma17 }) => { - const suffix = `sync_${seq++}`; - const seed = await seedTenant(prisma14, suffix); - const runId = `run_${CUID_25}`; - const friendlyId = `run_${suffix}`; - const traceId = "a".repeat(32); - const userId = `user_${suffix}`; - - // The dashboard user, joined to the org so the route's real orgMember check passes. - await prisma14.user.create({ - data: { id: userId, email: `u-${suffix}@example.com`, authenticationMethod: "MAGIC_LINK" }, - }); - await prisma14.orgMember.create({ - data: { userId, organizationId: seed.organization.id, role: "ADMIN" }, - }); - - const lagged = buildRouter(prisma14, prisma17, [{ model: "taskRun", mode: "missing" }]); - await lagged.legacyStore.createRun( - buildCreateRunInput({ - runId, - friendlyId, - organizationId: seed.organization.id, - projectId: seed.project.id, - runtimeEnvironmentId: seed.environment.id, - traceId, - spanId: `span_${suffix}`, - }) - ); - - holder.store = lagged.router; - holder.environment = authEnvironment(seed); - holder.userId = userId; - holder.resolvedEnv = { organizationId: seed.organization.id }; - holder.replicaMarker = { orgMember: prisma14.orgMember }; - - const res = (await syncTraceRunsLoader(syncRequest(traceId))) as Response; - - // The frozen replica WAS consulted (the lag was really exercised). - expect(lagged.legacyReplica.wasHit("taskRun")).toBe(true); - - // The primary fallback recovers the live run and the loader proceeds to the shape stream (200). - expect(res.status).toBe(200); - expect(res.headers.get("x-longpoll")).toBe("hit"); - } - ); - - // Negative control for the sync route: a truly-absent run must still 404 (the primary fallback - // recovers a LIVE run, it does not turn every miss into a 200). - heteroRunOpsPostgresTest( - "sync trace-runs loader: 404s when the run is absent on the primary too", - async ({ prisma14, prisma17 }) => { - const suffix = `sync_absent_${seq++}`; - const seed = await seedTenant(prisma14, suffix); - const userId = `user_${suffix}`; - await prisma14.user.create({ - data: { id: userId, email: `u-${suffix}@example.com`, authenticationMethod: "MAGIC_LINK" }, - }); - await prisma14.orgMember.create({ - data: { userId, organizationId: seed.organization.id, role: "ADMIN" }, - }); - - const lagged = buildRouter(prisma14, prisma17, [{ model: "taskRun", mode: "missing" }]); - holder.store = lagged.router; - holder.environment = authEnvironment(seed); - holder.userId = userId; - holder.resolvedEnv = { organizationId: seed.organization.id }; - holder.replicaMarker = { orgMember: prisma14.orgMember }; - - const res = (await syncTraceRunsLoader(syncRequest("b".repeat(32)))) as Response; - expect(lagged.legacyReplica.wasHit("taskRun")).toBe(true); - expect(res.status).toBe(404); - } - ); }); diff --git a/internal-packages/run-store/src/runOpsStore.routesSpanTraceReadView.replicaLag.test.ts b/internal-packages/run-store/src/runOpsStore.routesSpanTraceReadView.replicaLag.test.ts index 15754d5572..5a5ecb4a79 100644 --- a/internal-packages/run-store/src/runOpsStore.routesSpanTraceReadView.replicaLag.test.ts +++ b/internal-packages/run-store/src/runOpsStore.routesSpanTraceReadView.replicaLag.test.ts @@ -1,5 +1,5 @@ -// Lagging-replica coverage for the SPAN-DETAIL / TRACE / TRACE-SYNC route read views on the run-ops -// split. Four run lookups back three GET routes; NONE is a read-your-writes mutation gate — every one +// Lagging-replica coverage for the SPAN-DETAIL / TRACE route read views on the run-ops +// split. Three run lookups back two GET routes; NONE is a read-your-writes mutation gate — every one // resolves a run for a DISPLAY/GET response and tolerates a stale/missing read by a named caller // mechanism. This builds the store as each route holds it (`runStore` via a RoutingRunStore), freezes // the OWNING store's replica with the shared laggingReplica primitive, invokes each read EXACTLY as @@ -22,8 +22,6 @@ // 2. spans.$spanId findRuns({where:{runtimeEnvironmentId, parentSpanId}, take:50, select}) — open // predicate → both stores' replicas, merged. // 3. trace findRun({friendlyId, runtimeEnvironmentId}) — same shape as read 1. -// 4. sync.traces findRun({traceId}, {select:{runtimeEnvironmentId}}) — unclassifiable where → -// unrouted lookup (NEW-first then LEGACY, both replicas). // // Real split topology via heteroRunOpsPostgresTest — NEVER mocked. The laggingReplica primitive // freezes findFirst/findMany/findUnique on a chosen Prisma model so a replica-routed read misses the @@ -136,7 +134,7 @@ function makeRouters(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) { return { laggingRouter, healthyRouter, legacyReplica }; } -describe("run-ops split — span-detail / trace / trace-sync route read views vs. a lagging replica", () => { +describe("run-ops split — span-detail / trace route read views vs. a lagging replica", () => { // --- spans.$spanId findPgRun -------------------------------------------------------------------- // findResource resolves the run for the span-detail GET. Under lag the owning (legacy) replica // returns null; the route falls through to the mollifier buffer fallback and, failing that, returns @@ -273,50 +271,4 @@ describe("run-ops split — span-detail / trace / trace-sync route read views vs expect(recovered!.friendlyId).toBe(friendlyId); } ); - - // --- sync.traces findRun ------------------------------------------------------------------------ - // The dashboard trace-sync loader resolves a run by traceId to authorize the user + resolve the - // env, then long-polls Electric for the "TaskRun" shape. The where carries ONLY traceId - // (unclassifiable) → unrouted lookup (NEW-first then LEGACY, both on their replicas). Under lag the - // owning replica returns null → the loader returns a 404 "No run found". This is a live-sync loader - // the dashboard re-establishes (long-poll); a run reaching it is user-navigation sourced and has - // existed for seconds, so the stale-null window is transient and re-polled. Tolerated (display/sync - // loader, re-polled; no mutation). Store fact under lag: null. - heteroRunOpsPostgresTest( - "sync.traces findRun(traceId, select, branded $replica) is NULL under owning-replica lag; caught-up replica recovers the run (re-polled sync loader tolerates the stale 404)", - async ({ prisma14, prisma17 }) => { - const { laggingRouter, healthyRouter, legacyReplica } = makeRouters(prisma14, prisma17); - const seed = await seedEnvironment(prisma14, "trace_sync"); - const runId = `run_${CUID_25}`; - const traceId = "trace_sync_unique"; - await prisma14.taskRun.create({ - data: taskRunData({ - id: runId, - friendlyId: `run_tracesync00000000000000`, - organizationId: seed.organization.id, - projectId: seed.project.id, - runtimeEnvironmentId: seed.environment.id, - traceId, - spanId: "span_trace_sync", - }), - }); - - // Exactly the call site: findRun({traceId}, {select:{runtimeEnvironmentId}}, $replica). - const stale = await laggingRouter.findRun( - { traceId }, - { select: { runtimeEnvironmentId: true } }, - BRANDED_REPLICA - ); - expect(stale).toBeNull(); - expect(legacyReplica.wasHit()).toBe(true); - - const recovered = (await healthyRouter.findRun( - { traceId }, - { select: { runtimeEnvironmentId: true } }, - BRANDED_REPLICA - )) as { runtimeEnvironmentId: string } | null; - expect(recovered).not.toBeNull(); - expect(recovered!.runtimeEnvironmentId).toBe(seed.environment.id); - } - ); }); From 44eca4d1665e52e09495a57082ab42c7c240ad9a Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 28 Jul 2026 11:28:09 +0200 Subject: [PATCH 23/23] feat(webapp): org-gated internal API origin in run env vars (#4366) Adds an opt-in way for operators to route deployed runs' API traffic through a different origin than the public one, per organization. Set `INTERNAL_API_ORIGIN` on the webapp and enable the `internalApiOriginEnabled` feature flag (globally or per org, with the org override winning in both directions): deployed runs for enabled orgs then get `TRIGGER_API_URL` set to the internal origin instead of `API_ORIGIN`. Useful for gradually moving run traffic onto a private network path. ## Design The origin is resolved when an attempt starts, so flag changes take effect on the next attempt and roll back the same way, with no task redeploys. The org override is read fresh per attempt; the global default comes from the cached flags registry (a cold read fails safe to the public origin). When `INTERNAL_API_ORIGIN` is unset the flag is a no-op and no extra queries run, so existing deployments are unaffected. Dev runs always use the public origin, and `TRIGGER_STREAM_URL` remains unchanged. --- .server-changes/internal-api-origin-flag.md | 6 ++ apps/webapp/app/env.server.ts | 5 ++ .../api.v1.projects.$projectRef.envvars.ts | 3 + .../environmentVariablesRepository.server.ts | 33 ++++++++- apps/webapp/app/v3/featureFlags.ts | 36 +++++++++ .../worker/workerGroupTokenService.server.ts | 2 + apps/webapp/test/internalApiOrigin.test.ts | 73 +++++++++++++++++++ 7 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 .server-changes/internal-api-origin-flag.md create mode 100644 apps/webapp/test/internalApiOrigin.test.ts diff --git a/.server-changes/internal-api-origin-flag.md b/.server-changes/internal-api-origin-flag.md new file mode 100644 index 0000000000..48b26ba853 --- /dev/null +++ b/.server-changes/internal-api-origin-flag.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Self-hosted instances can now serve deployed runs' API traffic from a different origin than the public one, per organization, via the `INTERNAL_API_ORIGIN` environment variable and a feature flag. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 2d28827f7d..f2240bd681 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -233,6 +233,11 @@ const EnvironmentSchema = z LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true), APP_ORIGIN: z.string().default("http://localhost:3030"), API_ORIGIN: z.string().optional(), + // Alternative API origin for deployed runs whose org has the + // internalApiOriginEnabled feature flag on. Unset = flag is a no-op. + INTERNAL_API_ORIGIN: z.string().optional(), + // Global default for internalApiOriginEnabled when an org hasn't set it. + INTERNAL_API_ORIGIN_ENABLED: z.string().default("0"), STREAM_ORIGIN: z.string().optional(), ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"), // A comma separated list of electric origins to shard into different electric instances by environmentId diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts index 151c182e16..4fc8b680d1 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts @@ -1,6 +1,7 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; import { resolveVariablesForEnvironment } from "~/v3/environmentVariables/environmentVariablesRepository.server"; @@ -44,6 +45,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, include: { parentEnvironment: true, + // Feeds resolveProdApiOrigin; only loaded when internal-origin routing is possible. + ...(env.INTERNAL_API_ORIGIN ? { organization: { select: { featureFlags: true } } } : {}), }, }); diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 1a0b824734..c4dd631470 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -7,6 +7,8 @@ import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; import { removeBlacklistedVariables } from "../environmentVariableRules.server"; +import { FEATURE_FLAG, resolveInternalApiOriginEnabled } from "../featureFlags"; +import { globalFlagsRegistry } from "../globalFlagsRegistry.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type CreateEnvironmentVariables, @@ -934,7 +936,7 @@ export type RuntimeEnvironmentForEnvRepo = Pick< | "organizationId" | "branchName" | "builtInEnvironmentVariableOverrides" ->; +> & { organization?: { featureFlags: unknown } | null }; export const environmentVariablesRepository = new EnvironmentVariablesRepository(); @@ -1146,10 +1148,35 @@ async function resolveOverridableOtelDevVariables( return result; } +// Deployed runs normally get the public API origin. When INTERNAL_API_ORIGIN is +// set and the org's internalApiOriginEnabled flag resolves on (org override wins +// in both directions; INTERNAL_API_ORIGIN_ENABLED is the global default applied +// only when the org has not set it), they get the internal origin instead. The +// global default is the cached DB flag with INTERNAL_API_ORIGIN_ENABLED as the +// fallback; org flags are read in-memory, so a flip applies on the next attempt. +function resolveProdApiOrigin(runtimeEnvironment: RuntimeEnvironmentForEnvRepo): string { + const publicOrigin = env.API_ORIGIN ?? env.APP_ORIGIN; + + if (!env.INTERNAL_API_ORIGIN) { + return publicOrigin; + } + + const enabled = resolveInternalApiOriginEnabled({ + orgFeatureFlags: runtimeEnvironment.organization?.featureFlags, + globalDefault: + globalFlagsRegistry.current()?.[FEATURE_FLAG.internalApiOriginEnabled] ?? + env.INTERNAL_API_ORIGIN_ENABLED === "1", + }); + + return enabled ? env.INTERNAL_API_ORIGIN : publicOrigin; +} + async function resolveBuiltInProdVariables( runtimeEnvironment: RuntimeEnvironmentForEnvRepo, parentEnvironment?: RuntimeEnvironmentForEnvRepo ) { + const apiOrigin = resolveProdApiOrigin(runtimeEnvironment); + let result: Array = [ { key: "TRIGGER_SECRET_KEY", @@ -1157,9 +1184,11 @@ async function resolveBuiltInProdVariables( }, { key: "TRIGGER_API_URL", - value: env.API_ORIGIN ?? env.APP_ORIGIN, + value: apiOrigin, }, { + // Deliberately not switched by internalApiOriginEnabled: streams are + // long-lived connections served on their own path. key: "TRIGGER_STREAM_URL", value: env.STREAM_ORIGIN ?? env.API_ORIGIN ?? env.APP_ORIGIN, }, diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 49a18eaf35..22e5541d56 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -12,6 +12,7 @@ export const FEATURE_FLAG = { hasSso: "hasSso", mollifierEnabled: "mollifierEnabled", workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled", + internalApiOriginEnabled: "internalApiOriginEnabled", realtimeBackend: "realtimeBackend", computeMigrationEnabled: "computeMigrationEnabled", computeMigrationFreePercentage: "computeMigrationFreePercentage", @@ -38,6 +39,12 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.hasSso]: z.coerce.boolean(), [FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(), [FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(), + // Routes deployed runs' TRIGGER_API_URL to INTERNAL_API_ORIGIN. Per-org, with + // INTERNAL_API_ORIGIN_ENABLED as the global default (org wins). No-op unless + // INTERNAL_API_ORIGIN is set. + // Strict z.boolean(): coercion turns the string "false" into true, which + // would silently enable the wrong orgs if written as a string. + [FEATURE_FLAG.internalApiOriginEnabled]: z.boolean(), // Which backend serves the realtime run feed. Controllable // globally and per-org (org wins). Defaults to "electric" when unset. // "shadow" serves Electric but diffs the native path in the background. @@ -104,6 +111,35 @@ export function validatePartialFeatureFlags(values: Record) { } // Utility types for catalog-driven UI rendering +/** + * Resolve whether deployed runs should use the internal API origin, from the + * org's feature-flags JSON. Precedence: a per-org override wins in BOTH + * directions; the global default applies only when the org has not set the + * flag (or set it to something invalid). + */ +export function resolveInternalApiOriginEnabled({ + orgFeatureFlags, + globalDefault, +}: { + orgFeatureFlags: unknown; + globalDefault: boolean; +}): boolean { + const override = + orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags) + ? (orgFeatureFlags as Record)[FEATURE_FLAG.internalApiOriginEnabled] + : undefined; + + if (override !== undefined) { + const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override); + + if (parsed.success) { + return parsed.data; + } + } + + return globalDefault; +} + export type FlagControlType = | { type: "boolean" } | { type: "enum"; options: string[] } diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts index ba4741a5ec..0925c38676 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts @@ -544,6 +544,8 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { }, include: { parentEnvironment: true, + // Feeds resolveProdApiOrigin; only loaded when internal-origin routing is possible. + ...(env.INTERNAL_API_ORIGIN ? { organization: { select: { featureFlags: true } } } : {}), }, }); diff --git a/apps/webapp/test/internalApiOrigin.test.ts b/apps/webapp/test/internalApiOrigin.test.ts new file mode 100644 index 0000000000..6971f92960 --- /dev/null +++ b/apps/webapp/test/internalApiOrigin.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { resolveInternalApiOriginEnabled } from "~/v3/featureFlags"; + +describe("resolveInternalApiOriginEnabled", () => { + it("returns the global default when the org has no flags", () => { + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: null, globalDefault: false })).toBe( + false + ); + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: null, globalDefault: true })).toBe( + true + ); + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: {}, globalDefault: true })).toBe( + true + ); + }); + + it("lets an org override win in both directions", () => { + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: true }, + globalDefault: false, + }) + ).toBe(true); + + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: false }, + globalDefault: true, + }) + ).toBe(false); + }); + + it("ignores invalid overrides and falls back to the global default", () => { + // Strict z.boolean(): the string "false" must not coerce to an enable. + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: "false" }, + globalDefault: false, + }) + ).toBe(false); + + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: "true" }, + globalDefault: false, + }) + ).toBe(false); + + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: 1 }, + globalDefault: false, + }) + ).toBe(false); + + // The fallback must follow the global default, not hardcode false. + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: "false" }, + globalDefault: true, + }) + ).toBe(true); + }); + + it("ignores non-object flag containers", () => { + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: [], globalDefault: true })).toBe( + true + ); + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: "junk", globalDefault: false })).toBe( + false + ); + }); +});