Commit Graph

7847 Commits

Author SHA1 Message Date
Eric Allam 693c646424 fix(sdk): await the in-flight stream edit before the final channel egress
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.
2026-08-17 09:28:40 +01:00
Eric Allam 093d0cd652 fix(sdk,slack): move the Slack webhook source into @trigger.dev/slack
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.
2026-08-17 08:16:31 +01:00
Eric Allam b9797ec886 fix(sdk): guard success-path channel reaction callbacks so a throw can't fail a delivered turn
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.
2026-08-17 01:02:01 +01:00
Eric Allam 7ddc72d5d2 fix(slack): update self-message-guard test for unspaced in-list; fix ai.ts formatting
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.
2026-08-17 00:52:03 +01:00
Eric Allam cc9f8c6949 docs(sdk): note the interaction-resume double-call in hydrateMessages jsdoc 2026-08-17 00:08:05 +01:00
Eric Allam 9f8015ee92 fix(slack): make chat.update edits authoritative by clearing stale blocks
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.
2026-08-17 00:07:15 +01:00
Eric Allam c2f761a289 fix(sdk,slack): correct GitLab verifier and Slack secret provisioning, skip unroutable channel turns
- 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.
2026-08-16 23:57:57 +01:00
Eric Allam f8d545956e fix(sdk,slack): hydrate for channel HITL whenever the pending tool call is absent; unspace Slack guard in-list
- 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.
2026-08-16 23:45:20 +01:00
Eric Allam d921d0152b fix(sdk): stop leaking reasoning to channels, fix fallback-key namespacing, don't clobber posted answers
- 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.
2026-08-16 23:37:17 +01:00
Eric Allam 811286b4a1 fix(sdk): hoist clientData parse so channel-interaction hydrate doesn't hit a TDZ
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.
2026-08-16 23:26:06 +01:00
Eric Allam 92fe257148 fix(sdk): drop duplicate channel interaction clicks and harden channel error egress
- 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.
2026-08-16 23:22:13 +01:00
Eric Allam e2f3dc60bd fix(sdk): resolve channel HITL against the persisted chain for hydrateMessages agents
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.
2026-08-16 23:16:10 +01:00
Eric Allam f86d3cd3fe fix(sdk): stop losing channel deliveries mid-turn and dying on channel error callbacks
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.
2026-08-16 22:48:20 +01:00
Eric Allam e75a7ca1ae fix(sdk): detach the per-turn message listener when dropping a stale channel interaction
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.
2026-08-16 22:07:06 +01:00
Eric Allam 8dd16986e7 docs(sdk): correct the channel-connector default in the chat.agent test harness
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).
2026-08-16 19:35:32 +01:00
Eric Allam a7c0e8b740 docs(webhooks): fix typed-source example, idempotency fallback, and url-secret warning
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.
2026-08-16 19:35:32 +01:00
Eric Allam 478943d24f fix(slack): keep approve/deny controls for other pending tool calls after one decision
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.
2026-08-16 19:35:18 +01:00
Eric Allam b3bb085fe3 fix(sdk): bound the webhook key-template regex against ReDoS
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.
2026-08-16 14:33:45 +01:00
Eric Allam 7a4ae6d7de test(sdk): channel interaction coverage in the chat.agent test harness
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.
2026-08-16 14:33:45 +01:00
Eric Allam ff7b512eff fix(sdk): channel stream editor tail, abort cleanup, and error egress
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.
2026-08-16 14:33:45 +01:00
Eric Allam 7d6eb9f011 test(sdk): channel connector support in the chat.agent test harness
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.
2026-08-16 14:33:45 +01:00
Eric Allam dd4444440f docs(webhooks): make example snippets self-contained with imports
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.
2026-08-16 14:33:45 +01:00
Eric Allam 4ebed26e1c fix(slack): bound the markdown link regex to avoid quadratic backtracking
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.
2026-08-16 14:33:45 +01:00
Eric Allam 071054f081 docs(webhooks): require channels:history for Slack message events
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.
2026-08-16 14:33:45 +01:00
Eric Allam 9a23c2b49a chore(slack): declare @types/node explicitly instead of relying on hoisting
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.
2026-08-16 14:33:45 +01:00
Eric Allam 3ccb9c91cc fix(slack): render approval controls for every pending tool call
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.
2026-08-16 14:33:45 +01:00
Eric Allam 52fc043ec4 fix(sdk): drop stale channel interaction callbacks instead of starting a turn
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.
2026-08-16 14:33:45 +01:00
Eric Allam b7fffa3ad5 fix(slack): truncate oversized tool input in approval blocks
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.
2026-08-16 14:33:45 +01:00
Eric Allam 494d8e9964 fix(cli,core): fail indexing on duplicate webhook ids
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.
2026-08-16 14:33:45 +01:00
Eric Allam 8573e7241f fix(cli): send declared webhooks in the deploy worker metadata
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.
2026-08-16 14:33:45 +01:00
Eric Allam cf566adb81 feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop 2026-08-16 14:33:45 +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
Chris Arderne 1240d91e43 perf(clickhouse): add task_events_v2 inserted_at minmax index (#4620) 2026-08-14 14:46:23 +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 603c278687 docs: clarify when changesets and server-changes files are needed (#4617)
## 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.
2026-08-14 13:36:06 +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
Saadi Myftija c4b5e27258 feat(cli): build deployment images on prebuilt base images (#4602)
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.
2026-08-14 12:27:06 +02: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
nicktrn fa7eea39d8 fix(core): stop custom metric exporters breaking the metrics export (#4613)
## 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.
2026-08-14 08:40:07 +01:00
Matt Aitken 1114d9d6f9 fix(redis-worker): stop fair queue leaking concurrency slots (#4540)
## 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.
2026-08-13 19:36:20 +01:00
Chris Arderne 20a0ac5055 chore: fix lint warnings (#4605) 2026-08-13 17:26:46 +01:00
Saadi Myftija 035e71010d feat(base-images): immutable per-publish image tags (#4607)
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.
2026-08-13 18:22:50 +02: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.
docs-release-2026-08-13
2026-08-13 16:31:51 +01:00