Commit Graph

4696 Commits

Author SHA1 Message Date
nicktrn f3c46f140e chore(deps): raise nanoid floors, drop unused declarations (#4637)
## Summary

`nanoid` was pinned at exactly `3.3.8` in five manifests. Two of those
five never imported it: in `internal-packages/schedule-engine` and
`internal-packages/webhook-engine` the only occurrence of the string
`nanoid` in the entire package was the `package.json` line itself. Both
are removed rather than bumped.

The three that genuinely use it move to `3.3.18`, a version already
present in the tree via `postcss`, so this pulls in nothing new.

| Package | Uses it | Change |
| --- | --- | --- |
| `internal-packages/schedule-engine` | no | removed |
| `internal-packages/webhook-engine` | no | removed |
| `apps/webapp` | yes | `3.3.8` to `3.3.18` |
| `packages/core` | yes | `3.3.8` to `3.3.18` |
| `internal-packages/run-engine` | yes | `3.3.8` to `3.3.18` |
| `packages/redis-worker` | yes | `^5.0.7` to `^5.1.16` |

`redis-worker` is on the 5.x line and is included because its declared
range already permitted a newer release; the lockfile had simply not
re-resolved, leaving it on `5.1.2`.

The unused declarations were found with `pnpm run knip:deps`, which the
repo already ships.

`pnpm run typecheck` passes across all 57 workspaces.
2026-08-16 22:12:18 +01:00
nicktrn 148615b526 chore(webapp,supervisor,core): move socket.io to 4.8.3 (#4635)
## Summary

`socket.io` was pinned at exactly `4.7.4` in three manifests
(`apps/webapp`, `apps/supervisor`, `packages/core`). That pin capped
`engine.io` at 6.5.4, because 4.7.4 declares `engine.io: ~6.5.2`.

Moving all three pins to `4.8.3` lifts that cap: 4.8.3 declares
`engine.io: ~6.6.0`. The webapp's direct `engine.io` devDependency moves
from `^6.5.4` to `^6.6.7` to match.

These are direct dependencies, so they are bumped in place rather than
forced with an override.

## Result

The tree previously carried two `engine.io` copies. It now carries one:

```
engine.io@6.6.8
└─┬ socket.io@4.8.3
  ├── @trigger.dev/core (dependencies)
  ├─┬ react-email
  │ └── emails (devDependencies)
  ├── supervisor (dependencies)
  └── webapp (dependencies)
```

`react-email` was already resolving `socket.io@4.8.3` in this same tree,
so that combination was already running here before this change.

## Servers move, clients do not

This bumps `socket.io` (the server) only. `socket.io-client` stays at
`4.7.5` in `packages/core` and `packages/cli-v3`, deliberately: the fix
is server-side, and clients ship inside user deployments, so leaving
them alone keeps the blast radius small. That means a 4.8.3 server will
be talking to 4.7.5 clients indefinitely, which is worth being explicit
about.

That pairing is safe because neither wire protocol changed. Both
versions report the same protocol numbers:

| | 4.7.4 | 4.8.3 |
| --- | --- | --- |
| Socket.IO protocol (`socket.io-parser`) | 5 | 5 |
| Engine.IO protocol (`engine.io-parser`) | 4 | 4 |

The version bump moves `socket.io-parser` 4.2.6 to 4.2.7 and `engine.io`
6.5.4 to 6.6.8, but the protocol constants each exports are unchanged.
The 4.8.0 changes are additive on the client (custom transport
implementations, a `tryAllTransports` option) and bug fixes on the
server.

Verified rather than assumed, with a cross-version matrix covering both
transports and both directions:

```
PASS  server 4.8.3 <- client 4.7.5   websocket / polling
PASS  server 4.8.3 <- client 4.8.3   websocket / polling
PASS  server 4.7.4 <- client 4.7.5   websocket / polling
PASS  server 4.7.4 <- client 4.8.3   websocket / polling
```

Each case exercised connect, a server-initiated emit, `emitWithAck`,
room join, room broadcast, and a binary payload. Compatibility holds in
both directions, so there is no upgrade-ordering requirement between
server and client.

`pnpm run typecheck` passes across all 57 workspaces.

Stacked on #4634.
2026-08-16 21:11:34 +01:00
nicktrn a34d23973e chore(webapp): replace npm-run-all with an explicit build chain (#4633)
## Summary

`npm-run-all` has had no release since 4.1.5 in 2018, and pnpm now
covers the one thing we used it for. The webapp's `build` script was its
only consumer anywhere in the repo, so the dependency goes away
entirely.

`run-s build:**` becomes an explicit chain:

```
pnpm run build:remix && pnpm run build:server && pnpm run build:otlpworker && pnpm run build:sentry && pnpm run upload:sourcemaps
```

## Why this shape

I compared both forms side by side against the real `run-s` before
swapping:

| Behaviour | `run-s build:**` | explicit chain |
| --- | --- | --- |
| Scripts selected | remix, server, otlpworker, sentry | identical |
| Order | declaration order | identical |
| `upload:sourcemaps` matched by the glob | no | no |
| Second script fails | aborts, third never runs | identical |
| Exit code on failure | `1` | `1` |

`pnpm run --sequential "/^build:/"` was the closer-looking option, but
it keeps running scripts after one fails, so it is not a faithful
replacement.

The one thing given up is that `build:**` automatically picked up any
new `build:*` script, where the chain has to be edited. With four
entries that felt like the better trade.

`pnpm run build --filter webapp` passes end to end locally, all five
steps in order.
2026-08-16 21:11:33 +01:00
nicktrn 512a619ea8 fix(webapp): back to app returns to the current org (#4632)
## Summary

Following a link straight into an organization's settings (for example
the usage limit link in a billing email) and then clicking "Back to app"
took you to `/`, which resolves to whichever organization you last had
selected, not the one whose settings you were looking at. The button now
links to the organization in the URL, so you land back in the org you
came from.

The org index route already redirects to the best project in that org,
so the destination is unchanged apart from being the right org.

Account settings still links to `/`, since that page is not org scoped
and has no org to return to.
2026-08-16 19:16:39 +01:00
Eric Allam c0b84595a3 feat(webapp): hosted webhook ingress, delivery pipeline, and dashboard (#4344)
## Summary

The server half of hosted webhooks: the public ingress endpoint,
signature verification, the delivery pipeline (Postgres partitioned
storage + ClickHouse for ordering), the in-app partition manager, the
HTTP API, and the dashboard (Deliveries, Endpoints, and the in-app test
console).

The public SDK and docs half is #4537. That PR carries the user-facing
API (`webhook()`, `chat.event` / `chat.channels`, the
`@trigger.dev/slack` connector) and builds on the shared
`@trigger.dev/core` schemas that ship here.

## Shipping behind a flag

A `WEBHOOK_ENABLED` env var (default off) gates the public ingress route
and the engine worker plus partition cron, so merging and deploying this
changes nothing in production until it is flipped on per environment.
The dashboard is separately gated per org by the `hasWebhooksAccess`
feature flag.

## Note on packages

This PR includes the `@trigger.dev/core` schema additions the server
compiles against, but carries no changeset. Core is not consumed
independently of the SDK, so it is released together with the SDK via
#4537. Keeping its changeset off `main` means no release cut from `main`
publishes it early.
2026-08-16 14:33:42 +01:00
Eric Allam b98dd79fe4 feat(webapp,run-store,database): env-configurable transaction resilience (maxWait + tx-start retry) (#4623)
## What

Makes two transaction-resilience behaviors real and env-var
configurable, defaults set to the good values, so we can tune during and
after the Aug 15 database patch window without a redeploy:

- **maxWait 2s → 10s** (TRI-12982): how long Prisma waits to borrow a
connection before it can `BEGIN`. A restart freeze holds the pool full,
and the only thing that errored was transaction starts giving up at 2s.
- **Retry transaction-start P2028-at-acquisition** (TRI-12984): when
Prisma can't borrow a connection within `maxWait` it raises P2028
(`Unable to start a transaction in the given time`) and **no SQL ran**,
so retrying is safe. Scoped narrowly: only that error (never P2024
pool-exhaustion), 2 attempts, jittered backoff, and a token-bucket
budget so a mass freeze can't amplify into a retry storm.

## Env vars (`DATABASE_*` convention)

Generic defaults:

| var | default |
|---|---|
| `DATABASE_TRANSACTION_MAX_WAIT_MS` | `10000` |
| `DATABASE_TRANSACTION_START_RETRY_ENABLED` | `true` (kill switch) |
| `DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS` | `2` |
| `DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS` | `50` |
| `DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS` | `250` |
| `DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC` | `50` |
| `DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST` | `100` |

Per-writer-pool overrides, each falling back to the generic when unset
(same pattern as the per-client pool/connect-timeout work):
`RUN_OPS_DATABASE_TRANSACTION_*` and
`RUN_OPS_LEGACY_DATABASE_TRANSACTION_*` (all 7 knobs each). Transactions
only open on writer pools, so those are the only pools with their own
knobs. Each pool gets its **own** token bucket, so a storm on one pool
can't drain another's retry budget.

## Design

- The retry primitives live in `internal-packages/database` and never
read `process.env` (IoC): a P2028-at-acquisition classifier, a
`TokenBucketRetryBudget`, and `withTransactionStartRetry`, folded into
the `$transaction` helper via a new `startRetry` option. Config is
resolved at the app boundary and threaded in.
- The `$transaction` helper is the chokepoint (wraps the whole
transaction), not the per-statement `$allOperations` extension.
- The run engine's writes go through `PostgresRunStore`'s own
`.$transaction(...)`, not the webapp helper, so both the helper and the
two `PostgresRunStore` sites apply maxWait + retry (sharing the per-pool
config). Builds on the `options?: { timeout, maxWait }` seam added in
#4514.
- Webapp `$transaction` call sites get the default `maxWait` + retry
injected at one merge point, so no call site needed editing.

## Evidence

- Unit red/green in `internal-packages/database`: reverting the helper
wiring turned the acquisition-retry test red (`Unable to start a
transaction in the given time`), re-applying it green. Full package
suite 25/25. Covers: classifier (P2028-acq yes, P2024 no, in-tx P2028
no), retry (retry-then-succeed, no-retry P2024, stop at maxAttempts,
disabled, budget-exhausted, jitter bounds), token bucket, and
`$transaction` wiring.
- Typecheck clean: webapp, run-store, run-engine.
- Full-stack run: bounded queue-ay pass (15 projects, real dev runs
through the run-engine `PostgresRunStore` transaction path). 13 pass;
the 2 failures are one documented known-failure and one
stale-worker-state flake that passes 2/2 with this change active on a
fresh app.
- Boots cleanly with per-pool overrides set.

## Configuration & rollout

Ship **inert** first (zero behavior change), then flip to the good
values **live via env** — no redeploy needed for either.

### Inert — behaves exactly as today

```
DATABASE_TRANSACTION_MAX_WAIT_MS=2000            # Prisma's built-in default (change defaults to 10000)
DATABASE_TRANSACTION_START_RETRY_ENABLED=false   # disable the new retry entirely
```

`maxWait=2000` is what every path used before (Prisma's default; the
run-store sites and the helper passed no maxWait). `retry=false`
short-circuits `withTransactionStartRetry` to a single run and makes the
serialization-retry exclusion a no-op. Verified on the pooler-freeze
rig: identical fail-fast P2028 at ~2003ms with zero retries —
byte-for-byte current behavior, across all pools.

### Production ("good") — the baked defaults

Rely on defaults (nothing to set) or set explicitly:

```
DATABASE_TRANSACTION_MAX_WAIT_MS=10000
DATABASE_TRANSACTION_START_RETRY_ENABLED=true
DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS=3      # 3 attempts (2 retries); ~30s acquisition tolerance covers a ~20-25s freeze
DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS=50
DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS=250
DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC=50
DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST=100
```

Per-pool overrides `RUN_OPS_DATABASE_TRANSACTION_*` and
`RUN_OPS_LEGACY_DATABASE_TRANSACTION_*` (all seven knobs each) are
optional and fall back to the generic set — not needed for v1; the
generic set covers the control-plane, run-ops, and run-ops-legacy writer
pools. Readers open no transactions and take nothing.

**Guardrail:** the retry only engages when a pool's `pool_timeout` >
`maxWait`. Prod is fine (`DATABASE_POOL_TIMEOUT=60` >> 10). Do not set
any writer pool's `pool_timeout` at or under `maxWait`, or saturation
failures flip from retryable P2028 to non-retryable P2024 and the retry
silently stops helping.

### Rollback

Env flip (set inert) or revert. Retry only fires where no SQL ran, and
the per-pool token bucket caps a storm. No migration.

refs TRI-13295, TRI-12982, TRI-12984
2026-08-15 09:03:10 +01:00
claude[bot] 69f396fbef fix(webapp): keep paused environments paused when concurrency limits are pushed (#4625)
<!-- ccr-slack-attribution -->
_Requested by **Matt Aitken** · [Slack
thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1786732623292829?thread_ts=1786732623.292829&cid=C045W9WM3E1)_

**Before:** you pause an environment, then a deploy lands (or a
background worker is created, or an admin changes the
concurrency/burst-factor). The environment starts picking up runs again
even though the dashboard still shows it as paused.

**After:** a paused environment stays paused until it is resumed, no
matter what else pushes its concurrency limit.

Pausing an environment sets `paused` in the database and writes a `0`
env concurrency limit into the run queue — the `0` is the only thing
that actually stops dequeueing. Any caller that pushed the limit without
an explicit value (`finalizeDeployment`, `createBackgroundWorker`, the
two admin environment routes) rewrote the real limit and silently
un-paused the environment.

##  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

`apps/webapp/test/pauseEnvironment.server.test.ts` gains two
`containerTest` cases that wire a real `RunEngine` (real Redis) in place
of the stubbed app singleton and assert the actual run-queue env limit:

- pause a PRODUCTION env → limit is `0` → run the real
`FinalizeDeploymentService` → limit is still `0`, plus a control on a
running env in the same test proving that deploy path really does push
the limit (so the `0` can't just mean "nothing happened").
- pause → resume → the real limit is restored, so the clamp can't
regress resuming.

Both cases fail on `main` (`expected 17 to be +0` and `expected +0 to be
17`) and pass with this change. `pnpm run typecheck --filter webapp` is
clean.

---

## Changelog

Fix paused environments starting to run work again after a deploy.

---

## How

The clamp lives in the shared `updateEnvConcurrencyLimits` helper in
`apps/webapp/app/v3/runQueue.server.ts`, so every present and future
caller is covered: when no explicit limit is passed and the environment
is paused, `0` is written instead of the stored maximum. An
explicitly-passed limit still wins, which is what pausing itself relies
on. The resume path now passes the post-update environment state (its
in-memory copy was read before the un-pause and would otherwise be
clamped back to `0`), and the helper no longer mutates the caller's
environment object — that aliasing made a pause followed by a resume on
the same object write `0` twice. The existing `!paused` guards in
`allocateConcurrency` and the queue-level guard in
`createBackgroundWorker` are left in place as defence in depth, and
queue-level `TaskQueue.paused` behaviour is untouched.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-14 22:12:25 +01:00
Eric Allam dc8f90e66e fix(run-engine,webapp): resolve dequeue worker version fresh per task (#4622)
## Summary

After a deployment promotion or rollback, newly triggered runs could
keep dispatching onto the previously deployed version for up to 30
seconds. Runs now resolve the current version fresh on every dequeue, so
a promotion or rollback takes effect immediately.

## Fix

The dequeue path resolved the worker version through a 30s in-process
cache that nothing invalidated on promotion, and it loaded the worker's
entire task and queue set only to keep the single row matching the run.
Both go away: the resolve now fetches just the matched task and queue by
unique index and reads them fresh, so there is no cache left to serve a
stale version.

```
- cache.get(env:current)              # 30s TTL, never invalidated -> stale
- worker + ALL tasks + ALL queues
+ worker + one task WHERE slug=...  + one queue WHERE id/name=...   # fresh
```

A kill-switch env var (`RUN_OPS_WORKER_VERSION_FRESH_READ_ENABLED`,
default on) falls back to the old cached path without a code deploy.

Verified end-to-end on an isolated stack: a run triggered after a
mid-stream promotion now dequeues onto the new version, with the
previous stale behavior reproduced first.
2026-08-14 17:32:43 +01:00
Eric Allam dd78dd92ee perf(webapp): select only needed columns in dev current-worker lookup (#4621)
## Summary

When resolving the current worker for a development environment,
`findCurrentWorkerFromEnvironment` loaded the entire `BackgroundWorker`
row,
including the large `metadata` JSON, even though it only ever returns a
handful
of small fields. It is a frequently-run query, so the wasted payload
adds up:
every call pulled data it immediately threw away.

## Fix

Add a `select` to the development-environment lookup listing exactly the
fields
the function returns (`id`, `friendlyId`, `version`, `sdkVersion`,
`cliVersion`,
`supportsLazyAttempts`, `engine`). The query plan is unchanged, still a
single-row indexed lookup; only the row width shrinks. No behavior
change: the
dropped columns were never read.
2026-08-14 16:12:50 +01:00
Eric Allam 8dc8e1b58b perf(run-engine,webapp): narrow the control-plane worker-version read to the columns dequeue uses (#4619)
## Summary

The worker-version resolve path fetched every column of every
`BackgroundWorkerTask` for a worker (`include: { tasks: true }`), plus
full `WorkerDeployment` and `TaskQueue` rows, just to match one task at
dequeue. That pulls large JSON columns none of this path reads (task
`payloadSchema`/`config`/`queueConfig`/`description`, deployment
`externalBuildData`/`buildServerMetadata`/`errorData`/`git`, queue
`rateLimit`), so each resolve transfers and deserializes far more than
it uses.

## Fix

Replace the includes with explicit `select`s of only the columns dequeue
reads, in both the passthrough resolver and the app resolver:

- task: `id`, `slug`, `machineConfig`, `retryConfig`,
`maxDurationInSeconds`
- deployment: `id`, `friendlyId`, `imageReference`, `imagePlatform`
- queue: `id`, `name` (the queue matcher keys on both)

The shared `ResolvedWorkerVersion` element types narrow to match
(mirrored in the cache), which also shrinks each cached worker-version
entry.

## Impact

The `tasks` read fetches every task of a worker to match one, so its
cost scales with task count and payload-schema size. For a worker with
~70 registered tasks, dropping the unread columns cuts the per-query
transfer roughly:

| Task shape | Before | After | Reduction |
|---|---|---|---|
| Light (no payload schema, small config) | ~28 KB | ~14 KB | ~54% |
| Typical (mixed schemas / config) | ~62 KB | ~14 KB | ~77% |
| Schema-heavy (large `payloadSchema`) | ~200 KB | ~14 KB | ~93% |

The `after` size is roughly fixed because the kept columns are small;
the win grows with how heavy the dropped JSON is. Narrowing `deployment`
(four JSON columns off a single row) and `queues` saves further on top.

No behavior change: pure read-shape narrowing, no flag and no schema
change, so rollback is a plain revert. Verified with a red/green
run-engine test that asserts the resolved task, deployment, and queue
carry only the used columns, plus the queue feature-matrix runs (batch,
retry-policy, machine-preset, plain trigger) that exercise the kept
columns.
2026-08-14 15:11:30 +01:00
Eric Allam 4c21af8669 feat(webapp): CI guard for unindexed onDelete cascade FK columns (#4618)
## What

A relation with `onDelete: Cascade | SetNull` whose child FK column has
no index makes every parent delete fire a cascade that sequentially
scans the whole child table. That has shipped three times recently and
had to be fixed after the fact (#4554 `ProjectAlert.channelId`, #4555
`EnvironmentVariableValue.valueReferenceId`, #4588
`PersonalAccessToken.userId`).

This adds a schema-aware CI guard that catches the next one before it
merges.

## How

`apps/webapp/scripts/fkCascadeIndexGuard.ts` parses both Prisma schemas
(`@trigger.dev/database`, `@internal/run-ops-database`) and flags any
`onDelete: Cascade | SetNull` relation whose leading FK scalar is not
the leading column of some index (`@@index` / `@@unique` / `@@id` /
field-level `@id`/`@unique`) on the child model. A leading FK column
lets the cascade's `WHERE fk = $1` use the index instead of a seq scan.

It is modeled on the existing `runOpsLegacyGuard` (same `--check` gate,
same baseline-regenerate pattern), and it is lighter: it only reads
`schema.prisma` as text, so its CI job needs no Prisma client generation
and no raised heap.

## Why a baseline, not a hard rule

Not every unindexed cascade FK is a live bug. When the parent is only
ever soft-deleted, the cascade never fires, so the missing index is
harmless. Hard vs soft delete lives in application code
(`parent.delete()` vs `parent.update({ deletedAt })`), not in the
schema, and a `deletedAt` column proves neither direction. So the guard
makes no such judgment: it flags every unindexed cascade FK uniformly
and carries a baseline of the 72 currently-accepted cases. Only
violations **not** in the baseline fail `--check`.

The value is the forcing function: a newly added cascade FK stops CI and
makes the author answer "is the parent ever hard-deleted?" Add the index
if yes; regenerate the baseline with a reason if no.

## Wiring

- `apps/webapp/package.json`: `guard:fk-cascade-index` script
(regenerate with no args, gate with `-- --check`).
- `.github/workflows/fk-cascade-guard.yml`: the reusable workflow.
- `.github/workflows/pr_checks.yml`: runs on webapp-affecting changes,
aggregated into `all-checks`.

## Verification

- The three already-fixed columns are correctly seen as indexed (absent
from the baseline).
- `--check` passes on the current schemas (72 baselined, 0 new).
- A synthetic new unindexed cascade FK fails with exit 1 and an
actionable message.
- Adding `@@index([fk])`, or a composite leading with the FK, clears it.
No false positives.
- `oxfmt` and `oxlint` clean on the new script.

## Rollback

Pure tooling addition, no runtime code, no schema or data change. Revert
to remove.
2026-08-14 13:55:38 +01:00
Eric Allam fe199f7f92 perf(webapp): aggregate admin notification interaction counts in the database (#4616)
## Summary

The notifications admin list loaded every interaction row for the
notifications on the current page just to show three per-notification
counters (seen, clicked, dismissed), then counted them in memory. On
notifications with many interactions this made the page slow to load and
heavy on memory, even though only 20 notifications are shown.

## Fix

Compute the counters in a single grouped aggregate in the database
instead, returning one row per notification rather than one row per
interaction:

```sql
SELECT "notificationId",
  COUNT(*) AS seen,
  COUNT(*) FILTER (WHERE "webappClickedAt" IS NOT NULL) AS clicked,
  COUNT(*) FILTER (WHERE "webappDismissedAt" IS NOT NULL OR "cliDismissedAt" IS NOT NULL) AS dismissed
FROM "PlatformNotificationInteraction"
WHERE "notificationId" IN (...)
GROUP BY "notificationId"
```

Behavior is unchanged; notifications with no interactions report zero.
2026-08-14 11:59:46 +01:00
Matt Aitken 949e9cf1ec fix(webapp): show the real app version instead of v0.0.0 in organization settings (#4611)
## Summary

Since the move from the Remix compiler to Vite
([#4188](https://github.com/triggerdotdev/trigger.dev/pull/4188)), the
"App version" on the organization settings page shows `v0.0.0` unless
the image was built from a semver release tag (which bakes in
`BUILD_APP_VERSION`). Self-hosted builds and any image built from `main`
are affected. This restores the real version.

## Root cause

The Vite SSR bundle resolves workspace packages to TS source via the
`@triggerdotdev/source` condition, so `@trigger.dev/core`'s `VERSION`
constant is bundled as its raw `"0.0.0"` placeholder.
`scripts/updateVersion.ts` still stamps the real version at build time,
but only into the packages' dist output, which the bundle no longer
reads. The old Remix compiler bundled the stamped dist, which is why
this used to work.

The fix is a small Vite plugin that applies the same substitution to the
source version modules of `@trigger.dev/core` and `@trigger.dev/sdk`
during bundling. Beyond the settings page, this also restores real
values in the `trigger-version` request header and the version
attributes the bundled packages emit.

Verified by building the server bundle and confirming the VERSION
constants carry the package versions, with no `"0.0.0"` occurrences left
in the build output.
2026-08-14 10:45:58 +01:00
Chris Arderne 3e7964e7fa feat: surface cron windows in webapp, cli, sdk (#4572)
## Summary

Adds execution-window product surfaces for both declarative and
imperative schedules.

- Declarative schedules can set `window` through `schedules.task()`,
with support for whole-minute, hour, and percentage values.
- Imperative schedules can create, update, clear, and inspect windows
through the API and dashboard.
- Schedule API responses preserve `nextRun` as the nominal CRON time and
expose `nextRunEffectiveAt` as the stable assigned time.
- The dashboard displays configured windows alongside assigned
upcoming-run times.
- Deploy output summarizes declarative schedules and suggests adding a
wider window when the default 60-second placement range is used.

## Design

Window validation remains authoritative on the server and ensures each
window is compatible with the schedule cadence. Omitting a window uses
the default 60-second range, while explicit zero-duration windows remain
supported.

Deployment summaries are derived from the deployment's stored task
metadata, so they reflect the declarations associated with that
deployment.
2026-08-14 10:07:14 +01:00
Chris Arderne d98f64bb00 fix(webapp): hide misleading root API key creation dates (#4612) 2026-08-14 09:25:34 +01:00
Chris Arderne 20a0ac5055 chore: fix lint warnings (#4605) 2026-08-13 17:26:46 +01:00
Eric Allam eefe0a378d perf(webapp): bound environment loads in the env layout and batches list (#4606)
## Summary

Follow-up to #4595. Dashboard pages under an environment loaded every
environment in the project on each page just to resolve the one named in
the URL. On projects with many preview branches that meant reading
hundreds of (mostly archived) rows on every page load.

## Fix

The environment-scoped layout loader now scopes its lookup to the slug
in the URL (`where: { slug: envParam }`), resolving the current
environment through the `projectId, slug` composite index instead of
loading the whole project. Archived branches stay viewable by slug.
`BatchListPresenter` is bounded to the current environment, since every
batch in that list already belongs to it.

Verified on a project seeded with 2,000 archived branch environments:
the layout lookup drops from all environments to one, and both a normal
environment page and an archived branch page render correctly.
2026-08-13 16:31:51 +01:00
Matt Aitken 6485f37bf2 fix(webapp): show the dev environment's actual limit in the concurrency page Total column (#4596)
## Summary

On the Concurrency page, the dev environment row's Total always showed
the plan's included dev concurrency, even when the environment's limit
had been raised. The row's own "Extra concurrency" value was already
derived from the real limit, so the two columns could disagree with each
other.

## Root cause

The Total cell renders `planConcurrencyLimit + allocation`, where
`allocation` is the state behind the editable prod/staging inputs. Dev
environments are deliberately excluded from that allocation map (dev
concurrency is not purchasable), so the dev row's allocation always
resolved to 0 and the Total fell back to the plan value. The dev row now
renders the environment's actual `maximumConcurrencyLimit` instead.
2026-08-13 14:53:34 +01:00
Katia Bulatova d1ac3d597d fix(webapp): org avatars blocked by img-src CSP and avatar overflow on failed load (#4600)
## What & why

Org avatars disappeared from the sidebar, replaced by alt text spilling
across it. Two bugs stacked: the document img-src CSP pins the Google
favicon endpoint org avatars are stored as, but Google 302-redirects it
to `tN.gstatic.com` and CSP re-checks the redirect target, so the avatar
is refused. Changelog images served from `trigger.dev` in the agent chat
were also missing from the allowlist. And `Avatar.tsx` had no clipping
and no error fallback, so a refused image degraded into overflowing alt
text.

## What's inside

**CSP allowlist** — `app/utils/cspImageOrigins.ts`: the base sources
gain the four gstatic shards `t0`–`t3.gstatic.com`, path-pinned to
`/faviconV2`, plus `https://trigger.dev/changelog/` as a path prefix. No
wildcards — the no-wildcard beacon policy stands. The shard hosts are
Google-operated with no public write path, so the enumeration is as
narrow as the existing `s2/favicons` entry; if Google ever adds a `t4`,
the failure mode is one broken avatar, not a broken page.

**Avatar fallback** — `app/components/primitives/Avatar.tsx`: the image
box clips, and a failed load falls back to the globe icon. That covers
failures before hydration too — `onError` never replays for a node that
already failed, so a ref checks `complete && naturalWidth === 0` at
attach time. The error state resets when the URL changes
(`key={avatar.url}`).

**Radio card theming** — `app/components/primitives/RadioButton.tsx`: in
the dark themes the checked radio card rendered darker than the
unchecked ones. Unchecked cards now sit on `background-bright`
(near-black in dark, unchanged white in light) and the checked card uses
the `surface-control` tokens, so selection reads black → grey in dark
themes; light theme keeps its current look. The API keys route keeps its
indigo checked-hover via an explicit override.

## Testing

The CSP test helper now implements CSP's real path-matching rule
(trailing slash = prefix, otherwise exact, query ignored) and asserts
the pins hold: the gstatic redirect target passes, `beacon.png` on
gstatic, a `t9` shard, and non-changelog `trigger.dev` paths stay
blocked. 39 tests green plus webapp typecheck. Verified against a
running webapp that the served directive contains the new sources.
2026-08-13 15:18:05 +02:00
James Ritchie 0b52af94fa feat(webapp): restyle the modal and sheet close buttons (#4603)
The close button on modals and slide-over panels is now a simpler
icon-only button. The `Esc` key label moves out of the button and into a
hover tooltip, delayed by 500ms.

<img
src="https://raw.githubusercontent.com/triggerdotdev/trigger.dev/31b781afb984e1ca36b31cd1e7d3a475f06310d1/modal-close-button.png"
width="620" alt="Modal with the new square close button in the top
right" />

<img
src="https://raw.githubusercontent.com/triggerdotdev/trigger.dev/31b781afb984e1ca36b31cd1e7d3a475f06310d1/modal-close-button-tooltip.png"
width="200" alt="Hovering the close button shows a Close tooltip with
the Esc key" />

### Verified

Both surfaces, driven in a real browser: no tooltip on open despite
autofocus, hidden at 300ms of hover, "Close · Esc" at 700ms, hides on
pointer leave, `Escape` closes, clicking the X closes with no orphaned
tooltip, and the button stays keyboard-focusable (`tabIndex 0`). The
`fullscreen` dialog variant flips the tooltip below to stay on-screen.

`typecheck --filter webapp` passes; `format` and `lint` are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- conductor-workspace-link -->

---

[Open workspace in
Conductor](https://app.conductor.build/workspace/a7368189-9fbb-4edd-891c-43c633931bcf)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:05:17 +01:00
claude[bot] f6f3b75547 chore: remove obsolete v3/v4 version copy from the dashboard (#4589) 2026-08-13 13:51:42 +01:00
claude[bot] 802d23836d fix(webapp): show the toast when saving project general settings (#4601) 2026-08-13 12:17:48 +01:00
Katia Bulatova ee854480fe fix(webapp): dashboard agent maintenance moves into the agent project (#4599)
## What & why

The dashboard agent's upkeep — retention deletes and the investigation
sweep — ran as cron jobs on the webapp's common worker, even though it
only touches the agent's own datastore. This moves that upkeep into the
agent's Trigger project as scheduled tasks (TRI-13182).

## What's inside

**Retention** — `internal-packages/dashboard-agent/src/maintenance.ts`,
a daily task (03:00 UTC). Deletes turn evals older than 30 days,
hard-deletes chats soft-deleted more than 30 days ago, and purges
terminal watches and submission rows older than 7 days. It used to run
every 5 minutes; nothing needs a hard delete that fast, so it is daily
now, draining in bounded batches and warning if it hits the cap. It
retries (3 attempts) because the next run is a day away. It connects
with `DASHBOARD_AGENT_DATABASE_URL`, falling back to `DATABASE_URL` like
every other task in the package (the deletes are confined to the agent's
own Postgres schema), and skips when neither is set.

**Investigation sweep** — `src/investigation-sweep.ts`, every 5 minutes,
same as before: settles investigation cards stuck `in_progress`
(30-minute window, attempt cap, force-abandon note). It keeps the fast
cadence because it fixes live state the UI is showing.

**What stays in the webapp.** The watch finalize/deliver sweep and batch
rearm: they cover a dead agent-side tick chain — a backstop can't live
inside the thing it backstops — and they need the main database and the
alerts worker. The org-deletion chat purge also stays: deletion must not
depend on the agent project being deployed. The removed cron job keeps a
cron-less tombstone entry so already-queued items drain cleanly; remove
it in a follow-up.

**Test plumbing** — the drizzle migration replayer that webapp tests
hand-rolled is now exported once from
`@internal/dashboard-agent-db/testing`; the moved tests live in the
agent package as `src/*.test.ts` against real Postgres.

## Testing

Agent package: retention passes (backlog drain, batch cap, no-op guard,
chat-delete cascade) and the sweep, on testcontainers Postgres. Webapp:
the watch/chat suites, plus a test that a settlement card stops the
dashboard spinner. Full typecheck on both.
2026-08-13 13:13:02 +02:00
Eric Allam aca234d1c3 perf(webapp): bound checkSchedule environment load to the requested ids (#4598)
## What

`CheckScheduleService.call` loaded **every** environment of a project
(`{ id, type, archivedAt }`, no filter) and then immediately narrowed to
just the requested `environmentIds` via
`resolveProjectScopedEnvironments`. It only ever uses the requested envs
(to reject foreign env ids and reject archived branches). On a
preview-heavy project that meant loading hundreds of archived branch
rows to validate one, on a path called in a per-scheduled-task loop on
the deploy path (`createBackgroundWorker` -> `syncDeclarativeSchedules`)
and from `upsertTaskSchedule`.

The query is index-backed and individually fast (rows_read/returned = 1
per predicate), so this is about result-set width / egress and wasted
work at scale (~580k calls/24h observed via Insights), not a slow plan.

## Change

Bound the `environments` relation load to `boundedIn(environmentIds)`:

```ts
environments: {
  where: { id: { in: boundedIn(environmentIds) } },
  select: { id: true, type: true, archivedAt: true },
}
```

Returns `<=` the number of requested envs (usually 1) instead of the
whole project. Both existing behaviors are preserved:

- **Foreign-id rejection**: the relation is still scoped to the project,
so a requested id belonging to another project never comes back and
`resolveProjectScopedEnvironments` reports it as `foreign` (a missing
requested id is already treated as foreign).
- **Archived-branch rejection**: a requested id that is an archived
branch still comes back with `archivedAt` set, so the downstream `Can't
add or edit a schedule for an archived branch` check still fires.

`archivedAt` is kept in the select deliberately, so this bounds by id
rather than filtering archived rows out.

## Evidence (isolated stack, seeded 1 prod env + 40 archived branch
envs)

Local `EXPLAIN (ANALYZE)` of the exact environments sub-select:

| | rows returned | buffers |
|---|---|---|
| before (unbounded) | **41** | shared hit=12 |
| after (`id IN (requested)`) | **1** (`Rows Removed by Filter: 40`) |
shared hit=4 |

Same `RuntimeEnvironment_projectId_idx`, no plan change. Rows to the
client drop to `len(environmentIds)`, which is the point.

**Unit (vitest, testcontainers, real Postgres):**
`apps/webapp/test/checkSchedule.test.ts` extended to prove, on real
rows, that the bounded load returns only the requested env (1 of 10),
still reports a foreign id as foreign, and still surfaces an archived
branch when it is the requested one. 5/5 pass.

**Full e2e (both execution modes, real stack):** a purpose-built project
with two declarative `schedules.task`s.
- `trigger dev`: dev worker created, both schedules synced through the
edited `checkSchedule` loop, no errors.
- `trigger deploy` (managed deployment): PRODUCTION worker registered,
both schedules synced against the **prod** environment through the same
loop, prod + dev schedule instances active, no errors.

`typecheck --filter webapp` clean.

## Rollout / rollback

Straight deploy, no flag, no migration. Rollback is revert-only
(read-path narrowing, no data change). Old and in-flight rows read
correctly under both the old and new code.

## Out of scope

The two lower-priority sibling reads in the ticket (the Query/metrics
env id->slug map and the env-var repository fan-out) are left for
follow-ups; they need caching / per-method scoping rather than this
single bound.
2026-08-13 07:36:07 +01:00
Eric Allam c6ef5f3959 perf(webapp): paginate the environment variables settings page (#4597)
## What

The environment variables settings page loaded **every** variable in the
project in one shot, with a nested `values` read plus a `valueReference`
(SecretReference) sub-load that was selected but never read. For a
project with many variables this pulled `variables × environments` value
rows (~18k for large projects) on every page load, plus a matching
~18k-row `SecretReference IN` query.

This paginates the presenter by variable key and removes the dead
include.

- Remove the never-read `valueReference: { select: { key } }` include →
the `SecretReference` query is gone entirely.
- Paginate the parent variable query: `count` + `orderBy key` +
`skip/take`, page size 50 → the value read is bounded to `pageSize ×
environments` per page.
- Scope the count and the page to variables that have a value in a
displayed environment (`values: { some: { environmentId: { in } } }`),
so `totalCount`/`totalPages` and the `skip/take` window match what
actually renders (no phantom empty pages from variables that live only
in archived branches or another member's dev env).
- Display order comes from the DB `orderBy: { key: "asc" }` — the
presenter no longer re-sorts each page with `localeCompare`, which under
pagination could disagree with the DB collation at page boundaries.
- The secret-value lookup (`SecretStore` keys) and the updater lookup
(`user` by id) are now scoped to the current page instead of the whole
project.
- Search moves server-side (variable key, case-insensitive) and drives
both the count and the page; the UI gains standard pagination controls.

## Why

The two correlated ~18k-row control-plane queries flagged in the ticket
come from this settings-page presenter, not from any hot path. Both are
index-covered (`rows_read == rows_returned`); the issue is the sheer
volume fetched in one burst. Bounding it per page removes the burst.

## Evidence

Measured on an isolated stack with a seeded project of 1000 variables ×
3 environments (3000 value rows), using Prisma's emitted-SQL log:

| | SecretReference query | value rows fetched |
| --- | --- | --- |
| before | 1 | 3000 |
| after | **0** | **150** (page 1) + one `count` |

`EXPLAIN` on Prisma's verbatim statements (index confirmed via
`enable_seqscan=off`; the local table is too small for the planner to
choose them by default):

- `count` (`WHERE projectId AND EXISTS(values in displayed envs)`) →
Hash Join: Index Scan on `EnvironmentVariable_pkey` + Bitmap Index Scan
on `EnvironmentVariableValue_environmentId_idx`
- paginated parent (`WHERE projectId AND EXISTS(...) ORDER BY key
LIMIT/OFFSET`) → Nested Loop Semi Join: Index Scan on
`EnvironmentVariable_projectId_key_key` (**no Sort node**) driving an
Index-Only Scan on
`EnvironmentVariableValue_variableId_environmentId_key`
- nested values (`variableId = ANY … AND environmentId = ANY …`) → index
scan on `EnvironmentVariableValue_environmentId_idx`
- `SecretStore` keys (`key = ANY …`) → index scan on
`SecretStore_key_idx`

No new index required. Verified in the browser on the seeded project: 20
pages, page navigation, server-side search (matches across all pages),
last page renders, no app console errors. `typecheck`, `oxlint`, `oxfmt`
all clean.

## Behavior change

The previous client-side search matched variable **name and value** (and
environment type / branch name). Values are encrypted at rest and
resolved separately, so they cannot be searched server-side under
pagination. Search is now **variable-name only**, server-side,
case-insensitive. Projects with fewer than one page of variables see no
pagination bar and no visible change.

## Rollout / rollback

Pure read-path change on a dashboard loader, no schema or data
migration. Rollback is a straight revert.

## Screenshots

<img width="2400" height="1794" alt="01-page1"
src="https://github.com/user-attachments/assets/d4a7effd-d167-4dd6-92f4-6e9174818acd"
/>
<img width="2400" height="1794" alt="02-search-single"
src="https://github.com/user-attachments/assets/cd113ca9-ff87-431f-b2f6-7f7d36f2b32a"
/>
2026-08-12 23:51:14 +01:00
Eric Allam 8d0f693186 perf(webapp): drop archived branch environments from project env loads (#4595)
## What

Several project pages loaded **every** `RuntimeEnvironment` row for a
project, including the archived preview-branch environments that are
never shown in the UI. On a project with heavy preview-branch usage that
means thousands of rows per load, producing a large result set and a
rare multi-second tail on the environment lookup (~30s outlier observed
via Insights on `RuntimeEnvironment` projectId lookups, fingerprint
`f2b3ecab…`).

The tail is dominated by the size of the result being
parsed/transferred, not by the query plan (it already used
`RuntimeEnvironment_projectId_idx` with no over-read). So the fix is to
stop returning archived branch environments.

## Diagnosis correction

The ticket framed this as a "large `projectId IN` list" and suggested
bounding the IN list / cursor pagination. It's actually a Prisma
**nested relation load** on a *single-project* `project.findFirst`, so
the `IN (...)` holds one projectId and the trailing `OFFSET $1` is
Prisma's relation-subquery artifact. The 4,644 rows in the observed
execution were **one project with ~4,644 environments** (accumulated
archived branches), not many projects.

## Change

Filter the `environments` relation load to `archivedAt: null` (base envs
never archive, so only archived preview branches are excluded):

- `ProjectPresenter.server.ts`
-
`orgs.$organizationSlug.projects.$projectParam.{concurrency,apikeys,environment-variables,settings}.ts`
(best-env resolvers)

And remove an **unused** `environments` select from
`DeploymentListPresenter.server.ts` (it was selected but never read).

`loadProjectEnvironments` (replay route) already filters `archivedAt:
null` + env type; this change follows that existing precedent.

## Evidence (isolated stack, seeded one project with 2,000 archived
branch envs + 4 active)

`EXPLAIN (ANALYZE)` of the exact presenter sub-select:

| | rows returned | index |
|---|---|---|
| before (unfiltered) | **2004** | `RuntimeEnvironment_projectId_idx` |
| after (`archivedAt IS NULL`) | **4** (`Rows Removed by Filter: 2000`)
| same index, no plan change |

500x fewer rows to the client, which is what removes the parse-on-load
tail. No new index needed. `typecheck --filter webapp` clean. UI
verified: project layout, Deploys page, and the concurrency best-env
redirect all render with the 2,000 archived branches present in the DB
and zero console errors.

## Rollout / rollback

Straight deploy, no migration. Rollback is revert-only (read-path
filter, no data change). Old and in-flight rows read correctly under
both the old and new code.

## Limitation

A project with thousands of *active* branches would still load them all;
in practice active branches are few (branches are archived when their
work merges). Hard-bounding active branches would be a larger change and
is out of scope here.
2026-08-12 23:50:52 +01:00
Matt Aitken bc3a33be24 fix(webapp): stop the billing limits page timing out under enforcement (#4594)
## Summary

Opening the billing limits page while a spend limit was being enforced
could time out with no response for organizations with many preview
branches. That is exactly the moment the page matters: it is the only
self-serve way to raise or resolve the limit. The page now loads fast
regardless of how many environments the organization has.

## Root cause and fix

The loader's queued-run count ran one ClickHouse count per billable
environment, sequentially, with no timeout, and the environment list
included every archived preview branch ever created. Thousands of
environments times one round trip each held the response open past the
edge timeout.

The count is now a single org-level ClickHouse query filtered on
environment type, capped server-side with max_execution_time. If the
count fails, the loader falls back to 0 (the page hides the count label
at 0) instead of throwing, so the recovery panel stays reachable even
when the count errors. The billing-limit bulk-cancel path also stops
enumerating archived environments.
2026-08-12 20:40:38 +01:00
Katia Bulatova 622fa79643 fix(webapp): restore header docs buttons when the dashboard agent is unavailable (#4592)
Restores the page-header docs buttons removed in #4529 / #4418, shown
only when the dashboard agent is unavailable (feature flag off, or pages
outside the environment layout). The buttons are restored verbatim at
their original spots — 22 sites across 21 files — wrapped in a small
`WhenAgentUnavailable` gate that reads the agent context (SSR-safe, no
hydration flicker).

Also: in the light theme, the query editor's Format/Clear/Copy toolbar
gets a translucent white background (`light:bg-white/80`) instead of
transparent, so it no longer blends into the code behind it.
<img width="1215" height="133" alt="Screenshot 2026-08-12 at 17 26 02"
src="https://github.com/user-attachments/assets/4bdba825-9cbd-4df4-b6ca-0ea6691a534b"
/>
2026-08-12 18:08:44 +02:00
claude[bot] 442702e879 feat(webapp): stay on the same page when switching project or organization (#4585)
<!-- ccr-slack-attribution -->
_Requested by **Eric Allam** · [Slack
thread](https://triggerdotdev.slack.com/archives/C0BEM9Z73TM/p1786528784863449)_

**Before:** you're on the API keys page in project X, you switch to
project Y in the sidebar, and you land on project Y's Tasks page. Same
for switching organization. Every switch threw away the page you were
looking at.

**After:** you land on project Y's API keys page. Switching organization
does the same thing, one project down. Pages that name a single thing —
a run, a batch, a queue, a schedule, a deploy, a session, an error group
— can't exist in another project, so those take you to the matching list
page instead (a run page takes you to Runs).

The environment is still chosen exactly as it is today: nothing tries to
guess it in the browser.

---

## Testing

- New `apps/webapp/app/utils/pageSwitching.test.ts` (35 tests). It reads
the compiled Remix route manifest, so the portable-page list can't
silently drift from the routes:
- every environment page that names no resource survives an environment
switch — the same pages the old slug swap kept
- the two branch lists are the only pages an environment switch keeps
and a project switch drops
- the pages gated per organization — Logs, Query and the queue metrics
dashboard — travel with an environment or project switch but not an
organization switch, and that list is derived from the route sources so
a new gated page cannot be missed
  - every portable page points at a route that exists
- every one of the 19 environment routes that takes a resource id
truncates to a list page, with the id gone
- portable pages resolve to themselves, so switching twice lands in the
same place
- every rejection case: leading slash, `//`, absolute URL, `..`,
percent-encoded traversal, `javascript:`, unknown page — each falls back
to Tasks rather than being sanitised into something
- Manual: switch project and organization from API keys, project
settings, a run page, and a queue page.
- `pnpm run typecheck --filter webapp` passes.
- The rest of the webapp suite needs Docker for testcontainers, which
wasn't available here; all colocated pure unit tests under `app/utils/`
pass (15 files, 150 tests).

---

## Changelog

Switching project or organization in the sidebar keeps you on the same
page instead of sending you back to Tasks. Pages for a specific run,
deploy or other single item open the matching list instead.

---

## How

The switcher links already pointed at `/orgs/:org/projects/:project` and
`/orgs/:org`, whose `_index` loaders resolve the best environment (and,
for the organization, the best project) and redirect. So the page
travels as a search param on those links, and each loader appends it to
the path it already builds:

- `app/utils/pageSwitching.ts` — one pure module.
`environmentPortablePage(suffix)` and `projectPortablePage(suffix)` walk
up the suffix until they find an entry in an allowlist of portable
pages, and answer with the environment root if they find none. The
result is therefore always a literal from that closed set, which is what
makes it safe to concatenate into a redirect target; there is no regex
sanitising. The allowlist is built from the landing pages already listed
in `deeplinkPages.ts` plus the handful of nested pages that file doesn't
know about, so this isn't a new URL-shape table.
- `app/hooks/useEnvironmentSwitcher.ts` — `usePageSwitcher()` derives
the current page by slicing the environment layout route match's
pathname off the current pathname, so there's no route table on the
client either. The query string and hash are dropped on a project or
organization switch, since filters encode task slugs and ids scoped to
the project you're leaving.
- Both `_index` loaders re-validate the page through the same function
before using it.

Two things worth a look:

- **The environment switcher's truncation gap is fixed as a side
effect.** It had a hand-written switch covering `runs/:runParam`,
`deployments/:deploymentParam` and `schedules/:scheduleParam`; the other
16 id-bearing routes carried their id straight into the new environment
(e.g. `queues/:queueParam`, `batches/:batchParam`,
`errors/:fingerprint`, `sessions/:sessionParam`). All three switchers
now share one truncation, and the test asserts it covers every such
route in the manifest.
- **Portability turned out to be two properties, not one.** Preview
branches and dev branches render under any environment slug of their
project — both loaders pass a hardcoded environment type and the project
slug and never read `envParam` — so an environment switch keeps them,
exactly as swapping the slug did before. A project or organization
switch still falls back to Tasks, since the project you land in may have
no preview branches. A test locks the environment half: every id-free
page below an environment has to survive an environment switch.

---

## Screenshots

_n/a — no visual change; only where the switcher links point._

💯

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 15:35:31 +01:00
Iss 4b4f6f2071 fix(webapp): accept Plain customers without an external id on customer cards (#4575)
Plain sends `customer.externalId` as an explicit `null` rather than
omitting the key. The schema validated it with `z.string().optional()`,
which accepts `undefined` but rejects `null`, so every customer we don't
set an `externalId` for got a 400 instead of a card — while the rest
worked, which made it look intermittent.

`email`, `externalId` and `thread` are now `nullish`. One of
email/externalId is still required, and the route's existing email
fallback resolves these customers.

Three related fixes in the same path:

- The route returned `{ cards: [] }` when no user matched. Plain records
an integration error for any requested key it doesn't get back, so that
surfaced as a broken card rather than a hidden one. Every requested key
is now answered, with `components: null` where there's no data.
- The impersonation link is offered only when the customer matched on
`externalId` — a value we set ourselves. An email match is a weaker
claim, since the address on a Plain customer isn't verified and for
customers created outside our own writes it comes from whoever sent the
message. Email-matched customers get the account rows without a
one-click impersonation link.
- The not-found log recorded raw customer identifiers; it now keeps
presence flags only.

The schema and the response helper moved to
`app/utils/plainCustomerCards.ts` so they can be unit-tested without
pulling in the db and env modules.

## Testing

`app/utils/plainCustomerCards.test.ts` — 11 tests covering the null
shapes, the every-key-answered response, and the missing-vs-zero
distinction. Verified locally.

Split out of #4571, which bundled this with an unrelated impersonation
fix.
2026-08-12 09:18:04 -04:00
Eric Allam db0ca9eb40 fix(webapp): drop unused OrgMember _count aggregate from org-list presenter (#4587)
## What

`OrganizationsPresenter.#getOrganizations` selected a Prisma
`_count.members` relation on every org-list load (hit on nearly every
dashboard navigation). Prisma lowers that relation `_count` to a
whole-`OrgMember`-table `GROUP BY organizationId` aggregate joined onto
`Organization`. The computed `membersCount` field is read by **nothing**
in the webapp, so the entire aggregate scan is wasted work.

This removes the `_count` select and the `membersCount` field. The query
keeps only the indexed `EXISTS` membership filter and the org/project
selects.

## Why it's safe

- `membersCount` has zero consumers (whole-webapp grep finds the name
only at the point of assignment). It was added in #1796 (2023) and has
been unused since.
- The member count shown on the org settings/team page comes from a
separate presenter query, not this one. No user-visible change.

## Evidence (generated SQL, before/after, seeded isolated stack)

Before (with `_count.members`):

```sql
SELECT ..., COALESCE(aggr._aggr_count_members, 0)
FROM "Organization"
LEFT JOIN (SELECT "organizationId", COUNT(*) AS _aggr_count_members
           FROM "OrgMember" GROUP BY "organizationId") aggr ON ...
WHERE EXISTS (... "userId" = $1 ...) AND "deletedAt" IS NULL
ORDER BY "createdAt" DESC
```

After:

```sql
SELECT id, slug, title, avatar, "featureFlags"
FROM "Organization"
WHERE EXISTS (... "userId" = $1 ...) AND "deletedAt" IS NULL
ORDER BY "createdAt" DESC
```

The whole-table `GROUP BY` aggregate is gone. The only remaining
`OrgMember` access is the `EXISTS` on the caller's own membership
(indexed by `userId`, a handful of rows). This is the single largest
read-amplification query on the control-plane database (~1.39B rows
read/day, ~719s DB CPU/day per Insights); removing it takes that portion
to zero.

Webapp typecheck passes.

## Rollout

Straight deploy, zero blast radius. Rollback is a plain revert, no data
migration.

refs TRI-13170
2026-08-12 14:03:52 +01:00
Eric Allam 4fd7cc0f55 perf(webapp,database): index RuntimeEnvironment.pauseSource for the billing-limit reconcile tick (#4590)
## What

The `billingLimit.reconcileTick` worker calls
`getOrgIdsWithBillingPauseSource()` on
`BILLING_LIMIT_RECONCILE_INTERVAL_MS` (~every 90s) to find which orgs
currently have billing-limit-paused environments. Two problems:

1. `RuntimeEnvironment.pauseSource` had no index, so `WHERE pauseSource
= 'BILLING_LIMIT'` was a **sequential scan of the whole table** on the
control-plane primary, every tick.
2. Prisma `distinct` dedups **after** fetching, so it read every paused
row (thousands) to produce a handful of distinct org ids.

This PR:

- Adds a **partial index** on `RuntimeEnvironment (pauseSource,
organizationId) WHERE pauseSource IS NOT NULL`. Nearly all rows have
`pauseSource = null`, so the index stays tiny. Second column lets the DB
satisfy the distinct-org lookup from the index. Defined in SQL (Prisma
can't express partial indexes), matching the existing partial-unique
indexes on this model.
- Switches the query from `findMany({ distinct })` to
`groupBy(["organizationId"])`, pushing DISTINCT into the DB so it
returns only the distinct orgs.

## Evidence

**Correctness** — colocated `postgresTest` (testcontainers, no mocks):
multiple `BILLING_LIMIT` envs in one org collapse to one org id,
`pauseSource = null` envs are excluded, each org id returned once. 5/5
tests in `billingLimitReconciliation.test.ts` pass.

**Plan change** — `EXPLAIN ANALYZE` on a synthetic table (200k rows,
5,250 `BILLING_LIMIT` across ~40 orgs, mirroring the test-side numbers
from the investigation):

| | Before (no index) | After (partial index) |
|---|---|---|
| Plan | Seq Scan (194,750 rows removed by filter) | Bitmap Index Scan
on partial index |
| Buffers | 1355 | 51 (index 6 + heap 45) |
| Exec time | 6.06 ms | 0.59 ms |

Index size 56 kB vs table 11 MB. The key win: cost now scales with the
paused-env count, not total table size, which matters most on prod where
the table is far larger.

## Rollout & rollback

- **Index**: `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, in its own
migration file. Pre-apply the index manually on the control-plane
primary before deploying the migration (the migration is a no-op if the
index already exists).
- **Query change** is behavior-equivalent (same distinct org set), so no
flag needed.
- **Rollback**: revert the deploy and drop the index. No data migration
either direction.

## Notes / limitations

- The planner uses a Bitmap Heap Scan, so `organizationId` is still read
from the heap (45 blocks for the matched rows only, not the whole
table). A pure index-only scan isn't chosen for the bitmap path; the
second index column keeps that open for the index-scan path at
negligible cost.

refs TRI-13169
2026-08-12 14:03:44 +01:00
Chris Arderne 7b7d48916d fix(webapp): selfhost apikey role cta (#4586) 2026-08-12 14:14:57 +02:00
Katia Bulatova 480bede0ad feat(webapp,sdk): dashboard agent plan enforcement, component gallery — and fixes (#4516)
Plan enforcement for the dashboard agent — message quota and watch
limits — plus the component gallery, fixes and test hardening from the
same stack (#4548, #4549, #4550, #4552, #4556 merged here).

## Plan enforcement
([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863))

**Agent message quota.** The Free-plan allowance becomes a real
server-side limit with a durable counter. New `agent_message_usage`
table keyed `(organization_id, period)` — deliberately not joined to
chats, so deleting a chat can't free quota within the period. Both send
paths count one user message (wakes never count) and refuse at the cap
with `403 message_quota_reached`, which the client renders as an upgrade
panel, never a silent drop. The refusal code is a single shared constant
on both sides.

**Watch limits.** A watch whose window exceeds the plan's
`agentWatchMaxHours`, or that would push the org past its
`agentWatchers` count, is refused with `watch_limit_reached` (409 on the
API, an upgrade hint on the card). Plan limits only tighten the existing
code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A
plan limit of zero means zero, not unlimited. Questions answerable
instantly are answered before any plan refusal — a one-shot consumes no
slot and never sees an upgrade nag.

**Fails open by design.** Cloud ships the actual per-plan numbers
separately (TRI-12863 P0). Until then absent limits resolve to the
unlimited sentinel and the upgrade UI is gated on billing presence —
self-hosted sees no cap, no upsell, with tests proving the fallback.
Both quotas are nudges, not security boundaries: a failing limit read
never blocks a send.

## Component gallery

An admin-only gallery of every agent card state: five
`storybook.agent-*` pages (chat UI, view blocks, report, investigation,
watch) with their shared shell and manifest, demo fixtures, two
demo-only cards, toast examples, and the screenshot script. No LLM and
no data — every state renders from fixtures under
`dashboard-agent/demo/`, never reachable from a production path.
Designers and reviewers can look at every state, including the report
states, without seeding anything.

## And fixes

**SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065,
TRI-13070) — watch mode keeps reconnecting across empty long-poll
windows and only stops on abort or a settled session; a passive
subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is
explicit, default off). Review findings fixed alongside: a superseded
stream's async teardown no longer removes the live successor's abort
controller or multi-tab claim, and stopping a generation hands the chat
back to the user's other tabs.

**Query boundary pinned end-to-end**
([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a
route-level test drives `api.v1.query` with a real signed environment
JWT (writes refused before ClickHouse, a read passes); `readonly=1` made
non-overridable; a per-turn cap stops the model burning a turn rewriting
a query it can't fix (deterministic SQL errors only — busy/transport
rejections don't count).

**chat.agent durability regression suite**
([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) —
testcontainers-backed coverage of the two audit criticals (cross-tenant
isolation, no duplicate mid-stream turn, both control-broken) plus
crash-resume, cursor-based refresh, clean rollback of a mid-write turn
failure (torn by a real constraint violation), and OOM-restart replay.

**Investigation sweep backoff** — stale investigations get an attempt
counter and backoff so a poison row can't pin the sweep queue head
(migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`).

## Screenshots

<img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19"
src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-12 13:59:35 +02:00
Chris Arderne ed1bb72fb8 feat: implement cron window spread backend (#4566)
- New DB fields on Schedule and ScheduleInstance
- Use `queueTimestamp` for the "effectiveAt" delayed start time,
propagate it to Clickhouse TaskRun table
- Disable fastpath for delayed jobs
- Add schedule timing logic, API endpoints with windows, persistence
- Calculate phase for every schedule, only persist when window is
non-null
- Additional o11y for phased rollout
2026-08-12 12:24:32 +01:00
Chris Arderne 3c5bbc1607 fix(webapp): hard-navigate after creating a project (#4584) 2026-08-12 10:44:38 +00:00
Matt Aitken c2c6e5c705 fix(webapp): keep session runs off the legacy realtime streams backend (#4564)
## Summary

Runs created for a Session were triggered without a realtime streams
version, so they fell through to the `realtimeStreamsVersion` column
default of `v1`. A Session's own `.in` / `.out` channels are always
`v2`, so any run-scoped `streams.append()` or `streams.pipe()` call made
inside a session run wrote to a different backend than the session it
belongs to, and stayed there for the life of the run.

The API trigger routes were never affected. They call
`determineRealtimeStreamsVersion` with the client's
`x-trigger-realtime-streams-version` header and always pass an explicit
value, so a current SDK asking for v2 gets it. Only the internal callers
that build trigger options by hand were leaning on the column default,
which no env var can influence because that path never calls the
resolver at all.

## The version resolver

Fixing the call site exposed a second problem in
`determineRealtimeStreamsVersion`. Its two paths disagreed: an explicit
`v2` was checked against the S2 configuration first, but when the caller
expressed no preference it returned `REALTIME_STREAMS_DEFAULT_VERSION`
verbatim with no check. A deployment that set the default to `v2`
without configuring S2 therefore stamped runs `v2`, nothing failed at
trigger time, and every later read or write against those runs' streams
threw `Realtime streams v2 is required for this run but S2 configuration
is missing` for the life of the run.

Both paths now resolve through one pure function that takes its
configuration rather than reading `env`:

```ts
const requested = streamVersion ?? config.defaultVersion;
if (requested !== "v2") return "v1";

const hasCredentials = Boolean(config.accessToken) || config.skipAccessTokens;
return hasCredentials && Boolean(config.basin) ? "v2" : "v1";
```

## The basin requirement

`resolveStreamBasin` resolves run, session and organization basins ahead
of the global setting, so a deployment that provisions a basin per
organization can serve v2 with no global basin at all. Gating purely on
the global setting would degrade every run there to `v1`.

`determineRealtimeStreamsVersion` therefore takes an optional
organization basin, and every caller that holds one passes it, including
the session path:

```ts
basin: organizationBasinName ?? env.REALTIME_STREAMS_S2_BASIN,
```

This is deliberately the resolved basin and not the
`REALTIME_STREAMS_PER_ORG_BASINS_ENABLED` flag. The flag says the
feature is on, not that a given organization has been provisioned, and
provisioning happens out of band. Keying off the flag would stamp `v2`
on runs for unprovisioned organizations, recreating the failure this
removes.

**This widens behaviour for explicit `v2` requests**, which previously
required the global basin: a provisioned organization on a per-org
deployment now resolves `v2` where it used to get `v1`. That is
intentional, and it makes every path agree.

## Scope

Only newly created runs change. A run already stamped `v1` keeps that
version for its lifetime by design, since readers resolve the backend
from the same column and its existing streams have to stay readable.
Scheduled runs reach the same column default through
`scheduleEngine.server.ts` and are deliberately left alone: that one is
a policy question about `REALTIME_STREAMS_DEFAULT_VERSION` rather than
an inconsistency inside a single feature.

## Verification

A full-stack e2e boots the real webapp plus Postgres, Redis and s2-lite,
creates a Session through the public API so the run comes from the real
trigger path, appends records the way `streams.append()` does, and
asserts three things at once: the version stamped on the run, that the
payload is readable from S2, and that no key exists in Redis. It appends
at a realistic record size so the route's body cap and S2's per-record
cap are both exercised. Reverting the session-path change flips all
three observations, so it fails against the old behaviour rather than
passing vacuously.

Unit tests cover the resolver matrix, including organization-basin-only
and credential-only configurations; two of them fail against the
previous resolver.

Also verified by hand against a local stack: a real `chat.agent` session
run writing 8 records of 250KB through `streams.append()` put 2,049,072
bytes into S2 with no Redis key, while the same agent with the
session-path change removed put 2,102,360 bytes into Redis and nothing
into S2.
2026-08-12 11:01:59 +01:00
Chris Arderne 429c004118 fix(webapp): include Tailwind in production image (#4582)
fix(webapp): include Tailwind in production image

## Summary

Include `tailwindcss` in the webapp production dependencies so
self-hosted Docker images can render emails that use React Email's
Tailwind component.
2026-08-12 10:52:26 +01:00
Chris Arderne 7b390e5984 feat(cli,webapp): allow deploys with environment API keys (#4561) 2026-08-12 10:11:31 +01:00
Chris Arderne 26a730f908 fix(webapp): externalize kapaai (#4580) 2026-08-12 09:47:51 +01:00
Katia Bulatova 0b750d00dd feat(webapp): dashboard agent — Watch (#4525)
Watch is the agent noticing something later: you ask it to tell you when
a condition holds, and it answers when it does — or when it can't any
more.

A watch is a **durable one-shot promise**. The condition is checked on a
schedule by deterministic code (no LLM in the checks), the answer lands
in the chat once, and then the watch is over. Ten kinds: three on a run,
five on a queue, error recurrence, health recovery.

## Stack

Stacked on **#4529** (UI), which is stacked on **#4418** (chat, reports,
investigate). Merge those first. **#4516** (storybook gallery) sits on
top of this branch.

## How to review


[**GUIDEBOOK.md**](https://github.com/triggerdotdev/trigger.dev/blob/feat/dashboard-agent-flows-watch/internal-packages/dashboard-agent/GUIDEBOOK.md)
on this branch is the behaviour reference — it states the conditions
rather than the code, so you can predict what happens without running
anything. "The ten watch kinds, and what makes each fire" and "Creating
a watch" describe exactly this PR, and the tables there are the spec the
code is written against.

## What's inside

- **Ten watch kinds**, one deterministic check each
(`dashboardAgentWatch*Checks.ts`), with the spec union in
`dashboard-agent-contracts/src/watch.ts`.
- **Scheduling** — each watch schedules its own next check; due watches
of one `(environment, cadence)` group can be checked together in one
batch pass, with a sweep as the backstop for expiry, redelivery and
retention.
- **Delivery** — the in-chat wake and card, an optional email alert (new
`DASHBOARD_AGENT_WATCH` alert channel, so it shows on the project's
Alerts page with one-click unsubscribe), and an optional investigation
when the outcome needs attention.
- **Submission ledger** — `watch_submissions`, keyed `(chat_id,
client_request_id)`, so a retried card submission replays the recorded
outcome instead of creating a second watch.
- **Watch token** — a delayed-execution credential accepted only by the
watch endpoints, re-checked against the user's live access on every
tick.
- **Unread work** — the panel polls for wakes that landed while it was
closed, so a chat can go unread and light the launcher dot.

## Key decisions

**A check result is a 4-way, and only two of them are verdicts.**
`satisfied` / `terminal_unsatisfied` are answers; `pending` and
`unavailable` are not. Any exception inside any check is caught in one
place and becomes `unavailable` with an unverified observation — a check
that failed is never evidence.

**A completed window is an answer, and whether it is good or bad news is
declared per kind, never inferred.** There is a table for that in the
guidebook: `run_failed` completing its window is *good* news ("hasn't
failed"), `backlog_drain` completing it is not. One rule overrides the
table: a window that completed on an unverified observation is neutral
and says only that the watch ended without a confirmed answer. **An
unreadable source is never a negative answer** — and, because
investigations only open on `attention`, it never starts one either.

**Identity is `(chat, project, environment)` plus the condition,**
enforced by a partial unique index over active rows
(`watches_chat_active_identity_key`), not by the read-then-insert check.
Cadence, window, note and `ticks` are deliberately not part of it. Two
different chats may watch the same thing — a watch is a promise to a
chat.

**The server resolves the target's name, whatever the model calls it.**
The model can't tell a task queue (`task/<id>`) from a custom queue, so
both spellings are tried and the stored one wins — and the rewrite
happens **before** identity and before the row is written, so the
identity, the checks, the link and the wording all see one spelling.

**Freshness fences.** Depth falls back from the live counter to the
newest 60 s ClickHouse bucket, which only counts as current within 60 s
of now. A non-current reading at or below the *quiet line* is refused as
`unavailable` rather than believed, so a stale empty bucket is never
read as "drained". The stall streak is the one piece of carried state:
it lives in the previous check's facts and *freezes* on an unreadable
reading rather than breaking.

**Chain reliability.** There is no shared cron — each watch (or batch
group) schedules its own next tick, so the failure mode to review is the
chain dying. A failed batch check is caught, the next tick is scheduled
anyway and the run resolves rather than failing, so the chain survives a
check that couldn't run; the sweep re-arms groups and finalizes anything
still active past its deadline, even when delivery isn't configured.
Wake redelivery is id-deduped rather than conditional, because the sweep
can't know whether the user was already told. Access is re-authorized on
**every** check against the primary — replica lag would extend access
the user has already lost.

**Wording lives in one place.** `watch-wording.ts` is read by the card,
banner, toast, email and the agent's own narration, and the numbers come
from the frozen observation rather than a fresh read, so a retry
produces the same sentence. Replay reproduces the **recorded** decision
instead of deciding again — the transcript is append-once, so a second
decision would contradict it forever.

**Cancellation is the ending without an answer** — no resolution, no
wake. One exception, decided during testing: a watch the *user*
cancelled leaves a single neutral transcript line ("Stopped watching
…"), keyed off the watch id so a retry can't repeat it. The other four
reasons stay silent.

**Email is opt-in and only a fired watch emails.** An expiry is narrated
in the chat and nowhere else. Both gates (agent access, a configured
email transport) are checked at subscribe time *and* again at delivery,
and the subscription outcome is frozen on the ledger row so a retry
replays it. Neither gate is a plan check.

**One watch offer per turn.** The prompt and the renderer guard this
independently — if the turn already proposed a watch card, the action
button is dropped, because the card is the better affordance. Two eval
cases pin the prompt side: exactly one offer with the line last and the
button after it, and zero offers when the rendered card already carries
one — deterministic assertions, over a real-model run.

## Testing

Unit tests (vitest, testcontainers, no mocks) under
`apps/webapp/test/dashboardAgentWatch*.test.ts` and
`internal-packages/dashboard-agent/src/watch-*.test.ts` cover the
invariants above: the 4-way check results and the freshness fences,
identity/dedup and the submission ledger, queue-name resolution, the
batch chain surviving a failed check, sweep boundaries and alert-once,
tenancy and the watch token's scope, and the wording snapshot. The
load-bearing ones were verified by control-breaking the guard first and
checking the test goes red.

Live-tested end to end against a local stack, following the guidebook:
all ten watch kinds firing and expiring, cancellation, the email pair (a
fired watch mails, an expired one does not), and watch recovery from a
health report.
2026-08-12 09:51:40 +02:00
Eric Allam 326e9950f4 perf(webapp): scope declarative schedule sync to the current environment (#4577)
## Summary

Background worker registration runs on every deploy and every `trigger
dev` file save. Its declarative-schedule reconcile loaded every
declarative schedule for the whole project across all environments, then
re-fetched the deletion candidates it already had in memory. For
projects with many scheduled tasks or many environments, that meant
reading tens of thousands of rows on each registration. This scopes the
load to the environment being registered, drops the redundant re-fetch,
and selects only the columns the reconcile needs.

It also fixes the schedule-limit count (`getUsedSchedulesCount`), which
joined `TaskSchedule` and `RuntimeEnvironment` without a project
constraint and could scan those tables in full. Pushing `projectId` onto
both joins gives it a project-scoped index path with the same result.

Follow-up to
[#4522](https://github.com/triggerdotdev/trigger.dev/pull/4522), which
batched the delete side of the same reconcile.
2026-08-12 08:16:55 +01:00
Eric Allam 26cdedda1c perf(webapp): scope env var create pre-check to submitted keys (#4579)
## Summary

Setting or importing environment variables ran a conflict pre-check that
loaded every variable in the project and every value across all of its
environments, only to decide whether the submitted keys already had a
value in the target environments. On projects with many variables and
environments that meant reading tens of thousands of rows on each
create/import call.

This scopes the pre-check to the submitted keys and target environments,
so it reads only the rows it actually inspects (submitted keys × target
envs), wrapped in `boundedIn` to keep the prepared-statement cache
stable. Same conflict detection, a handful of rows instead of the whole
project's env-var values.
2026-08-12 08:15:27 +01:00
Katia Bulatova 9a3bee0288 feat(webapp): dashboard agent — UI (#4529)
Stacked on #4418. Merge that first.

The UI slice of the dashboard agent: the side panel, the chat transport
wiring, message and card rendering, suggested prompts, and chat history.
#4418 works without this — the system is simply invisible. The diff is
mostly components, so the notes below cover only the three decisions you
can't read off the markup. Behavior and a hands-on walkthrough live in
GUIDEBOOK.md, which lands with #4525.

## Decisions worth knowing

- **Action rows always render at the end of a turn.** The model's
emission order isn't trusted for layout, so action blocks are split out
of the stream and appended last. Display only — `answered` stays keyed
on the emission index.
- **The last-chat memory is org-true.** It's keyed by the chat's own
organization, and a foreign or deleted chat comes back as a 404 the
client treats as gone, rather than an empty chat it keeps around.
- **A dead stream self-heals from the settled transcript.** Terminal
records are written to the chat row after the client's stream closes, so
the panel re-reads it. The poll gate is any unfinished turn — a dangling
tool part, not just an open investigation.

## Notes

- Gated by `canAccessDashboardAgent`; no behavior change with the flag
off.
- Page marks: `handle.agentPageContext` on 47 routes, ~20 lines each.
- Entry points: Ask Trigger button, ⌘J, Help & Feedback. The old ⌘I and
`?aiHelp=` links keep working.

## Screenshots

<img width="1440" height="788" alt="Screenshot 2026-08-07 at 15 14 29"
src="https://github.com/user-attachments/assets/f4e89e8d-13ed-4be3-a88d-d5cca3ece0fa"
/>
2026-08-12 08:38:59 +02:00
Katia Bulatova 4569657923 feat(webapp): dashboard agent — chat, reports, investigate (#4418)
## What & why

This is the system behind the Dashboard Agent — an assistant that
answers questions about a project's runs, errors, queues, deploys and
health, and can investigate failures end to end.

The agent runs as a chat.agent task in its own Trigger project. It has
no access to the main database or ClickHouse; all platform data is read
through the public API using a delegated, read-only user token.

Everything here is behind `canAccessDashboardAgent` and inert with the
flag off. The UI that mounts the panel lands in #4529.

## Stack

`#4418` (this, base) ← `#4529` UI ← `#4525` Watch ← `#4516` storybook
gallery. The scenario/contract reference for the whole stack is
`internal-packages/dashboard-agent/GUIDEBOOK.md` (it lands on the Watch
branch): it states, per feature, what makes each thing happen and where
that is decided.

## What's inside

**Agent runtime and tools** — `internal-packages/dashboard-agent`:
prompt, tool set (API reads, TRQL query, docs, navigation,
evidence/investigations, repo source), conversation compaction, a
prompt-prefix token budget pinned by snapshot test, and sampled
LLM-judged turn evals. The package cannot import webapp server code,
which is what makes the "no DB access" claim structural rather than a
convention.

**Contracts** — `internal-packages/dashboard-agent-contracts`:
`trigger://` URIs, intents, and the block envelope every rendered card
travels in.

**Conversation store** — `internal-packages/dashboard-agent-db`: drizzle
over postgres-js in its own `trigger_dashboard_agent` Postgres schema,
plus one additive migration.

**Auth boundary** — the user-actor token gains an optional environment
claim; one guard (`userActorEnvironment.server.ts`) enforces it so
routes don't each re-derive the rule. Token minting, cap ceiling, and
the RBAC fallback path for self-hosted.

**Transport** — webapp resource routes that mint the token and proxy
each turn, and SDK-side mid-turn reconnect.

**Public API the agent reads through** — orgs, projects, environments,
runs, queue metrics, workers, a run's commit metadata, repo snapshot,
reports, and `POST /api/v1/query`.

**Reports** — the health report's layout is declared once and shared by
the card, the markdown surface and the JSON/MCP surface, so the same
report reads the same in the dashboard, the terminal and an editor.

**Block renderers** — the report and investigation cards the flows above
already emit (`app/components/dashboard-agent/`). The panel that hosts
them, and the rest of the chat UI, is #4529.

**Query safety and CSP** — see below.

## Key decisions

- **The agent is a separate Trigger project, not webapp code.** It reads
platform data over the public API with a delegated user-actor token
whose `cap` ceilings it to read scopes. No Prisma, no ClickHouse, no
webapp imports.
- **The PAT-only auth helper now refuses user-actor tokens.** This is an
intentional behavioral change: its callers consume only a bare userId
and do not enforce delegated-token capabilities. Actor-aware routes
continue through the scoped route builders instead.
- **RBAC fallback builds a delegated token's ability from its own cap**,
never the blanket ability a PAT gets (read-only when the token declares
none). Without this, the agent's read-only cap would buy a write JWT on
self-hosted.
- **Org creation checks RBAC only for user-actor tokens, and only after
the env gate**, so an install with `ORG_CREATION_API_ENABLED` off
returns 404 rather than 403, and an ordinary PAT never consults an
ability the route has no org to scope. Both orderings are pinned by
test.
- **The query path is read-only in depth.** TRQL rejects write
statements at the grammar level (they don't parse, rather than being
filtered), ClickHouse runs with `readonly=1`, and the org/project/env
filters are injected server-side from the credential — the request body
cannot widen scope. An unparseable query denies instead of falling
through to the permissive resource.
- **Document-wide img-src CSP.** Remote images are an
outbound-request/exfiltration surface, so the policy permits only
own-origin/data/blob, the required SSO avatar hosts, and the favicon
endpoint. Operators can add exact origins through CSP_IMG_SRC_ALLOWLIST;
wildcard hosts and bare schemes are intentionally not allowed.
- **The chat transport reconnects on a mid-turn EOF**
(`@trigger.dev/sdk`). A body that ends without a turn-complete is
terminal only when the server says `X-Session-Settled: true`; otherwise
the transport resubscribes from `lastEventId` with bounded backoff, and
any record re-earns the budget. Previously a closed long-poll window or
a proxy restart left the reply stuck as if still generating.
- **Conversations live in their own datastore**, schema-scoped and
foreign-key-free (it references `organizationId`/`userId` by id, because
in cloud it is a different database). It is a display read-model for the
History tab and transport resume; `chat.agent`'s object-store snapshot
remains the model's source of truth.
- **Deterministic first.** Reports and health checks contain no LLM —
they are computed from the same data the dashboard shows, and the model
only narrates and links them. That is what makes a number in an answer
auditable.

## Testing

- 63 new test files, run with `pnpm run test --filter webapp` and
per-package vitest. Heaviest coverage on the auth boundary
(`userActorPatOnlyBoundary`, `userActorTokenClaimsAndScopes`,
`contextlessPatRoutes`, `rbacFallbackBranch`), TRQL read-only, the
report layout, and the SDK reconnect.
- The agent package has a separate eval lane (`pnpm run test:evals`,
`vitest.eval.config.ts`) that hits the real model, so it never runs in
`pnpm test`.
- Live-tested against a local stack scenario by scenario; the GUIDEBOOK
lists the condition each behaviour is expected under, which is what
those runs were checked against.

## Changelog

`.server-changes/dashboard-agent.md`, plus changesets for
`@trigger.dev/core` (report schemas), `@trigger.dev/sdk` (chat
reconnect) and the CLI's `mint-token` help text.
2026-08-11 18:56:14 +02:00
Saadi Myftija 02de2e693f feat(api): separate rate limit budget for deployment endpoints (#4565)
Most deploy-flow API calls shared the general per-environment rate limit
bucket with all of that environment's runtime traffic, so an org with
heavy API usage could intermittently 429 its own deploys; the
`/api/v*/deployments` endpoints themselves were fully exempt from rate
limits as a stopgap
([#2774](https://github.com/triggerdotdev/trigger.dev/pull/2774)), which
promised a dedicated limiter as the follow-up. This is that follow-up:
the whole deploy-flow group now runs on its own budget, separate from
runtime API limits.

### Design

A new `deploymentRateLimiter` covers every endpoint the deploy flow
depends on: the `/api/v*/deployments` group, the env API key exchange
(`/api/v1/projects/:ref/:env`), build-time env var resolution and sync
(`/envvars`, `/envvars/:slug/import`), preview branches,
`/api/v1/remote-build-provider-status` and `/api/v1/artifacts`. The
general API limiter whitelists the same shared path list, so exactly one
limiter applies to each path and the two can't drift apart.

Buckets are keyed per environment for environment API keys and per token
for the PAT-authenticated phase of a CLI deploy (whoami, key exchange,
branches). The deploy budget is controlled via the
`DEPLOYMENT_RATE_LIMIT_*` env vars.
2026-08-11 17:51:31 +02:00
claude[bot] 8819e25751 fix(webapp): hard-navigate after creating an organization (#4530) 2026-08-11 16:00:06 +01:00
Eric Allam 6449a644b9 feat(webapp,cli,database): track real dev onboarding progress (#4563)
## Summary

The dev environment "Get set up" panel used to be a static list of CLI
commands that only disappeared once your tasks registered, so nothing
ever changed after you ran `init` and people assumed it was stuck. It
now tracks real progress: `trigger init` records the project as
initialized, so step 1 checks off, and the panel updates live as the dev
server connects and your tasks register.

It also adds a prominent "Copy AI agent prompt" button, presented as a
clear alternative ("or") to the manual CLI steps, that copies a
ready-to-paste setup prompt pre-filled with your project reference for
Claude Code, Cursor, or any coding agent.

## Notes

- Adds a `Project.initializedAt` column (migration
`20260811065646_add_project_initialized_at`); the CLI `init` command
calls a new project-scoped `POST /api/v1/projects/:ref/init` best-effort
at the end of setup.
- The `init` scaffold now imports from `@trigger.dev/sdk` instead of the
deprecated `/v3` subpath.

## Screenshots

<img width="2400" height="1794" alt="v7-redesigned-card"
src="https://github.com/user-attachments/assets/c2fb4fa1-9484-4700-8bd3-110d66f5a44e"
/>
2026-08-11 11:43:33 +01:00
Eric Allam 820c079145 perf(webapp): read per-run environment config from the replica at dequeue (#4560)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-supervisor (push) Has been skipped
🦋 Changesets PR / Create Release PR (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
## Summary

Adds an opt-in path to serve a run's per-run configuration reads from
the control-plane read replica instead of the primary, reducing primary
database load during task execution. The managed-worker dequeue resolves
each run's environment, organization, and environment variables before
starting the run; those rows are stable for the life of a run, so they
can safely come from the replica.

Gated by `CONTROL_PLANE_DEQUEUE_READS_FROM_REPLICA`, defaulting to `"0"`
(reads from the primary, unchanged from today). Set it to `"1"` to route
the reads to the replica. The env-var read is scoped to the
dequeue/resolution path (`resolveVariablesForEnvironment`); dashboard
env-var reads and writes always stay on the primary. When no read
replica is configured, `$replica` transparently falls back to the
writer, so single-database self-host is unchanged either way.

Verified end-to-end against a real primary/replica split, in both
`trigger dev` and deployed (managed-worker) runs: with the flag on, env
vars inject correctly and a value set immediately before triggering a
deployed run is present on the run.
2026-08-10 17:45:57 +01:00
Eric Allam 951d8e8d7b feat(webapp): per-client database pool metrics that survive the driver adapter (#4541)
## What

Follow-up to #4539. The driver-adapter work is inert until a client
flips to the pg driver adapter, but the moment one does, our database
observability degrades: the OTel metrics pipeline reads pool stats from
Prisma's `$metrics`, which is owned by the Rust engine's `quaint` pool.
Under the adapter, `pg.Pool` owns the pool, so those gauges read zero.
The pipeline also only ever scraped a single client (the control-plane
writer singleton).

This PR makes database metrics driver-agnostic and per-client:

- Every configured client registers a metrics source: control-plane
writer/replica, run-ops writer/replica, legacy writer/replica.
Previously only the control-plane writer singleton was scraped.
- Each OTel instrument is observed per client with `db_client` and
`db_driver` (`quaint` | `pg-adapter`) attributes. `db_client` uses our
canonical datasource-role labels (`control-plane-writer`,
`control-plane-replica`, `run-ops-writer`, `run-ops-replica`,
`legacy-run-ops-writer`, `legacy-run-ops-replica`) — the same strings
used for the `db.datasource` span attribute, so a metric and a trace
point at the same pool.
- Pool figures come from the authoritative source per driver:
- **pg-adapter**: `pg.Pool` (`totalCount`/`idleCount`/`waitingCount`,
plus cumulative opened/closed from `connect`/`remove` events).
- **quaint**: the Rust engine's `$metrics` pool gauges/counters, exactly
as before.
- Query counters and duration histograms still come from `$metrics` for
both drivers (the Rust engine executes queries in both cases).
- New `db.pool.connections.waiting` gauge (pg.Pool exposes this; quaint
reports 0).
- Stops exporting Prisma metrics from the Prometheus `/metrics` route.
Pool observability now lives entirely in the OTel pipeline, per driver,
per client.

## Why

So we can flip any client (including the control-plane writer, the
primary desync-fix target) to the driver adapter without losing pool
visibility. Existing dashboards keyed on the same metric names keep
working; they gain a per-client dimension.

## Testing

Unit (`apps/webapp/app/utils/databaseMetrics.server.test.ts`): the pure
normalizer — quaint reads pool from `$metrics`; adapter reads pool from
`pg.Pool` and keeps engine query metrics; `busy` never goes negative;
graceful zeroing when `$metrics` is unavailable (adapter still reports
live pool figures).

Live smoke test against a prod-shaped local stack: three
physically-distinct Postgres DBs (control-plane, run-ops, legacy) behind
dual PgBouncers, split mode on, with a mix of adapter and quaint
clients. Reading the actual emitted OTel metrics, every pool shows up as
its own series:

```
db.pool.connections.total{db_client="control-plane-writer",  db_driver="pg-adapter"} = 1
db.pool.connections.total{db_client="control-plane-replica", db_driver="quaint"}     = 1
db.pool.connections.total{db_client="run-ops-writer",        db_driver="pg-adapter"} = 1
db.pool.connections.total{db_client="run-ops-replica",       db_driver="quaint"}     = 1
db.pool.connections.total{db_client="legacy-run-ops-writer", db_driver="quaint"}     = 1
db.pool.connections.total{db_client="legacy-run-ops-replica",db_driver="quaint"}     = 1
db.client.queries.total{db_client="control-plane-writer",db_driver="pg-adapter"} = incrementing
db.client.queries.duration.count{db_client="control-plane-writer",db_driver="pg-adapter"} = incrementing
```

Confirms: metrics are attributed per pool with the correct driver;
adapter pools' figures come from `pg.Pool`; and query counters/duration
histograms keep incrementing under the pg adapter. Also verified
`/metrics` (Prometheus) now returns zero `prisma_*` series while still
serving the app's own metrics.

`pnpm run typecheck --filter webapp` passes.

## Notes

- `/metrics` (Prometheus) no longer includes `prisma_*` series. Anything
scraping that endpoint for Prisma metrics should read the equivalent
`db.*` metrics from the OTel exporter instead.
- **PgBouncer + `?schema=` gotcha (separate from this PR, worth flagging
for rollout):** since #4539 parses `?schema=` from the DSN and passes `{
schema }` to the adapter, node-postgres sends `search_path` as a startup
parameter. A transaction-mode PgBouncer rejects that with `FATAL:
unsupported startup parameter: search_path`. Our prod control-plane DSNs
use the default `public` schema with no `?schema=` param, so this is
latent, but any client we flip to the adapter must not carry `?schema=`
in its DSN (or the pooler needs `ignore_startup_parameters =
search_path`).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 13:54:06 +01:00