In stream delivery the debounce editor's stop() cleared the pending timer but
returned immediately even with an edit request already in flight. The turn then
issued the authoritative final edit to the same message ref, so a slower
in-flight partial-text edit could land after it and leave truncated text as the
last write. stop() now awaits any in-flight edit (and makeChannelStreamTap's
flush/cancel and the turn finally await stop()), so the final edit is always the
last write.
The generic webhooks.slack() SDK source was auto-generated from the core provider
config, which carries only the HMAC verifier. Slack additionally needs the
one-time url_verification handshake (before an Event Subscriptions URL can be
saved) and form-encoded interactivity parsing, so an endpoint built from
webhooks.slack() could never be connected.
Drop slack from the generated SDK producers (runtime + type) and export
webhookSource() from @trigger.dev/slack, which carries the handshake + formPayload
the connector already uses. Users get a plain Slack webhook source from
webhookSource(), or the full chat channel from slack(). Core's slack config stays
for server-side verification.
The turn-start 'working' resolver and the success-path 'done' resolver both
awaited user-supplied reactions.working / reactions.done callbacks unguarded. A
rejection propagated into the turn catch, which then posted an error to the
channel and skipped onTurnComplete / the snapshot write, turning an
already-delivered answer into a reported failure. Wrapped both in try/catch that
degrade to a warn, matching the error-path reaction handling.
The SELF_MESSAGE_GUARD in-list was unspaced earlier to match the documented
filter style, but its unit test still asserted the spaced substring. Update the
assertion. Also apply oxfmt to a wrapped line in the channel error-reaction
block that exceeded the width limit.
A text-only reply issued chat.update with text alone, so Slack kept whatever
blocks were already on the message (e.g. approval controls previously rendered
into the same ts). Send blocks: [] on edits when the outgoing message has no
blocks, so an edit is authoritative. postMessage still omits blocks when empty.
- webhooks.gitlab used the Svix preset (svix-id/svix-timestamp/svix-signature,
HMAC over {id}.{timestamp}.{body}), but GitLab doesn't sign bodies: it echoes
the configured token in X-Gitlab-Token. Every GitLab delivery failed
verification. Switched it to the shared-secret scheme (header, x-gitlab-token).
- The Slack connector declared secretProvisioning 'integrator', so the Connect
panel offered a Generate-secret button, but Slack mints its own Signing Secret
that must be pasted (core already classifies slack as 'provider'). Changed to
'provider'.
- A verified channel delivery naming a connector the running agent doesn't have
fell through to a normal turn with an empty incoming message, so the agent
answered nothing and burned tokens. Handle the unresolved-connector case like a
stale interaction: warn with the connectorId/deliveryId and skip the turn
without consuming a turn.
- The channel-interaction pre-hydrate was gated on an empty accumulator, so
a hydrate-backed agent whose in-memory accumulator was non-empty but no
longer held the pending tool part (after a chat.history mutation or a
compaction trim) would skip the hydrate and drop the click as stale. Gate
instead on the pending toolCallId being absent from the accumulator, so
the persisted chain is reloaded whenever the call can't be found (a
duplicate click on an already-resolved call still short-circuits without a
needless hydrate, since the call is present).
- Unspace the Slack SELF_MESSAGE_GUARD in-list to match the documented
filter style. The runtime parser already skips whitespace so this is
cosmetic, but it keeps the guard consistent with the docs example.
- channelReplyText selected parts with 'text' in p, which also matches AI
SDK reasoning parts ({ type: 'reasoning', text }). With sendReasoning
defaulting to true, a reasoning-capable model's private chain-of-thought
was concatenated into the text posted to Slack. Narrowed the selection to
parts whose type is exactly 'text'.
- normalizeKeyString namespaced only the whole placeholder, so a fallback
key like {a || b} (the Slack connector's DEFAULT_KEY form) left every
alternative after the first bare, diverging from ValidatedWebhookKey which
validates each || side and defaults bare sides to the body. It now splits
on ||, trims, and namespaces each alternative. Added test cases.
- On the turn-error path the error text was sent with previousRef pointing
at the ack placeholder. If a hook after the final answer was posted threw
(e.g. onTurnComplete), the delivered answer was edited into the error
string. Track whether the final answer was posted and, if so, send the
error as a fresh follow-up instead of editing over the answer.
The channel-interaction pre-hydrate added for hydrateMessages agents
referenced clientData, but clientData was declared with const later in the
same turn block. The early reference hit the temporal dead zone and threw
ReferenceError, which the local try/catch swallowed, so hydration silently
never ran and the button click was still dropped as a stale interaction.
Parse clientData once above the channel-event block so both the interaction
hydrate and the rest of the turn share the same value.
- buildInteractionResolutionMessage matched a tool part by toolCallId
without checking it was still input-available, so a duplicate or
retried approval/deny click on an already-answered tool call built a
fresh resolution and ran the decision a second time. Gate on
state === 'input-available' so a resolved call resolves to a dropped
stale interaction, matching the function's documented contract.
- The turn-error channel egress only edited the start-of-turn ack
placeholder, so a connector configured with ack: null posted nothing on
a failed turn (the user saw silence). Send a fresh final message when
there is no placeholder ref. The error text is now run through the
agent's onError option (same sanitizer the browser stream uses) instead
of forwarding turnError.message verbatim to the channel surface.
A channel interaction (e.g. a Slack approval click) is resolved at the
top of the turn by matching its toolCallId against the in-memory
accumulator. For agents that register hydrateMessages, the accumulator is
empty on a fresh continuation / OOM-retry boot (those agents own
persistence, so the boot seeding block is skipped and the hydrate hook
only runs later in the turn). The click therefore matched nothing, was
classified as a stale interaction, and was dropped, losing the human's
decision and leaving the paused turn unresolved.
When an interaction arrives and hydrateMessages is registered but the
accumulator is empty, load the persisted chain via the hydrate hook first
so the interaction can find its pending tool call. The per-turn hydrate
later in the turn still runs to persist the synthesized resolution
message. Guarded on an empty accumulator so warm turns don't re-hydrate.
Two channel-connector fixes in the chat.agent turn loop:
- Under a pendingMessages-enabled agent, the mid-turn session.in handler
returned early for any record without a steerable `.message`. Channel
deliveries carry `channelEvent` and no `.message`, so an inbound Slack
message or block_actions approval click arriving mid-turn was neither
steered nor buffered, and never became a turn. Channel records now fall
through to pendingWireMessages so they dispatch as the next turn.
- In the turn error handler, resolveReactionChoice (which invokes the
user's reactions.error callback) and channelConn.outbound ran unguarded.
A throw escaped the catch, skipping the error chunk, turn-complete, and
wait-for-next-message, killing the whole conversation. Both are now
wrapped so a bad user callback degrades to a warn.
A duplicate or late channel interaction (e.g. a repeated Slack approval
click) whose toolCallId matches no pending tool call was dropped without
detaching the per-turn session.in listener. That listener marks inbound
records CONSUMED, so after the drop the next user message was swallowed
into pendingWireMessages (already drained before the wait) and the turn
idled until the timeout, ending the run and losing the message.
Mirror the action path: detach msgSub and decrement turn (a dropped
interaction is not a turn) before falling through to the wait.
sendChannelEvent/deliverChannelEvent default connectorId to DEFAULT_TEST_CONNECTOR_ID,
not to a sole configured connector's id as the previous doc implied. Describe the
actual default (which lines up with recordingChannelConnector's own default id).
The typed-event example now passes webhooks.stripe<Stripe.Event>() to webhook()
so it actually demonstrates typed access to event. The verification section no
longer claims every idempotency key comes from a provider event id (it documents
the raw-body/timestamp/signature fallback), and adds a warning that url-secret
exposes the secret in the URL.
defaultSlackFinalizeInteraction re-rendered the message by dropping every actions
block, so once one tool call was approved or denied the other pending tool calls
in the same message lost their buttons and their turn could not resume. Drop only
the resolved tool call's actions block (matched by the toolCallId:: button value
prefix) and keep the rest.
The key placeholder regex used [^}]+, so a run of "{" with no closing brace
backtracks quadratically: every start position rescans to the end. Exclude "{"
from the character class so a non-matching position fails immediately (a valid
{path} placeholder never contains "{"), making normalization linear.
Add a fire-and-forget deliverChannelEvent to the harness and loop-level tests
for the channel interaction paths: a resolved interaction callback resumes the
pending tool and finalizes the controls, and a stale callback that matches no
pending tool is dropped without running a turn or posting anything.
Three fixes to chat.agent channel egress:
The debounced stream editor now re-arms after an edit it skipped because a
previous edit was still in flight, so text buffered during that window still
reaches the channel instead of stalling until the next delta.
The stream editor is stopped when the reply stream is aborted or cancelled,
not just on normal completion, so a late timer can no longer edit the channel
message after the turn has ended.
A turn that throws now edits the placeholder to show the error, so a channel
user sees the failure instead of a message stuck on the loading placeholder.
Add channel-event delivery to the mockChatAgent harness (sendChannelEvent)
and a recordingChannelConnector helper to @trigger.dev/sdk/ai/test, so a
chat.agent's channel round-trip (inbound mapping, ack placeholder, egress
send, edit-in-place, and lifecycle reactions) can be driven and asserted
entirely offline.
Several webhook doc code blocks used webhook, webhooks, streamText, anthropic, or chat without importing them, so a copied snippet would not type-check on its own. Add the imports to the standalone examples across sources, filters, channels, and human-in-the-loop.
The link replacement in toSlackMrkdwn used unbounded character classes, which can backtrack quadratically on pathological input. Bound the link text and URL lengths and exclude newlines. Also tightened two test assertions that used unsafe optional chaining.
The channels setup listed only chat:write, which posts replies but does not grant read access to message.channels events. Add channels:history to the app scopes and note that adding scopes after install requires a reinstall.
The Slack package sets types: ["node"] in its tsconfig but did not declare @types/node, so type resolution depended on workspace hoisting. Declare it at the repo-pinned version, matching the other packages that opt into node types. vitest stays root-provided, consistent with every other package.
The HITL renderer only posted approve/deny buttons for the first pending tool call, so when a turn paused on multiple tool approvals the rest never got controls and the turn could not finish. It now renders a section plus an approve/deny pair for each pending call.
A channel interaction callback (for example a Slack button click) that resolves to a tool call with no matching pending tool part is a stale or duplicate callback. It was falling through to the inbound-message path, which acked, reacted, and ran a full agent turn. Such callbacks are now dropped: no turn runs and the run returns to its idle wait for the next message.
The HITL approval block serialized the tool input without a bound into a Slack section text field, which is capped near 3000 characters. A large input made chat.postMessage fail with invalid_blocks so the approve and deny controls never appeared. The serialized input is now capped to keep the block within the limit.
Two webhook() declarations sharing an id used to silently overwrite each other in the worker manifest. Indexing now fails with the colliding ids and their file paths, matching how duplicate task ids are already handled.
The deploy path now forwards declared webhooks to the server the same way dev does, so hosted webhook endpoints are created and stay active on deploy instead of only working under trigger dev.
## 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.
## 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
<!-- 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>
## 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.
## 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.
## 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.
## 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.
## Summary
Clarifies when to add a changeset or a `.server-changes/` file. The
friction that keeps coming up is treating these as "I touched a public
package or a server app, so I owe a note." They are user-facing release
notes that go straight into the changelog customers read, not a catalog
of every change.
The guidance now leads with the real test: would a user or customer care
about this change? Add a note when the change is something they would
notice, act on, or want to hear about. Skip it otherwise, even when a
public package or server app is touched, for example:
- internal-only or admin-only changes, refactors, test-only changes,
chores
- performance or query tuning with no user-visible behavior change
- public packages that are not consumed independently (e.g.
`@trigger.dev/redis-worker`), where a version bump means nothing to a
user
Anyone who wants the exact history reads the commits.
Updates every place that encoded the old "touched a package or app, so
add a note" rule so they agree: `AGENTS.md`,
`.server-changes/README.md`, `CONTRIBUTING.md`, `CHANGESETS.md`,
`.claude/rules/server-apps.md`, and `.claude/REVIEW.md` (the last drives
automated review flagging, so it stops flagging exactly the changes the
new guidance says to skip). Also handles the mixed-PR case where the
package change needs no changeset but the server change is user-facing.
## 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.
The generated deploy Containerfile now starts from the prebuilt base
images published by base-images/ (`triggerdotdev/node` and
`triggerdotdev/bun` on DockerHub, pinned by digest) instead of
installing system packages during every project's build. Uncustomized
projects run no apt at all and their base layers are identical across
every project, so worker nodes cache one copy fleet-wide. The build
stage uses the -build toolchain variant for uncustomized and
package-only projects; projects with image instructions build FROM base
so instructions and their downloads run exactly once.
### Notes
- User packages install in their own sorted RUN with --allow-downgrades
(a pin of a preinstalled package is a downgrade against the prebuilt
base), preceded by a dpkg repair whenever instructions came first, since
apt-get install refuses to run on state a dpkg -i instruction left
broken.
- Deployed runtime images inherit newer package versions than today's
live-archive installs (the published bases upgrade everything to their
snapshot), plus the base images' OCI labels. Runtime env, user, workdir,
and entrypoint are unchanged.
## 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.
## 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.
## Summary
Projects that configure their own `metricExporters` or `metricReaders`
in `trigger.config.ts` were losing task metrics on nearly every run, and
seeing an unexplained `Failed to flush tracingSDK` alongside
`OTLPExporterError: Bad Request` in their run logs. Spans and logs kept
working, so the runs otherwise looked healthy.
## Root cause and fix
Every configured exporter gets its own `PeriodicExportingMetricReader`,
and `meterProvider.forceFlush()` fans out across all readers with
`Promise.all`, so two collections can land on the same millisecond.
`@opentelemetry/host-metrics` divides by the elapsed interval to compute
`process.cpu.utilization`
([common.ts](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/host-metrics/src/stats/common.ts)),
so a zero interval yields `0/0`. `JSON.stringify(NaN)` is `null`, and a
collector rejects `"asDouble": null` with a 400 that drops the
**entire** request, not just the offending point.
`flush()` and `shutdown()` now walk the metric readers one at a time, so
collections can no longer share a timestamp. Each reader is isolated, so
one failing reader cannot skip the readers behind it, and every failure
is logged with the reader that produced it. The first error is still
rethrown, so callers see failures exactly as before.
As a second layer, non-finite data points are dropped just before our
own export, so a metric that divides by zero cannot take the rest of the
batch with it. Exporters and readers supplied through
`trigger.config.ts` are untouched by that filter and still receive raw
data.
The trade-off is that configured exporters now flush after the built-in
one rather than alongside it, so flush latency is the sum rather than
the max.
An internal test package's dependency on core was replaced with a local
helper, because core now needs that package in `devDependencies` and the
two together formed a workspace cycle.
## Verification
Tested against a real collector in a container: a batch containing a
`NaN` reading is rejected with a 400 without the fix and accepted with
it, and a single flush is asserted to collect from one reader at a time.
## Summary
Fair queue consumers could leak the per-tenant concurrency slots that
gate admission. Slots were freed on some paths and skipped on others,
and once enough leaked slots accumulated for a tenant, every queue that
tenant owned stopped being served until someone cleared the set by hand.
This PR frees slots on every path and, more importantly, makes the
remaining failure modes self-healing.
## Design
The fix applies one rule uniformly: releasing a concurrency slot is
best-effort cleanup and must never block the message's primary state
transition. Blocking completion re-delivers the message, which
duplicates customer work; blocking a retry loses the attempt increment,
so the message can circle forever; blocking a reclaim strands the
message in flight. A leaked slot is the better failure in every one of
those trades because it is the only one that is recoverable. A failed
release is therefore logged and the transition proceeds.
Leaked slots then heal through two mechanisms:
- `reserve` re-admits a message that is already a member of its own
concurrency set, since re-admitting it does not increase concurrency. A
message whose earlier release failed can no longer be blocked by its own
leftover slot.
- A reconcile loop periodically removes any set member with no in-flight
record (interval configurable via `reconcileIntervalMs`, default 60s).
The check-and-remove is atomic, and it is sound because a message is
always registered in flight before its slot is reserved, so a member
with no in-flight record can only be a leak. This also covers leaks this
PR cannot prevent directly, such as a release that resolves the wrong
concurrency group from queue metadata.
Ordering hardening from earlier revisions stays: slots are released
before the in-flight record needed to describe them is discarded, the
release Lua scripts write the message back to the queue before removing
it from in-flight (Lua does not roll back on error), and dangling
in-flight entries with no payload are dropped instead of being rescanned
forever.
Every guard test was verified to fail without its specific fix,
including the duplicate-execution case: completing a message while its
slot release fails used to re-deliver and re-execute it.
Every publish now also pushes an immutable per-publish tag alongside the
mutable one, named after the snapshot date and commit (e.g.
`22-bookworm-20260812-45444a7`), so previously published digests stay
tag-referenced after republishes. Shipped CLI releases pin those
digests, so they must remain resolvable indefinitely.
Merging triggers a republish; the fresh tag-protected digests will then
be pinned by #4602 before it merges.
## 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.