Compare commits

...

261 Commits

Author SHA1 Message Date
Dan Sutton b2d56a9c03 refactor(run-engine): stop exporting the internal BlockedRun type
`BlockedRun` is only named inside types.ts, by CompleteResult. The repo's
knip gate rejects unused exports, so drop the export keyword rather than
add a knip.json exception — nothing outside this file needs the name yet.
2026-08-21 16:10:58 +01:00
Dan Sutton bf1c63b2e1 refactor(run-engine): cite the batchId log sites by symbol, not line number
The RunBlockEdge comment pointed at stale waitpointSystem.ts line
numbers that no longer match the file after this branch shrank it.
Name the continueRunIfUnblocked method instead so the citation can't
drift again.
2026-08-21 16:08:24 +01:00
Dan Sutton 43e24de84b refactor(run-engine): restore dropped residency comment clauses 2026-08-21 16:08:24 +01:00
Dan Sutton 0909db83d3 refactor(run-engine): move waitpoint creation and minting behind the coordinator 2026-08-21 16:08:24 +01:00
Dan Sutton c91593fc7d refactor(run-engine): move waitpoint completion behind the coordinator 2026-08-21 16:08:24 +01:00
Dan Sutton e73577ae5f refactor(run-engine): move block-edge registration behind the coordinator 2026-08-21 16:08:24 +01:00
Dan Sutton a724f32588 refactor(run-engine): move the run block-state read behind the coordinator 2026-08-21 16:08:24 +01:00
Dan Sutton 9353580508 refactor(run-engine): add WaitpointCoordinator seam with clearRunBlockState 2026-08-21 16:08:24 +01:00
Daniel Sutton c5c2ea92ca feat(core): add shard-routable run-ops id format and resolveShard (#4750)
## Summary

Adds a second generation of run-ops id, plus the resolver that reads a
store key straight out of an id. A gen-2 id keeps the existing
26-character layout, but the character at index 24 becomes a routing
shard key instead of a region code, and the version character at index
25 becomes `"2"`. Nothing mints gen-2 ids yet, so this is inert on
merge.

## Design

The version character is a single character, so the gen-1 and gen-2
shape checks can never both match. That is what makes the two
generations provably disjoint rather than disjoint by convention.

```ts
resolveShard(id) // gen-2 body    -> its shard key, [a-z0-9]
                 // gen-1 v1 body -> "new"
                 // anything else -> "legacy"
```

`resolveShard` is total: it returns a key for any input string,
including an empty or malformed one, and never throws.
`classifyResidency` keeps its signature and its two values, and now
reports gen-2 ids as part of the dedicated family, so existing consumers
of that boolean are unaffected.

The body stays 26 characters rather than 27 deliberately. The older
27-character format is still in the wild and has to keep resolving to
legacy, and a longer gen-2 shape would need probabilistic disambiguation
against it. A rare misroute is not an acceptable property for a routing
key.

The one behavior change is that a 26-character body ending in `"2"` now
routes by its shard key instead of falling back to legacy. Two test
assertions pinned the old result and are updated here. A repository-wide
search confirms they are the only two of their kind.

Verified against the full run-store corpus (68 files, 370 tests) with no
test-file changes there, plus the run-engine residency and waitpoint
suites. No changeset: the new surface has no caller, so a version bump
would tell a user nothing.
2026-08-21 13:04:25 +01:00
Eric Allam 60d71da90e perf(webapp,run-engine): cut CPU on the engine-facing worker-action routes (#4746)
Cuts CPU on the `engine/v1/worker-actions/*` routes a managed supervisor
calls, and adds the benchmark harness the numbers come from.

Measured on a local stack: **on-CPU per completed run 9.07ms → 6.59ms
(−27%)**, busy fraction 45.6% → 33.8%, with every worker-action p50 down
23–27%. Load was 5,000 runs / 24 virtual supervisors / 90s window /
30,120 requests / 0 errors.

Query-count work from the same investigation is deliberately **not**
here — it will follow as a separate PR.

## The three changes

**1. Split the event-loop monitor in two (~14% of on-CPU, plus ~5pp of
GC).**

`eventLoopMonitor.server.ts` installs a global `async_hooks` hook:
`init` writes a `Map` entry for *every* async resource the process
creates, `before` calls `process.hrtime()` and `context.active()` on
every one. Enabling any async hook also puts V8 on the slow path for
promise instrumentation process-wide. `EVENT_LOOP_MONITOR_ENABLED`
defaulted to `"1"`, so this was the shipping configuration.

The blocked-loop detector is now opt-in (`EVENT_LOOP_MONITOR_ENABLED`,
default `0`). The event-loop *utilization* gauge — a single interval
timer with no per-request cost — moves to its own flag
(`EVENT_LOOP_UTILIZATION_MONITOR_ENABLED`, default `1`) and stays on, so
the useful half survives without the expensive half.

A/B under identical load:

| | monitor on | monitor off | change |
|---|---|---|---|
| on-CPU per run | 9.08ms | 7.25ms | −20% |
| GC self time | 9.80% | 5.05% | −4.75pp |
| dequeue p50 | 76.6ms | 62.8ms | −18% |
| attempts/start p50 | 56.3ms | 43.5ms | −23% |

**2. Bucket route matching by first static path segment (10.4% → 3.9% of
on-CPU).**

`patches/@remix-run__router@1.23.3.patch` already memoized flattened
branches and compiled path regexes. What remained was the linear scan:
`matchRouteBranch` walked the ranked branch list calling `matchPath` per
branch across 521 route files, so every worker-action request paid a
scan proportional to the whole route table.

Branches are now indexed by their lowercased leading segment, with one
always-considered list for branches whose leading segment is dynamic,
splat or optional (and for root/pathless paths). A request walks only
its own bucket merged with that list. Route-matching self time dropped
64% (3.6s → 1.3s over a 90s window).

Ordering is preserved exactly: both lists hold indexes into the already
rank-sorted branch array and are walked in ascending-index order, so the
first match found is the same branch the full scan would have found.
Bucketing lowercases on both sides, so case-insensitive matching still
resolves and `caseSensitive: true` routes are still rejected by
`matchPath` itself. A pathname whose own leading segment can't be
bucketed falls back to the full scan.

Verified equivalent to the unpatched matcher over 20,050 pathnames
(literal, dynamic, splat, optional, case variants, basenames,
percent-encoded) with zero mismatches.
`apps/webapp/test/routeMatchingPatch.test.ts` pins the matching
semantics rather than the optimisation, so it still passes without the
patch.

**3. Demote per-heartbeat and per-dequeue `info` logs to `debug`.**

These are the two highest-rate engine calls and each wrote a synchronous
structured log line on every request. Synchronous `console` writes can
block the loop when stdout backs up, which costs more than the ~1.3% CPU
share suggests.

## The harness

Two benchmarks, neither in the default suite (they run for minutes,
attach the V8 profiler, and report numbers rather than assert on them).
See `apps/webapp/test/bench/README.md`.

- `apps/webapp/test/bench/engineHttp.bench.test.ts` — spawns a real
webapp against throwaway Postgres/Redis containers, seeds a production
environment with a promoted managed deployment, and drives a closed-loop
supervisor pool through the full lifecycle. Profiling runs over CDP
rather than `--cpu-prof` so it covers only the measured window instead
of being swamped by boot, and `performance.eventLoopUtilization()` is
sampled *inside* the webapp process.
-
`internal-packages/run-engine/src/engine/bench/runEngineLifecycle.bench.test.ts`
— drives `RunEngine` directly, profiling enqueue and lifecycle
separately so engine cost isn't mixed with request-stack overhead.
- `apps/webapp/test/bench/analyzeProfile.ts` — dependency-free
`.cpuprofile` analyzer that symbolicates through the build's source maps
and ranks CPU by package, self time and total time. Percentages are
shares of on-CPU time (V8's `(idle)`/`(program)` excluded).

`startWebapp` gains `overrideEnv`, applied after the worker-disable
defaults, so the HTTP bench can re-enable the run engine worker that
drains the master queue into the worker queues a supervisor dequeues
from.

The local OTel collector gains a traces pipeline. It only defined a
metrics pipeline, so pointing `INTERNAL_OTEL_TRACE_EXPORTER_URL` at it
locally failed and the webapp silently fell back to the console span
logger.

## Configuration

For operators upgrading:

- `EVENT_LOOP_MONITOR_ENABLED` (now defaults to `0`) — the
per-async-resource blocked-loop detector. Set to `1` to restore the
previous behaviour and keep emitting `event-loop-blocked` spans.
- `EVENT_LOOP_UTILIZATION_MONITOR_ENABLED` (new, defaults to `1`) — the
`nodejs.event_loop.utilization` gauge. Unchanged in behaviour; it just
has its own flag now so it survives turning the detector off.

## Notes for review

- `pnpm-lock.yaml` changes only because the router patch content
changed, which changes its patch hash.
- One thing the profile ruled out: with a real OTLP collector receiving
spans, tracing costs ~1.7% of on-CPU at 100% sampling and ~0.8% at the
production rate. Span shipping is not a hidden cost, so nothing here
touches it.
- Caveats on the numbers: a laptop, not production hardware, so DB and
Redis *latency* are unrepresentative (client-side CPU is what's ranked);
single webapp process; throughput varies ~5% run to run, which is why
the claims rest on on-CPU per run rather than req/s.

## Verification

- 20,050-pathname router equivalence check vs the unpatched matcher,
zero mismatches
- `apps/webapp/test/routeMatchingPatch.test.ts` (12 cases) passes
- webapp e2e smoke suite (68 tests) passes through the patched router
- run-engine suites covering the snapshot/attempt paths pass
- `typecheck`, `format`, `lint`, `knip` clean
2026-08-21 11:53:16 +01:00
claude[bot] 4953128c10 chore: vouch wuweiweiwu (#4748)
Adds `wuweiweiwu` to the vouched-contributors list.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Daniel Sutton <45313566+d-cs@users.noreply.github.com>
2026-08-21 09:28:27 +00:00
James Ritchie 2efb07e0b1 Toggle switch feels nicer to toggle (#4749)
Two small tweaks to the `Switch` primitive, so every variant and call
site picks them up:

1. **Track is 2px shorter.** `large` 44 → 42px, `medium` 32 → 30px,
`small` 24 → 22px. The checked thumb travel drops by the same 2px so the
thumb stays flush at both ends.
2. **Holding the switch down stretches the thumb into an oval** pointing
the way it's about to travel — rightwards when off, leftwards when on.
Pure CSS via `group-active:`, no new state or handlers.

The thumb's `transition` shorthand doesn't cover `width`, so it's now
`transition-[translate,width,background-color]` (same 150ms
duration/easing as before). `size-N` on the thumb became `h-N w-N` so
the press rule overrides the same `width` utility.

Verified in headless Chrome across all five variants in both states:
correct widths at rest, thumb flush at both ends, stretch grows the
right direction, and no overflow of the track.

<img width="266" height="108" alt="CleanShot 2026-08-21 at 10 16 14"
src="https://github.com/user-attachments/assets/ee95a399-0a40-48c4-a325-a1166b3bd88a"
/>


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

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

---

[Open workspace in
Conductor](https://app.conductor.build/workspace/c1ce8d0f-9ed2-4fbc-8084-a3989484cc53)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:24:29 +01:00
DKP d04467018e feat(webapp,database): save platform notifications as drafts and publish later (#4743)
## Summary

The platform notifications admin page can now save a notification as a
draft without committing to a schedule, then publish it later by
entering start and end dates. Drafts stay hidden from the webapp panel,
the CLI, and the "What's new" changelog until they are published.

## Design

A draft is an `isDraft` flag on `PlatformNotification`, not nullable
dates, so the existing index and every read query stay intact. All three
reader queries filter on the flag, so a draft can never surface
regardless of its placeholder dates. Publishing writes the real start
and end dates and clears the flag; the publish dialog validates the
range and shows inline errors. Editing a draft keeps it a draft, with
the schedule fields hidden until publish.

Also folds in a small tweak: the "Send preview to me" test button now
appears when editing a notification, not just when creating one.
2026-08-20 22:16:32 +01:00
Eric Allam 1034b618a4 fix(webapp): let an impersonating admin preview the Queue Metrics UI (#4736)
## Summary

The Queue Metrics dashboard UI is gated by a per-org feature flag, so
there was no way to look at it for a real org without turning it on for
every member of that org. An admin impersonating into an org now sees
the metrics UI there regardless of the flag, so it can be checked
against real data before anyone else in the org sees it.

Nothing changes for a normal session: a member of an org whose flag is
off still gets the classic Queues page, and the gated sub-routes still
404.

## Design

The gate had no request and only resolved the org flag. It now takes the
request and resolves impersonation itself, rather than each caller
computing a boolean and passing it in, so the rule lives in one place
and a new call site cannot forget it. Seven call sites gate on this,
which is exactly why.

Two things narrow the bypass:

- It keys on **impersonation**, not `user.admin`. Impersonation is
scoped to one org and is deliberate; keying on admin status would
silently hand every admin the preview in their own day-to-day orgs.
- It yields to the **view-as-user** toggle. That toggle exists so an
impersonating admin can see what the member sees, and unreleased UI
leaking through it would make it lie. Suppressing a read-only view there
stays inside the display-only contract in `hasAdminDisplayAccess` (added
in #4421).

The bypass also stays behind the gate's existing org-membership lookup.
Since the acting user id is the impersonation target, that lookup is
what keeps the preview confined to the org actually being impersonated
into.

Verified end-to-end against a running instance across the matrix: member
with the flag off gets the classic view and 404s; the same org under
impersonation gets the metrics view and a 200; flipping view-as-user
returns it to the member's exact experience and back; and the flag-on
path is unchanged. An admin who is merely a member, not impersonating,
still gets the classic view.

One thing worth flagging: a few route comments say that with the flag
off no metrics reads fire. That remains true for every member session
and for the org as a whole, but an admin actively previewing does
exercise that org's real Redis and ClickHouse reads. That is inherent to
previewing, and bounded to one admin session.
2026-08-20 15:46:52 +01:00
Eric Allam 9baebbd1a6 fix(webapp): keep the dashboard agent's tool calls on the user's instance (#4740)
## Summary

Follow-up to #4738. Splits the dashboard agent's base URL into two: the
instance that hosts the agent project (used for sessions), and the
instance the agent acts against as the user (used by its read-tools).
#4738 only needed the first, but moved the second along with it, which
breaks the tools when the agent runs on a different instance than the
webapp.

## Root cause

The agent's read-tools call the API as the logged-in user via a
delegated user-actor token. The webapp signs that token with its own
`SESSION_SECRET`, scoped to its own `userId` and `environmentId`, so it
can only be verified by, and only resolves the user's data on, that same
instance. #4738 routed the injected `apiOrigin` those tools use to the
agent's host instance, so the token no longer verifies and the data
isn't there.

## Fix

`dashboardAgentApiOrigin()` stays the agent's host instance (sessions,
task triggers, realtime, the `in` forward). A new
`dashboardAgentUserApiOrigin()` returns the webapp's own origin
(`API_ORIGIN ?? APP_ORIGIN`) and is injected into the run metadata the
tools use. Same-instance deployments resolve both to the same host, so
behavior is unchanged there.
2026-08-20 15:27:27 +01:00
Eric Allam 56f875680c fix(webapp): let the dashboard agent use a configurable base URL (#4738)
## Summary

Lets the dashboard agent point at a specific Trigger instance instead of
assuming it runs on the same instance as the webapp. Adds an optional
`DASHBOARD_AGENT_BASE_URL`; when unset it falls back to the SDK default.

## Root cause

The agent's session start, token mint, head start, in-proxy and the
client transport all built the agent's base URL from the webapp's own
origin (`API_ORIGIN ?? APP_ORIGIN`). That only holds when the agent
project runs on the same instance as the webapp. When it runs elsewhere,
`DASHBOARD_AGENT_SECRET_KEY` belongs to that other instance, so the
webapp's own API rejects it with an "Invalid API key" and the chat can't
start.

## Fix

`dashboardAgentApiOrigin()` now returns `DASHBOARD_AGENT_BASE_URL` or
the SDK default, never the webapp origin. A concrete default (rather
than an unset value) keeps it independent of `TRIGGER_API_URL`, which a
webapp may point at a different host. Every server call site already
routes through that helper; the client transport reads the value from
the root loader via a new `useDashboardAgentBaseUrl` hook.
2026-08-20 14:14:14 +01:00
claude[bot] 19eae515fd fix: rename the Projects org settings URL to /settings/projects (#4739) 2026-08-20 13:08:28 +00:00
Chris Arderne 4392e79ce2 chore: adopt stable React Compiler lint rules (#4737) 2026-08-20 14:17:40 +02:00
github-actions[bot] ce40d0259f chore: release v4.5.12 (#4610) 2026-08-20 12:47:22 +01:00
Chris Arderne 06f99aeb31 fix: security release 2026-08-12 (#4735) 2026-08-20 12:34:33 +01:00
claude[bot] 518978bc52 fix(core): don't assume a 64-character idempotency key is pre-hashed on reset (#4626)
<!-- ccr-slack-attribution -->
_Requested by **Matt Aitken** · [Slack
thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1786741966214949?thread_ts=1786741966.214949&cid=C045W9WM3E1)_

`idempotencyKeys.reset()` now honours an explicitly passed `scope` even
when the key material happens to be 64 characters long.

**Before:** `resetIdempotencyKey` treated *any* 64-character string as
an already-computed hash and sent it to the API verbatim. That
short-circuit ran before the scope logic, so if your key material is
itself a 64-character digest (a common pattern when you hash your own
dedup identity) the `scope` you passed was silently discarded and the
un-hashed material went on the wire. The server stores the hash, so the
reset matched no run and returned 404 every single time. Key material of
any other length worked fine, which made this look arbitrary.

**After:** a 64-character key with an explicit `scope` is sent verbatim
first and, only when that attempt comes back a definitive not-found,
retried as the derived scope hash. Every call that worked before behaves
identically, and the previously impossible case now resolves on the
fallback.

## How

A 64-character string is forwarded unchanged, exactly as before, when:

- the idempotency key catalog recognises it (it came from
`idempotencyKeys.create()` in this process), or
- no `scope` was passed, so there is nothing to derive a hash from, or
- the scope hash cannot be derived (e.g. `scope: "run"` outside a task
context with no `parentRunId`).

Otherwise the key is ambiguous: it may be raw material the caller wants
hashed with the scope, or it may already be the stored hash. Reset sends
the verbatim value first because that is what every previous version
sent, so anything that resolved before still resolves with the same
single request, the same target run, and the same errors. The derived
hash is the new behaviour, so it only runs once the verbatim attempt has
failed with a 404, a definitive "no run under this key". Any other error
(a 503, a connection error) leaves the verbatim key's state unknown, and
resetting a different key on unknown state would be an untargeted write
the caller never asked for, so those errors surface unchanged. That has
an honest cost: when the endpoint answers 503 for a miss it cannot
confirm, the caller sees the 503 and retries rather than silently
falling through to the derived key. When both attempts miss, the
verbatim attempt's 404 is surfaced, again matching what previous
versions threw.

A side benefit of this order: a key from `idempotencyKeys.create()`
reset with a `scope` from a cold process resolves in a single request,
because the created key is itself the stored value.

`isIdempotencyKey` is deliberately left alone: it applies the same
length rule on the trigger path, but it is self-consistent there, and
changing it would invalidate already-stored keys.

The `attachedOptions?.key` / `attachedOptions?.scope` fallbacks below
the old guard were unreachable (every catalog entry is a 64-character
digest, so it always hit the short-circuit first) and re-deriving from
them produces the identical hash anyway. They are removed rather than
left as dead code.

---

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

Tests in `packages/core/src/v3/idempotencyKeys.test.ts` drive the real
`resetIdempotencyKey` against a local HTTP server and assert on the
exact values that reach the wire, in order. Nothing is mocked. They
cover:

- 64-character material + explicit `scope` derives the global- and
run-scoped hash once the verbatim key misses (fails without this change)
- the verbatim key wins when runs exist under both the verbatim value
and the derived hash, so the pre-existing target is preserved
- keys from `idempotencyKeys.create()` are forwarded unchanged: catalog
hit, no scope, and scope with a cold catalog (the last now a single
request)
- a transient failure of the verbatim attempt surfaces its error without
ever touching the derived key
- error surfacing: a double miss reports the key the caller passed, and
a non-404 from the fallback is not swallowed
- ordinary short material is still hashed, and underivable run/attempt
scopes still send a 64-character key verbatim while still throwing for
shorter material

```
pnpm run test ./src/v3/idempotencyKeys.test.ts --run   # 18 passed
pnpm run build --filter @trigger.dev/core              # clean
pnpm run format && pnpm run lint                       # clean
```

---

## Changelog

`idempotencyKeys.reset()` now works when your idempotency key is itself
64 characters long. Previously any 64-character key was assumed to be
already hashed, so passing one along with a `scope` silently ignored the
scope and the reset never found a matching run.

---

## Follow-ups (not in this PR)

- `docs/idempotency.mdx` describes the `idempotencyKey` parameter of
`reset()` as "the 64-character hash string" in one place while showing
raw material plus `{ scope: "global" }` a few lines later. Worth
reconciling.
- No surface currently exposes the stored hash that the reset endpoint
matches on: `ctx.run.idempotencyKey`, the run page and the
`idempotency_key` query column all show the user-provided key. That is
what leads people to send a value reset cannot match.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2026-08-20 12:14:51 +01:00
Chris Arderne c668b72c3f chore(webapp): enforce React Compiler lint (#4732)
## Summary

Enforces `react/react-compiler` as an error for the webapp now that all
reported compiler diagnostics are fixed or narrowly scoped. Removes the
unused lazy-ref helper made obsolete by the ref initialization cleanup.
2026-08-20 09:59:45 +01:00
Chris Arderne 2b2b047089 chore(webapp): scope imperative route refs (#4731)
## Summary

Scopes React Compiler diagnostics to route statements where refs
intentionally coordinate virtualized views, live reload state, transport
lifecycles, and deferred callbacks. Other compiler diagnostics remain
active in those routes.
2026-08-20 09:59:45 +01:00
Chris Arderne 4d040e13be chore(webapp): scope imperative component refs (#4730)
## Summary

Scopes React Compiler diagnostics to component and hook statements where
refs intentionally coordinate editors, animations, polling, deferred
callbacks, and other imperative integrations. Other compiler diagnostics
remain active in those components.
2026-08-20 09:59:45 +01:00
Chris Arderne a89ce5a709 refactor(webapp): replace render-time ref initialization (#4729)
## Summary

Replaces render-time ref initialization with lazy state for frozen form
defaults, the tooltip's virtual positioning element, and the side menu's
first-paint visuals. Editable alert fields now update immutable state
snapshots.
2026-08-20 09:59:44 +01:00
Chris Arderne 7ab437c8ad chore(webapp): scope route effect synchronization (#4728)
## Summary

Scopes React Compiler diagnostics for route effects that intentionally
synchronize loader data, navigation, submissions, polling, streams, and
transient UI state. Each suppression remains attached to the reported
synchronization call.
2026-08-20 09:59:44 +01:00
Chris Arderne 7673c46a02 chore(webapp): scope component effect synchronization (#4727)
## Summary

Scopes React Compiler diagnostics for component and hook effects that
intentionally synchronize with navigation, submissions, browser APIs,
streams, timers, or authoritative server values. Each suppression stays
on the reported synchronization call rather than disabling analysis for
the component.
2026-08-20 09:59:43 +01:00
Chris Arderne 101883c41c refactor(webapp): derive controlled UI state during render (#4726)
## Summary

Derives controlled tab, tag, and checkbox values directly during render
instead of copying them through effects. Modal drafts now reset from
their open event, and the route-backed alert dialog renders open
immediately without a mount-time state update.
2026-08-20 09:59:43 +01:00
Chris Arderne 00149675ac chore(webapp): scope intentional draft synchronization (#4725)
## Summary

Scopes state synchronization that intentionally resets editable drafts
from authoritative server values, deployment state, or programmatic
filter changes. These values cannot be derived during render without
removing user control between resets.
2026-08-20 09:59:42 +01:00
Chris Arderne f723e5a1b8 refactor(webapp): simplify manual memoization (#4722)
## Summary

Removes manual memoization where derived values are already rebuilt each
render, narrows the dashboard watch callback to a stable chat
identifier, and scopes two intentional memoization patterns that protect
local edits and serialized synchronization.
2026-08-20 09:59:42 +01:00
Chris Arderne cf96204c7f chore(webapp): scope memo dependency diagnostics (#4721)
## Summary

Makes stable dashboard history refs explicit memo inputs and scopes the
remaining compiler diagnostics to callbacks whose local handlers or
lifetime-stable values cannot be represented accurately in dependency
arrays.
2026-08-20 09:59:41 +01:00
Chris Arderne 7682a215db fix(webapp): stabilize time-sensitive UI renders (#4720)
## Summary

Captures chat-history age when the menu opens so rerenders cannot change
labels mid-view. The waitpoint deadline form also reuses one intentional
wall-clock snapshot for all calculations in a render.
2026-08-20 09:59:41 +01:00
Chris Arderne e394b5acf5 fix(webapp): timestamp live metric responses (#4719)
## Summary

Records when live metric responses arrive and uses that timestamp to
evaluate gauge freshness and waiting duration. Cached or failed
responses remain untrusted until revalidated, while rendered values stay
stable between polling updates.
2026-08-20 09:59:41 +01:00
Chris Arderne 34211e6649 fix(webapp): derive expiry status from loader time (#4718)
## Summary

Derives session and API key expiry states from a timestamp captured by
each route loader. Every status on a page now uses one consistent point
in time instead of changing according to when an individual component
rerenders.
2026-08-20 09:59:40 +01:00
Chris Arderne 11ea1f8ba9 fix(webapp): use stable chart bucket timestamps (#4717)
## Summary

Uses explicit bucket timestamps when rendering usage charts instead of
anchoring missing timestamps to the current render time. Tooltips now
remain stable across rerenders, and examples use a deterministic
timestamp.
2026-08-20 09:59:40 +01:00
Chris Arderne 7ea02716fc fix(webapp): avoid mutating render inputs (#4716)
## Summary

Keeps render inputs and shared regular expressions immutable. Grouped
selects now compute each section's shortcut offset directly from
preceding sections, which also makes numeric shortcuts follow the
displayed item order reliably.
2026-08-20 09:59:39 +01:00
Chris Arderne 176fb6daf4 fix(webapp): call hooks directly and unconditionally (#4715)
## Summary

Calls dashboard hooks directly instead of passing them as ordinary
callback values, and subscribes to optional Ariakit stores through an
unconditional hook. This keeps hook ordering stable while preserving the
existing behavior when a provider is absent.
2026-08-20 09:59:39 +01:00
Chris Arderne 6dfc54b75b chore(webapp): scope unsupported React Compiler diagnostics (#4713)
## Summary

Adds targeted lint suppressions for components built around libraries
that React Compiler intentionally declines to memoize, plus one
unsupported function-reference pattern. Each suppression is scoped to
the affected component so other compiler diagnostics remain actionable.
2026-08-20 09:59:39 +01:00
Chris Arderne 9dca03f682 chore: enforce exhaustive React hook dependencies (#4712)
## Summary

Enables exhaustive React Hook dependency checking and resolves the
existing violations across the dashboard and React hooks package.
Effects and callbacks now track current values without introducing
request, subscription, or render loops.

## Design

Dependencies are included directly when the hook lifecycle should follow
them. Timers, Remix fetchers, and realtime subscriptions use stable
callbacks or latest-value refs where restarting work would change
behavior.

Unnecessary memoization was removed where ordinary derivation is
clearer. Full lint and typechecks for the webapp and React hooks package
pass.
2026-08-20 09:59:38 +01:00
Oskar Otwinowski adaa8e9e30 fix(clickhouse): renumber the external deployment id migration to 041 (#4734)
## Summary

`goose up` against `internal-packages/clickhouse/schema` panics on
`main` today, so ClickHouse migrations cannot be applied from a fresh
checkout. Renumbering the external deployment id migration from 040 to
041 clears it.

## Root cause

Two migrations claim version 40.
[#4615](https://github.com/triggerdotdev/trigger.dev/pull/4615) added
`040_create_task_events_search_v2.sql`, and
[#4661](https://github.com/triggerdotdev/trigger.dev/pull/4661) added
`040_add_task_runs_v2_external_deployment_id.sql` a day later. #4661 was
opened before #4615 merged, so 040 was genuinely free at branch time,
and because the two files have different names there is no textual
conflict for git or a rebase to surface. Both merged green, and no
workflow in this repo runs `goose`, so the collision only shows up the
first time someone actually migrates.

goose parses the numeric filename prefix as the version and refuses
duplicates:

```
panic: goose: duplicate version 40 detected:
  .../040_create_task_events_search_v2.sql
  .../040_add_task_runs_v2_external_deployment_id.sql
```

It aborts while collecting the directory, before executing any SQL, so
nothing was half applied and there is no migration state to repair.

This migration gets renumbered rather than the `task_events_search_v2`
one because goose keys on the version number and not the filename:
version 40 is already recorded wherever 040 has been applied, so
renaming that file would re-run an applied migration.

Verified with a full `goose up` against ClickHouse 26.2.19.43 (the image
pinned in `internal-packages/testcontainers`): migrations apply cleanly
through version 41, and `task_runs_v2.external_deployment_id` lands as
`String DEFAULT ''`.
2026-08-20 08:46:37 +00:00
Chris Arderne 19908436b8 perf(ci): speed up webapp test execution (#4709)
## Summary

Speeds up webapp test jobs by balancing measured work across runners,
reducing repeated container setup, and ensuring test workers release
shutdown resources promptly. Unit tests run across 24 duration-aware
shards, while E2E tests run across two balanced shards.

## Design

`RunEngine` shutdown now closes processing resources before support
resources, continues cleanup if one close fails, and reuses one shutdown
promise for concurrent callers. Redis workers clear completed shutdown
deadlines so finished tests no longer wait on idle timers.

Container-heavy suites are split only where it improves parallelism, and
repeated replication and engine fixtures are consolidated where one
end-to-end case provides coverage. Timing weights are refreshed for all
affected files.

Dependency installation overlaps container pulls, and both workflows use
WarpBuild's Node setup action.
2026-08-20 07:08:22 +01:00
claude[bot] 447471843c fix(webapp): keep the branches list query string when archiving a branch (#4724)
<!-- ccr-slack-attribution -->
_Requested by **Iss** · [Slack
thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1787161814493949)_

**Before:** archiving a branch dropped the query string on the way back
to the branches list, so the list reset to page 1. Working down a long
list meant re-navigating to the page you were on after every archive.

**After:** you land back on the exact page you archived from, with
`page`, `search` and `showArchived` intact.

The archive action now redirects to the page the request came from
instead of rebuilding a bare branches path.

## How

The archive dialog already submits the page it was opened from as a
hidden `redirectPath` field (`${location.pathname}${location.search}`),
and the failure path already redirected to it — only the success path
ignored it and rebuilt the path with `branchesPath`/`branchesDevPath`,
which have no query string. Both paths now redirect to the submitted
path, run through the existing `sanitizeRedirectPath` helper to keep the
redirect same-origin (the same idiom used by
`resources.batches.$batchId.check-completion`).

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

Three files change:

- `apps/webapp/app/routes/resources.branches.archive.tsx` — the fix.
- `apps/webapp/test/archiveBranchRedirect.test.ts` — new test that
drives the archive action and asserts the redirect `Location`: the query
string survives on both success and failure, and an off-origin
`redirectPath` falls back to `/`. Reverting the fix makes two of the
three cases fail, so the test covers the regression.
- `.server-changes/archive-branch-keeps-list-page.md` — release-note
entry, since this is a user-facing server-only change.

Also ran `pnpm run typecheck` and `oxlint` for `apps/webapp` — both
clean.

---

## Changelog

Archiving a branch now returns you to the same page of the branches list
instead of resetting it to page 1.

---

## Screenshots

_None — no visual change._

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 16:45:47 -04:00
Eric Allam aa17c4d706 chore(ci): deploy the dashboard agent dormant, drop the reviewer gate, add a ref input (#4710)
## Problem

Every merge to main touching the agent queued a gated `staging`+`prod`
deploy that sat `pending` on a reviewer approval nobody grants
routinely. Because the gated runs never completed, they never drained
the concurrency queue and cancelled each other, so the Actions tab
filled with never-completing runs and the agent only ever actually
deployed via a manual dispatch + approval.

The reviewer gate bought nothing here: the agent deploys with
`--skip-promotion`, so a deploy lands **dormant** and nothing goes live
until the consuming webapp flips `DASHBOARD_AGENT_VERSION`. Promotion is
already a deliberate act (the env-var flip); gating the dormant deploy
on top of that just created the pile-up.

## Change

- **Remove the reviewer gate** by dropping the required-reviewers rule
on the `dashboard-agent-*` environments (repo-settings change, done).
The `environment:` key **stays** so the per-environment scoped deploy
token still resolves — no secret migration.
- **`workflow_dispatch` `ref` input** — deploy a specific commit SHA,
branch, or tag; defaults to the ref the run launches from. Checkout uses
`github.event.inputs.ref || github.sha`.
- **Require the ref to be an ancestor of `main`.** Constrains which
commit gets deployed to merged code only. A push is always main's tip
(passes trivially); a dispatched unmerged ref is rejected before the
deploy step. Because an explicit `ref:` checkout doesn't create
remote-tracking branches, `origin/main` is fetched explicitly before
`git merge-base --is-ancestor`.
- **`cancel-in-progress: false`** (kept). Cancelling the runner wouldn't
stop the remote build (it finishes server-side), and a superseding
concurrent deploy would race the same project's indexer. With the gate
gone, deploys are short, so a brief queue can't pile up.
- `max-parallel: 1` stays (parallel deploys of the same project race at
the indexer).

## Owner actions (repo settings — not in the diff)

1. **Remove required-reviewers** on `dashboard-agent-staging` and
`dashboard-agent-prod` — done.
2. **Add a deployment branch policy** on both environments restricting
deployments to `main`. This is the authoritative token guard:
`workflow_dispatch` runs the workflow file from the selected ref, so the
in-file ancestor check alone can't protect `TRIGGER_ACCESS_TOKEN` (a
branch could edit the check out). GitHub enforces the branch policy
server-side against `GITHUB_REF` regardless of file contents. With it in
place, the workflow only runs (and the token is only exposed) when
dispatched from `main`, and the in-file check then constrains the
independent `ref` input to merged commits.

## Pile-up root cause

The stacking was caused by the **reviewer gate** (runs waited forever,
so the queue never drained), not by `cancel-in-progress`. Removing the
gate is what fixes it; `cancel-in-progress` stays `false`.
2026-08-19 17:17:12 +01:00
Oskar Otwinowski 967dedcebc fix(run-engine): correct park deadline and snapshot state for debounced parked runs (#4708)
Two defects that surface when a run parked on an external deployment id
gets pushed by a debounce key. Both were reproduced against a local
instance before being fixed.

## 1. The run is expired before it is due

```
now      | status  | statusReason                  | delayUntil | expiredAt
13:57:06 | EXPIRED | EXTERNAL_DEPLOYMENT_NOT_FOUND | 14:01:37   | 13:57:02
```

Killed 4m35s before its own scheduled start, blaming a missing
deployment.

**Why.** The park deadline is armed **once**, when the run is first
parked, from `max(now, delayUntil) + deadline`. Debounce pushes
`delayUntil` out afterwards and nothing re-arms it:

- `rescheduleDelayedRun` reschedules `enqueueDelayedRun:<id>`, not
`expireParkedExternalDeploymentRun:<id>`
- the redis-worker reschedule is an update-only `ZADD … XX`, and a
parked run has no `enqueueDelayedRun` job, so that call is a silent
no-op

Repeat triggers on one key walk `delayUntil` away from a deadline that
no longer moves. Once it crosses, the run dies while parked and not yet
due.

**Fix.** The expiry job already loads `delayUntil`, so it re-arms from
the current value and returns instead of expiring a run that is not due.

The guard lives in the expiry job rather than the debounce path
deliberately: it covers **every** caller that moves `delayUntil`, so a
future call site can't reintroduce this by forgetting to re-arm. It
stays bounded by the debounce max-duration contract, so a hot key can't
postpone expiry indefinitely.

## 2. The run reports itself as delayed while it is parked

```
RUN_CREATED | PENDING_VERSION | Run is waiting for a deployment of 'debounce-test-2'
DELAYED     | DELAYED         | Delayed run was rescheduled to a future date   ← after one debounce push
```

The row stays `PENDING_VERSION`; the latest snapshot claims `DELAYED`,
so the run page describes a parked run as delayed. Happens on the
*first* push.

**Fix.** `rescheduleRun` hardcoded `DELAYED`/`DELAYED`. The snapshot
statuses are now supplied by the caller and **default to `DELAYED`**, so
the ordinary delayed path is byte-identical, and `rescheduleDelayedRun`
passes the parked statuses through when the run is parked.

## Reproducing

Repeated triggers on one debounce key against an id that hasn't landed:

```bash
curl … -d '{"options":{"externalDeploymentId":"x","debounce":{"key":"k","delay":"5m"}}}'
```

Three triggers correctly fold into one parked run; the defects show up
on the pushes.

## Testing

Two tests, each verified red before green and failing alone:

- a run whose delay was pushed past the deadline stays `PENDING_VERSION`
instead of expiring
- a debounce push on a parked run leaves a
`RUN_CREATED`/`PENDING_VERSION` snapshot, not `DELAYED`

`56 passed` across parking, pendingVersion, delayedRunSystem and
debounce; `43 passed` in `PostgresRunStore`. Typecheck, lint, format
clean.

## Notes

- Stacks on #4665, so it lands after the whole external-deployment-id
series.
- No changeset: this fixes unreleased behaviour introduced by the stack
below it, so no user has seen it.
- Both found by Devin's review on #4664, and both confirmed end to end
on a local instance before fixing.
2026-08-19 17:43:54 +02:00
Oskar Otwinowski cde8919861 feat(webapp): show the external deployment id on deployments and runs (#4665)
Deployments page: an always-visible External ID column after Deployed
by, and an External ID row in the deployment inspector under Worker
type, both showing an en dash when a deploy carried no id. The Vercel
Linked column now renders before Git, still only when a Vercel
integration is connected. Also corrects the blank-row colSpan, which was
already off by one before this column existed.

Run inspector: an External deployment ID row between Version and SDK
version, read from the run annotations, so an operator can see which id
a run was pinned to - including a run that expired before its deployment
ever arrived, where the locked version is empty but the id is the whole
story. Buffered runs read the id from the same annotations rather than
reporting none.

Long ids are head-truncated with the full value behind the copy button:
a commit SHA is meaningful in its prefix, and the inspector panel can be
narrowed to 250px, where an unbroken 40-character SHA would otherwise
scroll the properties list sideways and push the copy button off-panel
(TRI-12923, TRI-13000).
2026-08-19 17:43:54 +02:00
Oskar Otwinowski 8b0385c429 feat(run-engine): trigger tasks pinned to an external deployment id (#4664)
The SDK discovers an external deployment id at runtime (explicit
TRIGGER_EXTERNAL_DEPLOYMENT_ID always; platform commit-SHA variables and
generic fallbacks when TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1) and
sends it alongside lockToVersion; the server resolves precedence
(version > external id > current). An id held by a deployed deployment
pins the run to that worker; an in-flight or unknown id parks the run in
PENDING_VERSION with the id in TaskRun.annotations, wakes it pinned when
a deployment carrying the id finalizes (ClickHouse candidates, Postgres
authoritative), and expires it after a deadline that re-checks Postgres
before acting. Parking outranks delaying and preserves delayUntil. The
id is projected to ClickHouse task_runs_v2.external_deployment_id during
replication. Redis cache for id-to-worker resolution, guarded
version-aware writes.

Ids are not unique. Several deployments can hold one id - a --force
rebuild is the ordinary way to get there - so resolution always picks
the highest version among the candidates, never the newest by timestamp.
The rule is applied identically on both paths that can bind a run to a
worker: resolveExternalDeployment at trigger time, and
PendingVersionSystem when a landing deployment wakes a parked run.
Version comparison is numeric on the counter half, so 20260807.10
outranks 20260807.9.

A run whose id never lands expires at the deadline with
EXTERNAL_DEPLOYMENT_NOT_FOUND and an error naming the id it waited for,
which is what a failed build or a typo looks like from the caller.
Default deadline is one hour (EXTERNAL_DEPLOYMENT_PARK_DEADLINE_MS).

Debounce registration happens in both the parked and the delayed branch
through one helper, so a debounced run that parks still binds its
debounce key; without it every later trigger for the same key created
another parked run, and all of them executed when the deployment landed.
The two DELAYED-only status checks in DebounceSystem also accept
PENDING_VERSION, without which the lock-contention fallback would
rethrow a 5xx the SDK retries and amplifies, and the fast path would
push every trigger on a parked key through the redlock.

Resolution is skipped in development. A dev environment cannot hold a
WorkerDeployment - trigger dev registers a BackgroundWorker with nothing
behind it, and deploy --env refuses dev - so an external deployment id
there could only ever park, and the parked run then expired against the
dev TTL while a connected dev worker sat idle. The id is still annotated
so the dashboard shows what the app sent (TRI-13000).
2026-08-19 17:43:53 +02:00
Oskar Otwinowski 6bfce6387d feat(deploy): --external-id and --force for deploy idempotency (#4663)
A deploy can carry an opaque external id (commit SHA, CI run id, release
tag). Repeating an id that already deployed returns the existing version
as a no-op instead of rebuilding; an id with a build in flight is
rejected with 409 naming that version; a failed id rebuilds freely.
--force is non-destructive to deployments that already succeeded - both
persist and the higher version wins - but cancels a build still in
flight, so one id never has two live builds racing to define it.
Cancelling writes a terminal status and appends a finalized event, which
aborts a build the platform drives; a build it does not drive keeps
running but can never land, and the CLI says so. Ids are deliberately
not unique - reuse is resolved in application code by highest version,
never timestamps. The no-op path mints no build credentials and no event
stream (TRI-12923).

What that means for callers: a --force rebuild leaves two deployments
holding one id, and runs triggered with it go to the higher version once
the rebuild lands, so the takeover needs no separate promotion. Until a
successful build exists for an id, runs triggered with it park and then
expire rather than falling back to current - a failed build is therefore
visible to the caller as expired runs, not as runs on the wrong release.
2026-08-19 17:43:51 +02:00
Oskar Otwinowski 689538d327 feat(core): external deployment id wire contract (#4662)
An external deployment id is an opaque, caller-chosen name for a release
- a commit SHA, a CI run id, a release tag. This adds the shared
contract that both halves of the feature read, and nothing else: no
deploy writes one yet and no trigger sends one.

ExternalDeploymentId is defined once and reused by
InitializeDeploymentRequestBody.externalId and
TriggerTaskRequestBody.options.externalDeploymentId, so a value accepted
by one half can never be rejected by the other. A value that is blank
once trimmed is treated as absent rather than rejected, so an unset CI
variable expanding to an empty string is not a 400. The 128 character
limit fits a SHA-256 commit hash with room for composite ids, and
EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH is the single source of truth that the
request schemas and the CLI both read.

RunAnnotations.externalDeploymentId records the request, not the
outcome: lockedToVersionId and taskVersion are overwritten when a run
locks, whereas this stays true forever, and it can carry the pin for a
run parked before its deployment exists.

Also lands the runtime discovery helpers as pure functions over an
environment reader: the explicit TRIGGER_EXTERNAL_DEPLOYMENT_ID
variable, the platform and CI commit-SHA table, and the
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION gate. Nothing calls them yet.

refs TRI-13000
2026-08-19 17:43:51 +02:00
Oskar Otwinowski 8fded28fcd feat(schema): add WorkerDeployment.externalId and task_runs_v2.external_deployment_id (#4661)
Migrations only, no code reads them yet. Postgres: nullable non-unique
externalId on WorkerDeployment plus a CONCURRENTLY-built (environmentId,
externalId) index in its own migration file. ClickHouse:
external_deployment_id String DEFAULT '' on task_runs_v2 (plain String,
not LowCardinality - commit SHAs are high-cardinality). Part of task run
version skew protection (TRI-12998).
2026-08-19 17:43:50 +02:00
Chris Arderne 23c5619dd1 fix(webapp): enforce keyboard interaction safeguards (#4702)
## Summary

Enable keyboard-event and static-element interaction safeguards across
the dashboard.

Earlier stack changes move actionable behavior to native controls. This
final enforcement keeps narrowly documented exceptions for focus
forwarding, scoped Escape handling, CodeMirror focus, and pointer-driven
table column resizing.

`jsx-a11y/no-autofocus` remains disabled.

Base: [#4701](https://github.com/triggerdotdev/trigger.dev/pull/4701)
2026-08-19 16:35:47 +01:00
Chris Arderne 5e50d2f80d fix(webapp): align tree mouse and keyboard interactions (#4701)
## Summary

Move tree selection onto semantic tree items and use native expansion
buttons.

Dashboard and story tree rows now share mouse and keyboard selection
through `getNodeProps`. Expand and collapse affordances are named
buttons instead of clickable layout elements.

Base: [#4700](https://github.com/triggerdotdev/trigger.dev/pull/4700)
2026-08-19 16:35:47 +01:00
Chris Arderne 5ae24710e4 fix(webapp): use native selectable row controls (#4700)
## Summary

Use native controls for sortable columns and selectable prompt versions.

Table headers keep filter actions separate from sort buttons, prompt
version rows expose pressed state, and a redundant deployment click
interceptor is removed.

Base: [#4699](https://github.com/triggerdotdev/trigger.dev/pull/4699)
2026-08-19 16:35:46 +01:00
Chris Arderne 3d650248fb fix(webapp): use native time filter mode controls (#4699)
## Summary

Make time-filter mode selection keyboard accessible.

Duration and exact-range modes now use native pressed buttons. Nested
date, duration, and quick-select controls no longer depend on click
propagation blockers.

Base: [#4698](https://github.com/triggerdotdev/trigger.dev/pull/4698)
2026-08-19 16:35:46 +01:00
Chris Arderne 73c8a4d975 fix(webapp): use native controls for inline actions (#4698)
## Summary

Replace mouse-only dashboard actions with native buttons.

Copy, remove, and stop-generation controls now expose keyboard focus and
accessible names. Hover-revealed actions remain mounted so keyboard
users can discover them, and a decorative clipboard icon no longer
captures clicks.

Base: [#4697](https://github.com/triggerdotdev/trigger.dev/pull/4697)
2026-08-19 16:35:45 +01:00
Chris Arderne 3d156dfd75 fix(webapp): use native checkbox label semantics (#4697)
## Summary

Use native label and checkbox behavior for `CheckboxWithLabel` and
enforce `jsx-a11y/no-noninteractive-element-interactions`.

The component no longer simulates checkbox activation with click
handlers on non-interactive wrappers. Native change events now drive the
controlled checked state.

Base: [#4696](https://github.com/triggerdotdev/trigger.dev/pull/4696)
2026-08-19 16:35:45 +01:00
Chris Arderne 646141199e fix(webapp): enforce accessible control names (#4696)
## Summary

Require accessible names for dashboard controls.

Filter menu action items and chart color controls now expose explicit
names. The chart legend action uses a native button, while lint depth
and spacer-cell configuration match the rendered control structure.

Base: [#4695](https://github.com/triggerdotdev/trigger.dev/pull/4695)
2026-08-19 16:35:44 +01:00
Chris Arderne 3a091eb764 fix(webapp): enforce associated form labels (#4695)
## Summary

Finish associating dashboard form labels with their controls and enforce
`jsx-a11y/label-has-associated-control`.

Repeated data store dialogs use unique generated IDs, story controls and
notification filters have explicit associations, and display-only status
text no longer uses label elements.

Base: [#4694](https://github.com/triggerdotdev/trigger.dev/pull/4694)
2026-08-19 16:35:44 +01:00
Chris Arderne 3ffd123d27 fix(webapp): associate model administration labels (#4694)
## Summary

Associate internal model administration labels with their form controls.

The model editor, creator, and tester now use explicit `htmlFor` and
`id` pairs. Section titles that do not label controls now use headings
instead of label elements.

Base: [#4693](https://github.com/triggerdotdev/trigger.dev/pull/4693)
2026-08-19 16:35:43 +01:00
Chris Arderne 4592fdf4d6 fix(webapp): enforce accessible image and role semantics (#4693)
## Summary

Enable foundational JSX accessibility checks for image text alternatives
and valid ARIA roles.

The avatar color picker now has an explicit accessible name and
decorative image alternative. Dashboard chat styling props no longer
reuse the reserved DOM `role` name.

Base: [#4692](https://github.com/triggerdotdev/trigger.dev/pull/4692)
2026-08-19 16:35:43 +01:00
Chris Arderne dda9504bdd fix(webapp): require explicit native button types (#4692)
## Summary

Add explicit types to native dashboard buttons and enforce
`react/button-has-type`.

This prevents action buttons from accidentally submitting a surrounding
form. Shared button primitives retain their caller-selected submit and
reset semantics with documented lint exceptions.

Base: [#4691](https://github.com/triggerdotdev/trigger.dev/pull/4691)
2026-08-19 16:35:42 +01:00
Chris Arderne a7a1e74fcb refactor(webapp): remove redundant React fragments (#4691)
## Summary

Remove redundant React fragments from the dashboard and enforce
`react/jsx-no-useless-fragment`.

The cleanup returns existing nodes, arrays, and empty states directly
without adding wrapper elements.

Base: [#4689](https://github.com/triggerdotdev/trigger.dev/pull/4689)
2026-08-19 16:35:42 +01:00
Chris Arderne a2cc315f40 perf(webapp): stabilize nested component identities (#4689)
## Summary

Keep component and renderer identities stable across dashboard renders.

Inline icon components, chart renderers, table cells, and select render
callbacks now use module-level implementations. Oxlint enforces the
pattern across the dashboard.

Base: [#4688](https://github.com/triggerdotdev/trigger.dev/pull/4688)
2026-08-19 16:35:41 +01:00
Chris Arderne 108f43ee9b fix(webapp,react-hooks): enforce stable hook ordering (#4688)
## Summary

Enforce stable React hook ordering in the dashboard and React hooks
package.

Conditional hook calls now keep a consistent order, and overloaded
realtime stream arguments are resolved before entering the shared hook
implementation.

Base: `main`
2026-08-19 16:35:41 +01:00
nicktrn a302f650b9 chore(deps): upgrade grpc-js to 1.12.7 (#4707)
`@grpc/grpc-js` sat at 1.12.6 in the lockfile. `dockerode` is the only
consumer and already declares `^1.11.1`, so a scoped override is enough:

```json
"@grpc/grpc-js@>=1.12.0 <1.12.7": "1.12.7"
```

Pinned exactly to stay on the 1.12 line; a caret would pull 1.14.x.
2026-08-19 13:57:36 +00:00
Eric Allam 32e647e020 perf(webapp): resolve schedule list run times per expression, not per row (#4703)
## Summary

Listing schedules could block the event loop for seconds. A page of 100
timezone-aware schedules spent over two seconds on cron arithmetic
alone, after the database work was already done, which stalls every
other request on that process. The same page now resolves in tens of
milliseconds.

## Root cause and fix

`cron-parser` walks the calendar unit by unit, and under a named
timezone every step goes through luxon. Parsing an expression is cheap
(single-digit microseconds); *stepping* it is not, ranging from a couple
of hundred microseconds for a common expression to several milliseconds
for a sparse one like `0 0 29 2 *`. The presenter did three independent
walks per row, one backwards for "last run" and two forwards (re-parsing
each time) for the next run and the occurrence after it. At 100 rows
that is 300 calendar walks in one uninterrupted tick.

Run times now resolve for the whole page in one pass, in a new
`resolveScheduleTimings` that takes plain values rather than Prisma rows
so it can be tested and benchmarked on its own.

- **Nominal times are cached per `(cron, timezone)`** against a single
`now` pinned for the batch, so cost scales with the number of distinct
expressions instead of the number of rows. Rows in one response also
stop disagreeing about the current time.
- **The backwards walk is opt-in.** It is the most expensive of the
three and only the dashboard renders the column; the public API never
returned it at all.
- **Windowless schedules take one step instead of two.** The second step
only measures the interval to the following occurrence, and that
interval reaches the result solely through `min(intervalMs,
max(MINIMUM_SCHEDULE_RANGE_MS, windowMs))`. With no window `windowMs` is
0, and `CronPattern` rejects expressions with a seconds field, so
occurrences are always at least `MINIMUM_SCHEDULE_RANGE_MS` apart and
that `min` can never bind. It is also the costlier step, since it walks
a whole period rather than the remainder of the current one.
- **`nextScheduledTimestamps` steps one parsed expression** instead of
re-parsing per step, which also helps the single-schedule callers.

Behaviour is unchanged, error semantics included: a malformed expression
still throws for the next run and still degrades to an undefined last
run.

## Verification

Measured inside a real request against a live environment, 100
schedules: sparse expressions went from 2250-2652 ms to 23-30 ms, and
five distinct timezone expressions from 463-500 ms to 9.7-10.6 ms.

The new suite checks the optimized code against an inline copy of the
previous implementation across eleven cron and timezone combinations
plus five DST transitions, so the rewrite is verified as
behaviour-preserving rather than just faster. Separate tests pin the
invariant the single-step path depends on, so if sub-minute crons are
ever allowed they fail loudly instead of the timings quietly going
wrong.

Worth knowing for later: `cron-parser` v5 is a much faster rewrite on
exactly this workload (`prev()` under a timezone drops from roughly 2700
to 60 microseconds), but it is a breaking API change across several call
sites including the schedule engine, so it belongs on its own. The
differential test added here is the tool to de-risk it.
2026-08-19 14:01:15 +01:00
Chris Arderne 338326c0d0 fix(clickhouse): lowercase logs search index terms (#4705) 2026-08-19 13:59:21 +01:00
Chris Arderne 4dabfca1d5 feat(webapp,cli,core): list production project runtime updates (#4659) 2026-08-19 13:44:55 +01:00
Chris Arderne 49aff3cb39 fix(clickhouse): use compatible logs text index syntax (#4704)
## Summary

Allow the logs search schema migration to run on ClickHouse versions
that require text index options to be literals.

## Root cause

The text index declared `lowerUTF8(search_text)` as a preprocessor
option. Some ClickHouse versions reject that column expression while
parsing index settings. The projected `search_text` is already
normalized to lowercase before insertion, so removing the redundant
preprocessor preserves search behavior.

Verified with the task events search integration tests.
2026-08-19 11:42:48 +00:00
nicktrn b93904526c test(testcontainers): hoist container boot off the test timer (#4686)
## What

The one-off worker container boot is billed to whichever test resolves
the fixture first. This moves it into a `beforeAll` with its own
timeout.

## Why

vitest runs the fixture chain *inside* the test timer:

```js
// @vitest/runner 4.1.7
setFn(task, withTimeout(...withFixtures(handler)..., timeout, ...))
```

There is no `fixtureTimeout`. So booting Postgres (plus `CREATE
DATABASE`, schema push, ClickHouse and Redis) lands on the first test
and consumes a budget sized for test work.

That is why losing the image pre-pull on fork PRs was fatal rather than
merely slower: the extra ~10s crossed the 60s cap. Since fork time is
roughly internal + 10s and forks exceed 60s, internal runs were already
clearing that cap by under 10s — a latent flake regardless of forks.

## How

`withWarmup` wraps each fixture family and lazily registers a
`beforeAll` on first touch, with its own generous timeout. Registration
is lazy so only files that actually use a family pay for it —
`@internal/testcontainers` is imported by hundreds of test files, many
of which only need Redis. It registers once per file, since `isolate`
gives each file a fresh module registry.

Eight families are wrapped. `isolatedRedisTest`,
`replicationContainerTest` and `postgresAndRedisTest` are deliberately
untouched: they use per-test containers by design, so there is no
one-off boot to hoist.

No test file or CI changes, and it applies to every package using these
fixtures.

## Verification

Proven by mutation. `src/warmup.test.ts` runs container tests under a
deliberately tight cap:

| | Result |
| --- | --- |
| with the warm-up | passes |
| warm-up neutered | fails, `Test timed out` |

It is kept as a regression test — without it, unwrapping a fixture would
break nothing visibly.

`triggerFailedTask.call.test.ts`, one of the five shard casualties,
passes locally in 20.4s.

## Also here

`@internal/testcontainers` had no `test` script, so `turbo run test
--filter "@internal/*"` skipped the package and its existing
`heteroDedicated.test.ts` never ran in CI. Adding the script (matching
the sibling packages') runs both files; verified green through turbo
exactly as CI invokes it.
2026-08-19 08:40:28 +01:00
nicktrn 7529c33a5e ci: correct testcontainer pre-pull image lists (#4685)
## What

Three corrections to the pre-pull lists, each verified against what the
suites actually use.

## Changes

**`ryuk:0.11.0` -> `0.14.0`** in `e2e-webapp.yml` and
`e2e-webapp-auth-full.yml`. The installed testcontainers hardcodes the
image it starts:

```js
// testcontainers@11.14.0 build/reaper/reaper.js
: ImageName.fromString("testcontainers/ryuk:0.14.0").string;
```

So those two lines were pre-pulling an image nothing starts, and the one
actually used was never pre-pulled. The other three workflows already
say 0.14.0.

**`postgres:17` added** to `unit-tests-webapp.yml`. The webapp suite
references `docker.io/postgres:17` across 10 files but only
`postgres:14` was pre-pulled. `unit-tests-internal.yml` already pulls
both.

**Electric pinned to its digest** in `unit-tests-webapp.yml`. The tests
run `electricsql/electric:1.2.4@sha256:20da...` while the pre-pull asked
for the bare tag, so the pre-pull did not necessarily populate the
manifest the tests then request.

## Not changed

The otel collector and s2 images are pulled by other workflows but are
not used by the webapp suite, so they are deliberately not added here.
`postgresAndRedisTest` uses per-test containers by design and needs
nothing pre-pulled.
2026-08-19 08:40:28 +01:00
nicktrn 9de90f7bed ci: pre-pull testcontainer images on fork PRs (#4684)
## What

The `Pre-pull testcontainer images` step is gated on
`env.DOCKERHUB_USERNAME`. Fork PRs receive no repository secrets, so
that variable is empty and the step is skipped along with the DockerHub
login it was grouped with.

## Why

With the pre-pull skipped, testcontainers pulls images lazily — inside
the first test that resolves the fixture, against that test's
`testTimeout`. On PR #4534 that pushed five webapp shards past their 60s
cap across three runs, each failing as `Test timed out in 60000ms` while
42 of 43 files in the shard passed.

Measured cost of the missing pre-pull, comparing the delta from vitest
start to the first container fixture on the same runner class:

| Run | Delta |
| --- | --- |
| internal x2 | +139.9s, +139.4s |
| fork x2 | +149.7s, +149.4s |

A 10.0s penalty, bimodal to within 0.3s.

Note the pulls themselves succeed anonymously — there are no rate-limit
errors in any of the failing logs. Only the login needs credentials, so
the pre-pull can run unconditionally.

## Scope

Removes the `if:` from the pre-pull step in all five workflows that have
one. The DockerHub login stays gated, since it genuinely needs secrets.
2026-08-19 08:40:27 +01:00
Chris Arderne 97461c08af refactor(webapp): remove redundant React fragments (#4683)
## Summary

Remove redundant React fragments from dashboard components, leaving
their rendered output unchanged while simplifying component trees.

Base: [#4682](https://github.com/triggerdotdev/trigger.dev/pull/4682)
2026-08-19 08:29:01 +01:00
Chris Arderne 219bc09d5f perf(webapp): stabilize chart loading line renderer (#4682)
## Summary

Keep the chart loading line renderer stable across parent renders so its
animated SVG paths retain their component identity.

Base: [#4681](https://github.com/triggerdotdev/trigger.dev/pull/4681)
2026-08-19 08:29:01 +01:00
Chris Arderne 1aeb356b9e fix(webapp): preserve React hook order (#4681)
## Summary

Call dashboard hooks unconditionally so components keep a stable hook
order when their props change.

Base: [#4680](https://github.com/triggerdotdev/trigger.dev/pull/4680)
2026-08-19 08:29:00 +01:00
Chris Arderne c3016eb9e4 chore: enable accessibility lint safeguards (#4680)
## Summary

Enable accessibility rules that catch invalid ARIA usage, inaccessible
media, and invalid focus behavior before they reach users.

Base: [#4679](https://github.com/triggerdotdev/trigger.dev/pull/4679)
2026-08-19 08:29:00 +01:00
Chris Arderne 7fca39c91d chore: enable React correctness safeguards (#4679)
## Summary

Enable React correctness rules that catch invalid DOM attributes, unsafe
legacy APIs, and malformed component contracts before they reach users.

Base: [#4678](https://github.com/triggerdotdev/trigger.dev/pull/4678)
2026-08-19 08:28:59 +01:00
Chris Arderne e0d96c3991 perf(webapp): memoize shared context values (#4678)
## Summary

Memoize shared context values so provider renders do not unnecessarily
rerender every consumer. Oxlint now enforces this pattern for the rest
of the dashboard.

Base: [#4677](https://github.com/triggerdotdev/trigger.dev/pull/4677)
2026-08-19 08:28:59 +01:00
Chris Arderne f4320937c5 chore: prefer direct iteration and function callback types (#4677)
## Summary

Enable lint rules that prefer direct iteration and concise function
callback types.

The existing code now uses direct iteration where no index is needed,
and callback contracts use function types consistently.

Base: [#4675](https://github.com/triggerdotdev/trigger.dev/pull/4675)
2026-08-19 08:28:58 +01:00
Chris Arderne 8572e8edbf chore: reject redundant standalone blocks (#4675)
## Summary

Enable the rule that rejects unnecessary standalone blocks.

The existing empty branches are removed so future control flow remains
purposeful.

Base: [#4674](https://github.com/triggerdotdev/trigger.dev/pull/4674)
2026-08-19 08:28:58 +01:00
Chris Arderne b2afff252c chore: enable JSX cleanup rules (#4674)
## Summary

Enable JSX cleanup rules for shorthand fragments and self-closing
components.

The existing JSX is automatically simplified, and future components will
follow the same concise form.

Base: [#4673](https://github.com/triggerdotdev/trigger.dev/pull/4673)
2026-08-19 08:28:57 +01:00
Chris Arderne 0f725cf2ba chore: enable lint cleanup rules (#4673)
## Summary

Enable small cleanup rules for redundant boolean expressions, object
ownership checks, assignments, and object construction.

The existing call sites now use the simpler equivalent forms, keeping
future code consistent without changing behavior.

Base: [#4672](https://github.com/triggerdotdev/trigger.dev/pull/4672)
2026-08-19 08:28:57 +01:00
Chris Arderne fe1d5f6961 chore: enable additional correctness lint rules (#4672)
## Summary

Enable additional lint rules that catch unsafe optional-chain
assertions, inherited-property iteration, anonymous symbols, and unsafe
external links.

The existing violations now use explicit values and own-property checks,
so the rules can prevent those patterns from returning.
2026-08-19 08:28:56 +01:00
nicktrn cffaa05517 feat(supervisor): optional priority class for run pods (#4671)
Adds an optional priority class for run pods.

```
KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME
```

When set, the value is applied as `priorityClassName` on the run pod
spec. When unset, pods are created exactly as before.

Off by default, and inert unless set. It sits beside the existing
`KUBERNETES_SCHEDULER_NAME` option and follows the same conditional
shape:

```ts
...(env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME
  ? { priorityClassName: env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME }
  : {}),
```

## Verification

`typecheck --filter supervisor`, `format` and `lint` clean. No changeset
or `.server-changes/` note: off by default, no user-visible behaviour
change.
2026-08-18 19:34:40 +01:00
Chris Arderne 12ec4667cb feat(webapp): enable development branches for all organizations (#4670) 2026-08-18 19:30:21 +01:00
Marcus Nerløe b83cf671de fix(core): mint the fallback external trace id per run (#4534)
## What

Runs that carry no external trace context (schedules, task-to-task
triggers) fall back to a trace id generated once in the [`TracingSDK`
constructor](https://github.com/triggerdotdev/trigger.dev/blob/main/packages/core/src/v3/otel/tracingSDK.ts#L165).
With `experimental_processKeepAlive` the SDK outlives the run, so every
run on a warm process is exported to the external OTLP endpoint under
that one id.

Across our production traces, 80.3% contained spans from more than one
run, worst case 25. Per-run cost and latency attribution is unusable as
a result. This is the same warm-start hazard c043c4a6a fixed for the
external-context path, which left the fallback captured at construction.

## How

`FallbackExternalTraceIds` hands out one id per internal trace, shared
by the span and log wrappers so a run's spans and logs agree.

The id is keyed off the record's own internal trace id rather than
ambient state at export time, because batch processors drain
asynchronously and a run's records routinely export after the next run
has started. The map is bounded and evicts least-recently-used, so a run
that is still exporting can't lose its id.

Granularity follows the internal trace, so a run and the runs it
triggers stay on one trace.

**Risk:** the wrappers only exist when `exporters` / `logExporters` are
configured, so deployments that don't export externally are untouched.
Nothing outside `tracingSDK.ts` changes.

**Known gap (pre-existing):** sampling and id selection still branch on
ambient `getExternalTraceContext()`, so records draining across a run
boundary in mixed mode are misplaced in both directions. It can't use
the approach here — the external id comes from the run's incoming
`traceparent`, which isn't carried on the record — so closing it means
capturing `internalTraceId -> external context` in a span processor.
Happy to follow up separately.

---

## Testing

`packages/core` suite passes. `pnpm run format` and `pnpm run lint:fix`
produce no diff.

Six cases in `externalSpanExporterWrapper.test.ts`, each
mutation-checked rather than just observed passing: one id per run,
stability within a run, correct id when records drain after the next run
started (spans and logs together), external export stays off when
unconfigured, retention of a run still exporting while the map churns,
and the bound itself.

**CI:** the five failing `webapp` shards are the ones containing
`containerTest` suites. Fork PRs receive no repository secrets, so
`unit-tests-webapp.yml` skips the DockerHub login and the image pre-pull
(both gated on `env.DOCKERHUB_USERNAME`) and the container tests time
out at 60s. Same five shards across five runs, every failure a 60s
timeout, and those shards pass on internal PRs. Happy to be corrected if
you can run them with secrets available.

---

## Changelog

Unrelated runs are no longer merged into a single trace in your external
observability tool when they happen to execute on the same warm worker
process. A run and the runs it triggers still share one trace, so a run
tree stays together.

---

## Screenshots

_n/a_

---

_Supersedes #4526 (auto-closed before I was vouched) and #4533 (opened
ready rather than as a draft). GitHub won't reopen either._

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Iss <74388823+isshaddad@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2026-08-18 18:59:00 +01:00
claude[bot] d7056a9c67 chore(webapp): reword the no-billing-limit banner copy (#4656)
<!-- ccr-slack-attribution -->
_Requested by **Matt Aitken** · [Slack
thread](https://triggerdotdev.slack.com/archives/C0BKB98B84W/p1787045331358929)_

Copy-only reword of the banner shown to org admins who have not set a
billing limit yet.

**Before** — the banner read "Protect your organization from unexpected
usage spikes." with a button labelled "Configure billing limit".

**After** — it reads "Add a billing limit to your account to prevent
overspending" with a button labelled "Billing limit settings".

The new wording names the action up front and matches the destination it
sends you to, so the banner reads as a settings link rather than a
one-off setup step.

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

Formatting and linting pass (`oxfmt --check`, `oxlint`). No tests or
snapshots assert this copy. The change is two string literals in one
component, with no behaviour attached.

---

## Changelog

Reworded the billing-limit banner for organizations without a limit
configured, and relabelled its button to "Billing limit settings".

---

## How

Both strings live in `NoLimitConfiguredBanner` in
`apps/webapp/app/components/billing/OrgBanner.tsx`: the heading is the
`canManageBillingLimits` branch of the banner's children, and the label
is the `<span>` inside the `LinkButton`. Only those two literals
changed. The button still points at `v3BillingLimitsPath(organization)`
(`/orgs/{slug}/settings/billing-limits`), so routing, permissions and
the non-admin variant of the message are untouched.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 18:50:32 +01:00
nicktrn e91fb746f7 feat(supervisor): configurable security context for run pods
Adds KUBERNETES_RUNNER_SECURITY_CONTEXT (off | baseline | restricted), selecting how constrained the run container is.

baseline drops the capability bounding set and blocks privilege escalation. restricted additionally pins the container to a non-root uid, chosen by runtime so bun images get their own.

Default is off, so this is inert on merge.
2026-08-18 18:40:18 +01:00
Matt Aitken 444c2215ca fix(run-engine): stop requeued runs with a lapsed ttl being orphaned in the queue (#4669)
## Summary

A run triggered with a `ttl` could get permanently stuck showing as
queued. If the run started executing and was then requeued after a
failure (a stalled heartbeat, a worker dying mid-run) once its TTL had
already elapsed, the next dequeue pass silently dropped it from every
queue structure. The run stayed QUEUED in the database forever, and
nothing (dequeue, the TTL consumer, queue repair) could ever see it
again.

## Root cause

Enqueue registers a TTL entry for the TTL consumer, and the first
dequeue removes it ("the run is executing, not expired"). A nack rewrote
the message preserving the original `ttlExpiresAt` without
re-registering that entry. The next dequeue pass then took the
expired-TTL branch: remove the run from the queue sorted sets and leave
the message for the TTL consumer to finalize. But the consumer's entry
was gone, so nothing ever finalized the run.

The fix has two halves:

- `nackMessage` drops `ttlExpiresAt` from the rewritten message. TTL
only applies to runs that have never been dequeued (the same contract as
`includeTtl` on re-enqueues), so a requeued run stays dequeuable and is
never expired by its original deadline.
- The dequeue expired-TTL branches now (re-)register the TTL entry
instead of assuming it exists, so any message still carrying a lapsed
`ttlExpiresAt` with no TTL entry (including ones written before this
fix) finalizes as EXPIRED instead of orphaning.

## Verification

New engine test suite `ttlNackRequeue.test.ts` (testcontainers, real
Redis and Postgres). All four tests fail before the fix and pass after:

- a heartbeat-stalled EXECUTING run with a lapsed TTL is requeued and
dequeued again instead of orphaned (the full production failure chain)
- requeue-after-failure strips `ttlExpiresAt` so later dequeues do not
treat the run as expired
- a lapsed-TTL message whose TTL entry is missing is re-registered by
dequeue and finalized as EXPIRED, for both plain and concurrency-key
queues

Also ran the existing ttl, heartbeats, dequeuing and attemptFailures
engine suites plus the full run-queue suite (149 tests) against the
change.
2026-08-18 19:32:41 +02:00
nicktrn 2496a8a863 feat(supervisor): optional image registry rewrite for run pods
Adds two optional env vars that rewrite the registry host of run pod images at pod creation, so a supervisor can pull from a registry in its own region. Off by default and inert unless both are set. Exact host-prefix matching, so look-alike hosts pass through untouched.
2026-08-18 15:03:12 +01:00
Chris Arderne b4313c8199 feat: logs search v2 (#4615) 2026-08-18 14:59:46 +01:00
nicktrn 158f6957e4 feat(supervisor): make the runner seccomp profile configurable
Replaces the hardcoded runner seccomp profile path with KUBERNETES_RUNNER_SECCOMP_PROFILE_PATH, and the node-24-only condition with KUBERNETES_RUNNER_SECCOMP_PROFILE_RUNTIMES (none | node-24-plus | all).

Both defaults reproduce current behaviour, so this is inert on merge. Widening the scope or turning attachment off becomes a config change rather than a deploy.
2026-08-18 14:47:00 +01:00
Chris Arderne 74db5a3f58 docs: document cron schedule windows (#4657) 2026-08-18 13:23:29 +01:00
Saadi Myftija 7e677008ed feat(supervisor): per-org placement overrides for run pods (#4655)
The supervisor now supports routing an organization's runs to specific
nodes. `KUBERNETES_ORG_PLACEMENT_OVERRIDES` takes JSON keyed by the
internal org ID, adding node selector entries and tolerations to that
org's run pods, e.g. to route an org onto a dedicated, tainted node
pool:

```json
{"<orgId>": {"nodeSelector": {"pool": "dedicated"}, "tolerations": "dedicated=runs:NoSchedule"}}
```

The node selector merges over the defaults (the override wins on key
collision, with a warning logged). Tolerations append to the existing
runner and scheduled-run sets. Overrides are validated at startup
similar to `KUBERNETES_RUNNER_TOLERATIONS`.

Exposed in the Helm chart as
`supervisor.config.kubernetes.orgPlacementOverrides`, where tolerations
can also be given as a list.
2026-08-18 12:16:23 +00:00
Chris Arderne 53ca44dd2d chore: cache and clean up Knip analysis (#4658) 2026-08-18 12:58:47 +01:00
Katia Bulatova e768d0a724 feat(webapp): run the dashboard agent through AWS Bedrock behind an env switch (#4609)
## What & why

The dashboard agent can now run its model calls through AWS Bedrock
instead of the direct Anthropic API, chosen by a single env switch. It's
**off by default** (`DASHBOARD_AGENT_MODEL_PROVIDER` unset ⇒
`anthropic`), so merging changes nothing at runtime — the Bedrock path
is a dormant branch until an operator sets the switch and AWS config.
The default Anthropic path is byte-for-byte unchanged.

This also carries a related tenant-isolation hardening for the agent's
delegated token (kept together deliberately — both land the agent on
Bedrock for HIPAA readiness). Refs: TRI-13251, TRI-11032.

## What's inside

**Provider seam** —
`internal-packages/dashboard-agent/src/model-provider.ts`: the registry
now holds both `anthropic` and `bedrock`; `resolveDashboardAgentModel()`
maps the canonical `"anthropic:<id>"` strings the managed prompts carry
to the active provider, and the cache-breakpoint helpers emit the active
provider's shape — Anthropic `cacheControl` vs Bedrock `cachePoint`.
Managed prompt strings stay canonical, so stored prompts don't change
meaning. Unmapped model ids throw rather than shipping a guaranteed-404
profile. All agent, watch, compaction and title callsites route through
the resolver; the `dashboardAgentModelKey` locals override (test mock
injection) is preserved.

**Cache telemetry** — `step-cache.ts`: cache token usage is read from
the active provider (Anthropic reports it on provider metadata; Bedrock
reports the write on metadata and the read via standard usage), so
`gen_ai.usage.cache_*` is populated on both. This also fixes a latent
ordering bug where step attributes could null-overwrite the prompt-cache
read count.

**Webapp callsites** — `dashboardAgentHeadStart.server.ts` and the
head-start route resolve the model and the cache breakpoint through the
same seam, so the warm-up prefix and the following turn share one
provider. The head-start firing gate is provider-aware: on Bedrock it
gates on `AWS_REGION` and lets the SDK resolve credentials (IAM role /
static keys / session token / bearer), so a role-based deploy still
warms; on Anthropic it stays `Boolean(ANTHROPIC_API_KEY)`.
`app/env.server.ts` gains the optional AWS vars and validates
`DASHBOARD_AGENT_MODEL_PROVIDER`. `ANTHROPIC_API_KEY` is untouched and
not required on a Bedrock deploy.

**Tenant-isolation hardening** —
`internal-packages/rbac/src/fallback.ts`: for a **scoped** context, the
OSS `authenticateUserActor` now applies the same membership floor as the
session path — a delegated user-actor token whose user is not a member
of the scoped org/project is denied (403). Unscoped tokens keep their
prior behavior (no tenant claim, no lookup). The user lookup falls back
replica→primary so replication lag can't spuriously 401 a just-joined
member. Members and admins are unaffected. Previously this invariant
held only through per-route discipline; this makes it structural.

## Enabling Bedrock (later, ops)

- Set `DASHBOARD_AGENT_MODEL_PROVIDER=bedrock` **identically** in both
the webapp and the agent task container — the webapp warms the cache
prefix and the task reads it, so a split would silently miss the cache.
- Set `AWS_REGION` and provide credentials the Bedrock SDK can resolve
(IAM role preferred). For v1 this runs **without** an Anthropic API key.
Note: with no Anthropic key set, rollback is "turn the agent off", not
"unset the switch" (unsetting falls back to the Anthropic provider,
which then has no key).
- Two things to confirm before rollout: the Sonnet inference-profile id
is validated against the SDK's own model-id union but still warrants a
live smoke test; and Bedrock prompt caching for Sonnet is a 5-minute
window (not Anthropic's 1h), so input-token cost rises when flipped.

## Testing

Unit tests cover both provider paths: the provider switch and
per-provider cache shapes, a structural regex asserting Bedrock ids are
real inference profiles (not an echo of the table), the split-metadata
cache telemetry, and real-Postgres RBAC tests — member allowed, scoped
non-member denied (org-only and project-only), missing user → 401, admin
non-member exempt, unscoped success. `typecheck --filter webapp` and the
dashboard-agent + rbac suites pass.
2026-08-18 13:14:01 +02:00
Chris Arderne b33197691b chore: enforce no unused deps or code in ci (#4654) 2026-08-18 11:35:51 +01:00
Matt Aitken 40c4064f96 fix(webapp): show errors on AI tool call and embed spans in the run inspector (#4653)
## Summary

When an AI SDK tool call failed inside a run, the span showed up under
the "Errors only" filter but the span inspector gave no hint of what
went wrong. The exception was recorded on the span all along; the
`ai.toolCall` and `ai.embed` inspector views just never rendered span
events. Failed tool call and embedding spans now show the standard error
block (message plus stack trace) below the Input section.

## Root cause

Generic spans render exception span events via the `SpanEvents`
component, but the AI-specific span entities replace the whole panel
with their own layout and dropped the events entirely. The span's events
are now passed into `AIToolCallSpanDetails` and `AIEmbedSpanDetails` and
rendered with the same `SpanEvents` component the generic view uses.

Errored generation spans (`ai.generateText` and friends) use a tabbed
view and still don't surface errors; that needs its own design pass and
is left for a follow-up.
2026-08-18 11:54:07 +02:00
Wes Mason a55f7cdf4d fix(run-engine): stop a '*' concurrency key stranding its whole base queue (#4628)
## The bug

A concurrency key is an unrestricted client string
(`ConcurrencyKeySchema` is `z.union([z.string(),
z.number()]).transform(String)`), and `concurrencyKeySection` does no
escaping, so `*` reaches the queue raw. `queueKey` then renders it as
`...:queue:<q>:ck:*`, which is byte-identical to the wildcard member the
CK scripts keep in the master queue to mean "this base queue has
concurrency-key work".

Every CK script ends with the same pair:

```lua
-- Rebalance master queue with ck:* member
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)

-- Remove old-format entry from master queue (transition cleanup)
redis.call('ZREM', masterQueueKey, queueName)
```

`ckWildcardName` is `toCkWildcard(message.queue)`, and for a `*`-keyed
run that returns the identical string, so the cleanup on the second line
deletes what the rebalance on the first line just wrote.

The master queue then has no entry for that base queue, while `ckIndex`
and the variant queues still hold the work. **Every concurrency key on
the queue stops being dequeued**, not just the `*` one. It is silent,
and it only recovers if some later write happens to re-add the member.

Reproduced before the fix:

```
master queue AFTER normal ck enqueue: ["{org:...}:queue:task/my-task:ck:*"]
master queue AFTER ck='*' enqueue:    []
ckIndex members (work still queued):  [":ck:user-1", ":ck:*"]
dequeued:                             []
```

Blast radius is bounded to the environment that triggers it, so it is
self-inflicted rather than cross-tenant, but a single trigger stalls the
queue for everything on it.

## The fix

Guard the cleanup so it never removes the wildcard member:

```lua
if queueName ~= ckWildcardName then
  redis.call('ZREM', masterQueueKey, queueName)
end
```

Applied to all 10 CK scripts (4 enqueue, 6 ack/nack/dead-letter). No
key-format change and no migration: a queue already stranded in Redis is
repaired by its next write.

I considered rejecting `*` at the API boundary instead and rejected it.
Existing Redis state and `TaskRun.concurrencyKey` rows already hold raw
`:`-bearing and `*` keys, so changing key construction would orphan
in-flight messages and split concurrency accounting mid-deploy. Boundary
validation would still be reasonable as belt-and-braces later, but the
Lua guard alone fixes it including for state already out there.

## Testing

`ckWildcardKey.test.ts` covers the enqueue, ack and nack paths. All
three pass with the guard and **all three fail without it**, verified by
reverting. Full `src/run-queue/` suite is green (166 tests).

## Note for #4367

The virtual-time branch adds three more CK scripts with the same pattern
(`enqueueMessageCkVtimeTracked`, `enqueueMessageWithTtlCkVtimeTracked`,
`nackMessageCkVtimeTracked`). They do not exist on main so they are not
in this PR; the same guard needs applying there, and I will do that on
that branch.
2026-08-18 09:36:48 +01:00
Chris Arderne 99f0787148 feat(cli,webapp): default new projects to node-24 (#4649) 2026-08-18 07:23:52 +01:00
Chris Arderne 7d9f1a3268 docs: document additional environment API keys (#4406) 2026-08-17 17:31:50 +01:00
nicktrn 6e7710282c ci: make the lefthook pre-push hook actually install (#4642)
## Summary

`lefthook.yml` has been in the repo since #4147, but nothing installs
lefthook and nothing runs `lefthook install`, so the pre-push hook it
describes has never fired for anyone. #3977 had removed the `lefthook`
devDependency a week before #4147 landed, and #4147 only added the
config file.

This supplies the missing half:

```diff
+    "prepare": "lefthook install",
+    "lefthook": "^2.1.10",
       "onlyBuiltDependencies": [
+      "lefthook",
```

With those in place, `pnpm install` wires the hook up on clone, and the
format and lint checks actually run before a push instead of first
failing in CI.

Also here: the pre-push jobs run in parallel rather than in sequence,
and `CONTRIBUTING.md` documents the hook, including how to skip it and
the fact that GitButler only runs hooks when "Run hooks" is enabled in
its settings.

`lefthook@2.1.10` is the current release.
2026-08-17 08:24:32 +01:00
claude[bot] 3d0b46fee5 chore: vouch gtremper (#4648)
<!-- ccr-slack-attribution -->
_Requested by **Matt Aitken** · [Slack
thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1786948064703889?thread_ts=1786948064.703889&cid=C045W9WM3E1)_

Adds `gtremper` to `.github/VOUCHED.td`.

Before: PRs from `gtremper` are auto-closed by the vouch check.
After: `gtremper` is vouched, so their PRs stay open and run CI.

Done as a direct file edit rather than the issue-comment flow because
there is no open Vouch Request issue for this user, matching the
precedent in #3804.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-17 06:53:20 +00:00
nicktrn d62dd0dc30 chore(core): drop the unused socket.io dependency (#4640)
## Summary

`packages/core` declared `socket.io`, the server package, but never
imported it. Its only Socket.IO usage is the client:

```
packages/core/src/v3/zodSocket.ts
packages/core/src/v3/runEngineWorker/supervisor/session.ts
  import { io } from "socket.io-client";
```

The only occurrence of `socket.io` outside those client imports was the
`package.json` line itself. Since `@trigger.dev/core` is published, that
line meant every consumer installed a server package nothing in the tree
imports.

`socket.io-client` is untouched. `apps/webapp` and `apps/supervisor`
keep their own `socket.io` dependencies, so the server side is
unaffected.

Found with `pnpm run knip:deps`, which the repo already ships.

`pnpm run typecheck` passes across all 57 workspaces, and
`@trigger.dev/core` builds clean.

Stacked on #4639.
2026-08-16 22:29:39 +01:00
nicktrn 362479d7b2 chore(deps): raise the find-my-way floor (#4639)
## Summary

`find-my-way` was resolving `9.3.0` even though its only parent,
`fastify@5.8.5`, declares `^9.0.0` and so already permitted a newer
release. The lockfile had not re-resolved since. This adds a floor so it
lands on a current 9.x:

```json
"find-my-way@>=9 <9.7.0": "^9.7.0"
```

It resolves to `9.7.0`. Nothing outside the 9.x line is touched, and no
parent is asked to accept anything its declared range did not already
allow.

The whole path is development only: `find-my-way` arrives through
`fastify`, which arrives through `evalite`, a devDependency of
`apps/webapp` used by the `eval:dev` harness.

Stacked on #4638.
2026-08-16 22:12:20 +01:00
nicktrn b4f7800ff1 chore(deps): cover the second ip-address parent (#4638)
## Summary

The existing `ip-address` override is scoped to a single parent,
`@jsonhero/json-infer-types>ip-address`. A second parent reaches
`ip-address` independently: `express-rate-limit@8.6.0`, which is itself
pinned by our `@modelcontextprotocol/sdk@>=1.26.0>express-rate-limit`
override. That path was resolving `10.2.0` while the scoped path
resolved `10.5.0`, so the tree carried two copies.

This adds a matching scoped override for the second parent:

```json
"express-rate-limit>ip-address": "^10.3.1"
```

`express-rate-limit` declares `^10.2.0`, so this asks nothing of it that
its own range did not already allow. The tree now resolves a single
`ip-address@10.5.0`.

The existing `@jsonhero/json-infer-types` override stays: that package
declares `ip-address: ^8.1.0`, so removing it brings an 8.x copy back.

Stacked on #4637.
2026-08-16 22:12:19 +01:00
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 869156e3b8 chore(deps): raise the axios floor (#4634)
## Summary

`axios` was resolving to 1.16.1 through `@slack/web-api`, which declares
`^1.16.0`. The lockfile had simply not re-resolved since, so the tree
sat on an older 1.x release than the range allows.

This adds a scoped override so the 1.x line picks up a current release:

```json
"axios@>=1.15.2 <1.18.0": "^1.18.0"
```

It resolves to 1.19.0. No parent bump is needed, since `^1.16.0` already
permits it, and `@slack/web-api` is the only consumer.

Stacked on #4633 so the two lockfile changes do not collide.
2026-08-16 21:11:33 +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
nicktrn 7ba81e983d chore(deps): raise stale transitive dependency floors (#4629)
## Summary

A number of `pnpm.overrides` entries had drifted behind the releases
they were written against. An override fixes the resolved version
outright, so in every one of these cases the tree was pinned to the
floor value rather than picking up later releases in the same line. This
raises each floor to a current release, and widens the selectors that
were scoped to an exact upper bound so they keep matching.

| Override | Before | After |
| --- | --- | --- |
| `body-parser` (under `express@^4`) | `1.20.3` | `^1.20.6` |
| `tar` | `7.5.19` | `7.5.21` |
| `hono` | `4.12.25` | `4.12.34` |
| `undici` (6.x) | `6.27.0` | `6.28.0` |
| `undici` (7.x) | `7.28.0` | `7.29.0` |
| `js-yaml` (3.x) | `3.14.2` | `3.15.1` |
| `js-yaml` (4.x) | `4.1.1` | `4.3.1` |
| `dompurify` | `^3.4.1` | `^3.4.13` |
| `vite` | `^6.4.2` | `^6.4.3` |
| `protobufjs` | `^7.5.6` | `^7.6.5` |
| `socket.io-parser` | `^4.2.6` | `^4.2.7` |
| `postcss` | `^8.5.10` | `^8.5.23` |
| `fast-uri` | `^3.1.2` | `^3.1.5` |
| `brace-expansion` (1.x) | `1.1.13` | `1.1.18` |
| `brace-expansion` (2.x) | `2.0.3` | `2.1.4` |
| `brace-expansion` (5.x) | `5.0.6` | `5.0.9` |
| `ip-address` (under `@jsonhero/json-infer-types`) | `^10.2.0` |
`^10.3.1` |

Every parent's declared range still accepts the new resolution, so
nothing is forced outside its stated bounds by this change.

Two of these changed a default rather than just moving version.
`js-yaml` 4.2.0 stopped resolving underscore-separated scalars such as
`1_000` as numbers, which is the YAML 1.2 behaviour, and there are none
in any YAML in this repo. `brace-expansion` 2.1.x now caps expansion
size by default, well above anything a real glob produces, and
`minimatch` calls it with no options. Neither is reachable from how we
use them.

`undici@5.29.0` and `vite@4.4.9` are left alone: their parents cap below
the newer lines, so moving either would mean taking the parent across a
major.

Verified with a clean install, and `pnpm run typecheck` passes.
2026-08-16 17:19:01 +00: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.
2026-08-13 16:31:51 +01:00
github-actions[bot] 6685cbd599 chore: release v4.5.11 (#4557)
## Summary
4 new features, 24 improvements, 10 bug fixes.

## Highlights

- Allow `trigger deploy` to authenticate with an environment API key
from `TRIGGER_ACCESS_TOKEN`.
([#4561](https://github.com/triggerdotdev/trigger.dev/pull/4561))

## Improvements
- Chat in the browser now reconnects when the connection drops mid-turn,
instead of leaving the reply stuck as if it were still generating.
Reports can be fetched as structured data with the `json` format, and
the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`).
The `mint-token` command's help is clearer too: a token minted without
`--cap` is read-only, and `--ttl` shows the correct maximum lifetime of
7 days.
([#4418](https://github.com/triggerdotdev/trigger.dev/pull/4418))
- The dev environment onboarding now tracks real progress. After you run
`init`, the setup checklist marks your project as initialized, and it
updates live as your dev server connects and your tasks register. The
blank state also adds a "Copy AI agent prompt" button that copies a
ready-to-paste setup prompt (pre-filled with your project reference) for
Claude Code, Cursor, or any coding agent.
([#4563](https://github.com/triggerdotdev/trigger.dev/pull/4563))
  
The `init` scaffold now imports from `@trigger.dev/sdk` instead of the
deprecated `@trigger.dev/sdk/v3` subpath.
- Deployed images now ship dependencies and bundled task code as
separate layers. Repeat deploys with unchanged dependencies typically
push and pull far less data, making deploys and worker image pulls
faster.
([#4551](https://github.com/triggerdotdev/trigger.dev/pull/4551))
- The current-worker API now reports each task's queue, so you can see
which tasks write to a given queue.
([#4525](https://github.com/triggerdotdev/trigger.dev/pull/4525))
- Watch-mode chat streams now survive quiet windows and page reloads,
and a reply cut off by a lost connection shows an error instead of
appearing finished. Aborting a resumed subscription only closes your
local stream — call `stopGeneration(chatId)` or pass `stopOnAbort: true`
to stop the run. Also fixed a race where quickly restarting a stream
could break stop and reconnect, and stopping a chat now hands it back to
your other tabs instead of leaving them read-only.
([#4516](https://github.com/triggerdotdev/trigger.dev/pull/4516))

## Server changes

These changes affect the self-hosted Docker image and Trigger.dev Cloud:

- The dashboard agent now has a monthly message allowance and plan-based
limits on watches. Queries stay read-only with clearer errors when busy,
and messages with unusual characters no longer fail to send.
([#4516](https://github.com/triggerdotdev/trigger.dev/pull/4516))
- Meet the dashboard agent: a chat in every environment that answers
questions about your runs, queues, errors and health with real data and
links, replacing Ask AI everywhere it used to appear. Investigate a
failed run, an error, a backed-up queue or a run that hasn't started to
get a worked-through answer — what happened, why, and how to fix it,
with every claim linked to the runs, errors and deploys behind it. It
reads your data read-only, works on preview and dev branches with that
branch's own data, and reads the same everywhere — dashboard, terminal,
editor. A very long chat keeps working: the agent summarises the earlier
part and carries on.
  
**Watch…** on a run, queue, error or the health report tells you when
things change: a run finishes, a queue clears or grows past a number you
pick, an error comes back, an environment recovers. The answer arrives
in the chat and, if you want, by email, Slack or webhook — and the agent
can look into bad news on its own. A watch reaches you on any browser
you sign in from, without opening the chat first.
  
A sample of conversations is scored automatically so the agent keeps
getting better; only the score and a one-line summary are kept, never
your messages, data or code, and we can switch it off for your
organization on request. Ask the agent instead of the Docs buttons in
page headers — they stay there when the agent isn't available to you.
Separately, a queue's wait times, peak depth, throughput and throttling
can now be read from the API.
([#4418](https://github.com/triggerdotdev/trigger.dev/pull/4418))
- Add backend support for delaying cron schedules within a specified
window with a minimum of 60 seconds.
([#4566](https://github.com/triggerdotdev/trigger.dev/pull/4566))
- Reduced recurring background database load from the billing-limit
recovery check, so paused environments are reconciled with less
overhead.
([#4590](https://github.com/triggerdotdev/trigger.dev/pull/4590))
- Validating a schedule when deploying or updating a schedule now does
less work on projects with many preview branches, so those operations
stay fast as branches accumulate.
([#4598](https://github.com/triggerdotdev/trigger.dev/pull/4598))
- Project pages now load faster for projects with a large number of
preview branches, by no longer loading archived branch environments that
aren't shown.
([#4595](https://github.com/triggerdotdev/trigger.dev/pull/4595))
- Database queries that filter on a list of values now reuse cached
query plans more consistently, instead of forcing the database to
re-plan whenever the list length changes.
([#4480](https://github.com/triggerdotdev/trigger.dev/pull/4480))
- Routine cleanup of old dashboard agent data now runs on its own
schedule.
([#4599](https://github.com/triggerdotdev/trigger.dev/pull/4599))
- Database connection metrics are now reported for every configured
database connection instead of only the primary one, and stay accurate
regardless of connection type.
([#4541](https://github.com/triggerdotdev/trigger.dev/pull/4541))
- Deployment-related API endpoints now draw from their own generous rate
limit budget, configurable via the `DEPLOYMENT_RATE_LIMIT_*` environment
variables, so runtime API traffic no longer competes with deployments
for the same per-environment budget.
([#4565](https://github.com/triggerdotdev/trigger.dev/pull/4565))
- Deleting or editing a secret environment variable is now fast and no
longer slows down as a project accumulates variables.
([#4555](https://github.com/triggerdotdev/trigger.dev/pull/4555))
- Speed up personal access token lookups by indexing them on their owner
([#4588](https://github.com/triggerdotdev/trigger.dev/pull/4588))
- Switching project or organization in the sidebar now 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.
([#4585](https://github.com/triggerdotdev/trigger.dev/pull/4585))
- Reduced database load when loading the dashboard by removing an unused
organization member count that was being calculated on every page
navigation.
([#4587](https://github.com/triggerdotdev/trigger.dev/pull/4587))
- The environment variables page now loads a page at a time, keeping it
fast for projects with a large number of variables. Search matches
variable names across every page.
([#4597](https://github.com/triggerdotdev/trigger.dev/pull/4597))
- Groundwork for an alternative database connection driver, gated behind
configuration and disabled by default, so there is no change to default
behavior.
([#4539](https://github.com/triggerdotdev/trigger.dev/pull/4539))
- Deleting an alert channel is now fast and no longer slows down as a
project builds up alert history.
([#4554](https://github.com/triggerdotdev/trigger.dev/pull/4554))
- Reduced internal overhead on the API under high load.
([#4532](https://github.com/triggerdotdev/trigger.dev/pull/4532))
- Out-of-date upgrade prompts no longer appear in the dashboard: the
"V4" badges and the notices saying preview branches and the queues table
need V4 have been removed. The side menu still warns you when a project
is on v3, with updated wording and a link to the v4 upgrade guide.
([#4589](https://github.com/triggerdotdev/trigger.dev/pull/4589))
- Make background worker registration cheaper for projects with many
scheduled tasks by scoping declarative schedule reconciliation to the
current environment and dropping redundant schedule lookups.
([#4577](https://github.com/triggerdotdev/trigger.dev/pull/4577))
- Speed up setting and importing environment variables for projects with
many variables.
([#4579](https://github.com/triggerdotdev/trigger.dev/pull/4579))
- Loading the deployments list is now faster, especially when filtering
by deployment status on projects with many deployments.
([#4591](https://github.com/triggerdotdev/trigger.dev/pull/4591))
- Fixed the billing limits page timing out for organizations with many
preview branches, especially while a spend limit was being enforced. The
page now loads quickly, so you can raise or resolve your limit without
delay. ([#4594](https://github.com/triggerdotdev/trigger.dev/pull/4594))
- Fix the Concurrency page showing the plan's default concurrency for
the dev environment instead of the environment's actual limit.
([#4596](https://github.com/triggerdotdev/trigger.dev/pull/4596))
- Creating an organization sometimes left you back on the creation form
even though the organization had already been created, so clicking
Create again made a duplicate. Creating an organization now completes
and takes you to your new organization.
([#4530](https://github.com/triggerdotdev/trigger.dev/pull/4530))
- Ensure creating a project completes instead of returning to its
creation form after a navigation error.
([#4584](https://github.com/triggerdotdev/trigger.dev/pull/4584))
- Renaming a project now keeps you on the project settings page and
tells you what happened, instead of silently moving you to the tasks
page or clearing the form with no explanation.
([#4601](https://github.com/triggerdotdev/trigger.dev/pull/4601))
- Fixed support threads showing no account details for some customers,
so the team can see your plan, organizations and projects when you get
in touch.
([#4575](https://github.com/triggerdotdev/trigger.dev/pull/4575))
- In the light theme, the Format, Clear and Copy buttons on the query
editor no longer blend into the query text behind them.
([#4592](https://github.com/triggerdotdev/trigger.dev/pull/4592))
- The health report now says start latency is "unknown" when there is no
data for it, instead of showing a healthy-looking 0ms
([#4544](https://github.com/triggerdotdev/trigger.dev/pull/4544))
- Realtime streams written inside a chat session run now use the same
backend as the session itself, and runs are no longer created against a
backend that cannot serve them.
([#4564](https://github.com/triggerdotdev/trigger.dev/pull/4564))
- The grouped "watch updates" notification now shows the total number of
results waiting, instead of only the most recent batch's count.
([#4525](https://github.com/triggerdotdev/trigger.dev/pull/4525))

<details>
<summary>Raw changeset output</summary>

# Releases
## @trigger.dev/build@4.5.11

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
## trigger.dev@4.5.11

### Patch Changes

- Chat in the browser now reconnects when the connection drops mid-turn,
instead of leaving the reply stuck as if it were still generating.
Reports can be fetched as structured data with the `json` format, and
the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`).
The `mint-token` command's help is clearer too: a token minted without
`--cap` is read-only, and `--ttl` shows the correct maximum lifetime of
7 days.
([#4418](https://github.com/triggerdotdev/trigger.dev/pull/4418))
- Allow `trigger deploy` to authenticate with an environment API key
from `TRIGGER_ACCESS_TOKEN`.
([#4561](https://github.com/triggerdotdev/trigger.dev/pull/4561))
- The dev environment onboarding now tracks real progress. After you run
`init`, the setup checklist marks your project as initialized, and it
updates live as your dev server connects and your tasks register. The
blank state also adds a "Copy AI agent prompt" button that copies a
ready-to-paste setup prompt (pre-filled with your project reference) for
Claude Code, Cursor, or any coding agent.
([#4563](https://github.com/triggerdotdev/trigger.dev/pull/4563))

The `init` scaffold now imports from `@trigger.dev/sdk` instead of the
deprecated `@trigger.dev/sdk/v3` subpath.

- Deployed images now ship dependencies and bundled task code as
separate layers. Repeat deploys with unchanged dependencies typically
push and pull far less data, making deploys and worker image pulls
faster.
([#4551](https://github.com/triggerdotdev/trigger.dev/pull/4551))
- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
  - `@trigger.dev/build@4.5.11`
  - `@trigger.dev/schema-to-json@4.5.11`
## @trigger.dev/core@4.5.11

### Patch Changes

- Chat in the browser now reconnects when the connection drops mid-turn,
instead of leaving the reply stuck as if it were still generating.
Reports can be fetched as structured data with the `json` format, and
the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`).
The `mint-token` command's help is clearer too: a token minted without
`--cap` is read-only, and `--ttl` shows the correct maximum lifetime of
7 days.
([#4418](https://github.com/triggerdotdev/trigger.dev/pull/4418))
- The current-worker API now reports each task's queue, so you can see
which tasks write to a given queue.
([#4525](https://github.com/triggerdotdev/trigger.dev/pull/4525))
## @trigger.dev/python@4.5.11

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
  - `@trigger.dev/sdk@4.5.11`
  - `@trigger.dev/build@4.5.11`
## @trigger.dev/react-hooks@4.5.11

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
## @trigger.dev/redis-worker@4.5.11

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
## @trigger.dev/rsc@4.5.11

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
## @trigger.dev/schema-to-json@4.5.11

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.11`
## @trigger.dev/sdk@4.5.11

### Patch Changes

- Chat in the browser now reconnects when the connection drops mid-turn,
instead of leaving the reply stuck as if it were still generating.
Reports can be fetched as structured data with the `json` format, and
the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`).
The `mint-token` command's help is clearer too: a token minted without
`--cap` is read-only, and `--ttl` shows the correct maximum lifetime of
7 days.
([#4418](https://github.com/triggerdotdev/trigger.dev/pull/4418))
- Watch-mode chat streams now survive quiet windows and page reloads,
and a reply cut off by a lost connection shows an error instead of
appearing finished. Aborting a resumed subscription only closes your
local stream — call `stopGeneration(chatId)` or pass `stopOnAbort: true`
to stop the run. Also fixed a race where quickly restarting a stream
could break stop and reconnect, and stopping a chat now hands it back to
your other tabs instead of leaving them read-only.
([#4516](https://github.com/triggerdotdev/trigger.dev/pull/4516))
- Updated dependencies:
  - `@trigger.dev/core@4.5.11`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-13 15:48:29 +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
Saadi Myftija 45444a7b1d feat: publish deploy base images to DockerHub (#4581)
Publishes the base images deployed task containers will build on:
`triggerdotdev/node:{21,22,24,26}-bookworm` and
`triggerdotdev/bun:1.3-node20-bookworm`, each with a `-build` toolchain
variant, multi-arch, built from a pinned Debian snapshot so every
published layer is reproducible from recorded inputs and carries a
GitHub provenance attestation.

Publishing runs on manual dispatch and on merges touching
`base-images/`. Pull requests build without pushing.
2026-08-13 11:21:40 +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 96b2959107 perf(database): index PersonalAccessToken.userId so token lookups stop seq-scanning (#4588)
## Summary

The two personal-access-token lookups by `userId` (one also filtering
`revokedAt is null`, the other also filtering `name`) had no index on
`userId`, so each did a full sequential scan of the
`PersonalAccessToken` table to return a single row. `userId` is also an
unindexed foreign key.

## Fix

Add a single `@@index([userId])`. A user owns only a handful of PATs, so
once `userId` is indexed each lookup touches a few rows and the residual
`revokedAt` / `name` filter is trivial. Both query shapes lead with
`userId =`, so one index serves both and a composite would only add
write cost. The migration uses `CREATE INDEX CONCURRENTLY IF NOT
EXISTS`, which is online-safe under write load and reversible by
dropping the index.

Verified with a seeded local EXPLAIN: both queries go from a full
sequential scan to an index scan on the new index.
2026-08-12 14:04:01 +01: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
Eric Allam 4658cd0721 perf(database): index WorkerDeployment on (environmentId, status, id) for the deployments list (#4591)
## What

Adds a composite index `@@index([environmentId, status, id])` to
`WorkerDeployment`.

The public deployments list (`GET /api/v1/deployments`) filters by
`status` and paginates by `id` descending. The existing indexes cover
`(environmentId, createdAt)` and the PK, but nothing covers `status`. So
for a status filter Postgres walks back through the environment's
deployments discarding non-matching statuses, reading roughly 350 rows
for every 1 returned (p99 ~1.1s on the busiest environments). The new
index makes the status filter index-satisfied and lets `id` serve both
the cursor range and the `ORDER BY id DESC`, bounding the read to a
single page.

Full composite (not partial) because callers filter by arbitrary status
values with no single dominant one.

## Query

```sql
SELECT ... FROM "WorkerDeployment"
WHERE "environmentId" = $1 AND "status" = $2 [AND "id" < $3]
ORDER BY "id" DESC LIMIT $4;
```

Source: `apps/webapp/app/routes/api.v1.deployments.ts`.

## Evidence

Reproduced on an isolated stack: one environment seeded with 7,000
deployments, the filtered status appearing 1 in 333 rows.

Before (no index):
```
Seq Scan on "WorkerDeployment"  (rows=21)
  Rows Removed by Filter: 6979
  Buffers: shared hit=206
Execution Time: 2.9 ms   (+ a sort for id desc)
```

After (with the index):
```
Index Scan Backward using "WorkerDeployment_environmentId_status_id_idx"
  Index Cond: (environmentId = $1 AND status = $2)
  Buffers: shared hit=23
Execution Time: 0.43 ms
```

Rows-removed-by-filter drops to 0; buffers 206 -> 23. The cursor
(mid-pagination) variant uses the same index with all three predicates
as the index condition. A dense/common status keeps the cheap PK
backward scan (already fine); the index targets exactly the rare-status
paths that were amplified.

End-to-end against the running webapp API: `?status=FAILED` returns the
correct newest-first page and paginates correctly across pages, and the
emitted SQL matches the query above.

## Rollout

- Index only, `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in its own
migration file. Online-safe under write load.
- Pre-apply the index in production before the migration deploys, per
repo convention (the migration is then a no-op).
- Rollback: drop the index. No data migration.

refs TRI-13171
2026-08-12 14:03:39 +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
DKP 336f515001 docs(ai): add LLM observability page (#4568)
## Summary

Adds a docs page for LLM observability: every opted-in Vercel AI SDK
call inside a task becomes its own span in the run trace, carrying the
model, provider, token counts, cost, and latency. The page covers
turning it on per call with `experimental_telemetry: { isEnabled: true
}`, what each span inspector tab shows (Overview, Messages, Tools, and a
Prompt tab when linked), linking a call to its prompt version with
`toAISDKTelemetry()`, and querying usage across runs with TRQL against
the `llm_metrics` table.

It sits in the AI dropdown under Features, next to
[Prompts](https://trigger.dev/docs/ai/prompts), and cross-links the
[Query](https://trigger.dev/docs/observability/query) page.

It is explicit that capture is opt-in per call (not automatic) and only
covers Vercel AI SDK calls, and notes the `@ai-sdk/otel` requirement on
AI SDK 7. Every API name, span tab, and TRQL column was checked against
the SDK and the live query schema.

## Also in this PR

Corrects one bullet in the [AI Agents
overview](https://trigger.dev/docs/ai-chat/overview): it claimed an
in-progress chat resumes on the new version after a redeploy, which
contradicts the version-upgrades and backend pages. Chat agent runs are
pinned to the version they started on; moving onto new code is an
explicit version upgrade.
2026-08-11 14:40:31 +00:00
DKP e367899510 docs(ai-agents): add chat.agent guide and refresh the AI agent guides (#4524)
## Summary

Adds a "Build a chat agent" guide to the AI agents section, surfaces the
ClickHouse chat agent example in the guides index and the AI agents
overview,
and refreshes the five existing workflow guides so their code is
current.

## Details

The pattern guides (prompt chaining, routing, parallelization,
orchestrator,
evaluator-optimizer) still used retired models and dated APIs. Updated
them to
current Anthropic Claude models (claude-haiku-4-5 for lightweight
classifier
roles, claude-sonnet-4-5 for the main work) and modernized the code:

- route-question uses generateObject for the routing decision instead of
  generateText plus manual JSON parsing.
- verify-news-article uses ModelMessage in place of the renamed
CoreMessage.
- Fixed translate-and-refine discarding its recursive refinement result,
so
  refined translations never returned to the caller.
- Fixed an invalid JSON test payload in generate-translate-copy.

The pattern concepts are unchanged; only the example code was stale.
2026-08-11 14:32:32 +00: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
Iss 1038641b15 chore: vouch Jakub-Vacek (#4559)
Adds [Jakub-Vacek](https://github.com/Jakub-Vacek) to the list of
vouched outside contributors so their PRs aren't auto-closed by the
vouch check.
2026-08-10 15:57:50 +01:00
Eric Allam ce368dd8e0 perf(database): index EnvironmentVariableValue.valueReferenceId so secret deletes stop seq-scanning (#4555)
## Why this change

`EnvironmentVariableValue.valueReference` is an `onDelete: SetNull`
foreign key. Deleting a `SecretReference` (the env var edit/delete path
for secret values) fires the cascade `UPDATE ONLY
"EnvironmentVariableValue" SET "valueReferenceId" = NULL WHERE $1 =
"valueReferenceId"`. That cascade is scan-shaped: with no index on
`valueReferenceId`, it reads the entire table to find the rows
referencing the deleted secret. The parent `SecretReference` delete does
almost no work itself; its latency is dominated by this cascade.

## Diagnosis

`EnvironmentVariableValue` was indexed on `environmentId` and
`(variableId, environmentId)`, but not on `valueReferenceId`. The SET
NULL cascade therefore did a full sequential scan of the whole table.
Two sibling SET NULL cascades on the same delete
(`OrganizationIntegration.tokenReferenceId`,
`User.mfaSecretReferenceId`) are index-backed and stay fast, which
isolates the missing index as the cause.

## Change

Add `@@index([valueReferenceId])` on `EnvironmentVariableValue`, created
with `CREATE INDEX CONCURRENTLY IF NOT EXISTS` so `prisma migrate
deploy` stays safe on a live table.

## Benchmark (local, seeded)

Local Postgres seeded with 1,000,000 `EnvironmentVariableValue` rows,
`EXPLAIN (ANALYZE, BUFFERS)` on the SET NULL cascade with zero matching
rows (the worst case: reads the whole table, affects nothing):

| | before | after |
|---|---|---|
| plan | Seq Scan (1M rows) | Bitmap Index Scan |
| execution | 183 ms | 2.8 ms |

In a variant where the secret matched several thousand rows, the parent
`SecretReference` delete's
`EnvironmentVariableValue_valueReferenceId_fkey` trigger dropped from
216 ms to 88 ms (the residual is the heap work of nulling those rows).

## Expected impact

The cascade drops from a full-table sequential scan to a targeted index
lookup. The win grows with the table, so the benefit is larger than the
seeded numbers above.

## Risks

- One extra btree to maintain on `EnvironmentVariableValue` writes;
small, single-column, and it should be pre-created before the migration
deploys (per the repo index rules).
- No behavior change: same rows nulled, no ordering or result-set
change, read paths untouched.

Companion to the same fix on `ProjectAlert.channelId`.
2026-08-10 13:54:18 +01:00
Eric Allam 4c58091973 perf(database): index ProjectAlert.channelId so alert-channel deletes stop seq-scanning (#4554)
## Why this change

Deleting a `ProjectAlertChannel` fires the FK cascade `DELETE FROM ONLY
"ProjectAlert" WHERE $1 = "channelId"`. That cascade is scan-shaped:
with no index on `channelId`, it reads the entire `ProjectAlert` table
to find the few child rows belonging to the deleted channel. The parent
`DELETE ProjectAlertChannel` does almost no work itself; its latency is
dominated by this cascade. `ProjectAlert` is append-heavy and grows over
time, so the scan cost only increases.

## Diagnosis

`ProjectAlert` had no index on `channelId` (only `pkey` + a `friendlyId`
unique). The cascade therefore did a full sequential scan of the whole
table. The sibling `ProjectAlertStorage` cascade on the same delete is
index-backed and stays fast, which isolates the missing index as the
cause.

## Change

Add `@@index([channelId])` on `ProjectAlert`, created with `CREATE INDEX
CONCURRENTLY IF NOT EXISTS` so `prisma migrate deploy` stays safe on a
live table.

## Benchmark (local, seeded)

Local Postgres seeded with 1,000,000 `ProjectAlert` rows across 50
channels (~20k rows per channel), `EXPLAIN (ANALYZE, BUFFERS)` on the
cascade delete:

| | before | after |
|---|---|---|
| plan | Seq Scan (1M rows) | Bitmap Index Scan |
| direct child delete | 740 ms | 22 ms |
| parent delete `ProjectAlert_channelId_fkey` trigger | 77.7 ms | 23.8
ms |

## Expected impact

The cascade drops from a full-table sequential scan to a targeted index
lookup. The win grows with the table: the more rows in `ProjectAlert`,
the more a scan costs and the more the index saves, so the benefit is
larger than the seeded numbers above.

## Risks

- One extra btree to maintain on every `ProjectAlert` insert; acceptable
for a single-column index on a high-insert table, and it should be
pre-created before the migration deploys (per the repo index rules).
- No behavior change: no rows orphaned, no ordering or result-set
change, read paths untouched.

## Follow-up

`ProjectAlert`'s other cascade FK columns (`projectId`, `environmentId`,
`workerDeploymentId`) are also unindexed, but their parents are
soft-deleted rather than physically removed, so those cascades do not
currently fire. Lower priority unless a hard-delete path is introduced.
2026-08-10 13:54:15 +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
Saadi Myftija bd8ce4a50f feat(deployments): split project dependencies and code into separate layers (#4551)
Deploy images previously shipped node_modules and the bundled task code
in a single layer, so every deploy re-pushed and re-pulled the full
dependency tree even when nothing in it changed. The generated
Containerfile now copies `/app/node_modules` as its own layer and the
app files separately. With unchanged dependencies the dependency layer
is identical across deploys, so registries and workers already have it
and only the code layer moves.
2026-08-10 14:44:11 +02:00
Katia Bulatova c00fb9c36c fix(webapp): report start latency as unknown when there is no data (#4544)
When the health report had no start-latency measurement for the window,
it printed a confident "p95 0ms" and graded it healthy. It now shows
"unknown" for that metric and skips grading it, so an absent measurement
can't read as a green signal.

A genuinely measured 0ms is still shown as 0ms: the loader keeps "no
measurement" distinct from a measured zero instead of coercing both to
0.
2026-08-10 13:53:23 +02:00
nicktrn 6e00aaf92b chore(deps): bump transitive mermaid to 11.16.1 (#4553)
## Summary

Bumps the transitive `mermaid` in the lockfile from `11.14.0` to
`11.16.1`.

`mermaid` has no direct dependents here. It arrives through
`streamdown`,
which declares it as a hard dependency even though diagram rendering is
gated
behind the optional `@streamdown/mermaid` plugin, which we don't
install.
`streamdown@2.5.0` is its latest release, and its declared range
(`^11.12.2`)
already permits `11.16.1`, so this was a stale lockfile pin rather than
a
range conflict.

Done as a scoped override rather than a bare lockfile refresh, so the
floor
survives a lockfile regenerated from an older base:

```json
"mermaid@>=11 <11.16.1": "^11.16.1"
```

Net effect is 96 fewer lockfile lines, contained to mermaid's own
subtree.
`11.16.1` swapped out its parser, so the `langium` / `chevrotain@12` /
`vscode-languageserver-*` chain drops in favour of a single
`@chevrotain/types`, and `lodash-es` and `uuid@11` are no longer pulled
at
all.

The override goes away once `streamdown` makes `mermaid` an optional
peer of
its diagram plugin instead of a hard dependency.
2026-08-10 12:52:49 +01:00
Eric Allam 90e8bd5c12 feat(webapp,database): opt-in per-client Prisma driver adapters (#4539)
🚀 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
📚 Docs Checks / check-broken-links (push) Has been cancelled
🧭 Helm Chart Prerelease / lint-and-test (push) Has been cancelled
Workflow Checks / Actionlint (push) Has been cancelled
Workflow Checks / Zizmor (push) Has been cancelled
🧭 Helm Chart Prerelease / prerelease (push) Has been cancelled
## What

Adds an opt-in path to run each Prisma client through
**`@prisma/adapter-pg`** (the node-postgres driver) instead of the
built-in engine driver, controlled by a **per-client env var, all off by
default**:

| env var | client |
|---|---|
| `CONTROL_PLANE_DATABASE_WRITER_DRIVER_ADAPTER` | control-plane writer
|
| `CONTROL_PLANE_DATABASE_REPLICA_DRIVER_ADAPTER` | control-plane
replica |
| `RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER` | new run-ops writer |
| `RUN_OPS_DATABASE_REPLICA_DRIVER_ADAPTER` | new run-ops replica |
| `RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER` | legacy run-ops
writer |
| `RUN_OPS_LEGACY_DATABASE_REPLICA_DRIVER_ADAPTER` | legacy run-ops
replica |

With every flag unset the construction path is byte-identical to today
(`datasources` URL + Rust engine), so this is inert until a flag is
turned on. Per-client granularity allows enabling the adapter only where
it's wanted.

## How

- Enables the `driverAdapters` preview feature on both schemas
(`@trigger.dev/database` and `@internal/run-ops-database`). This keeps
the **Rust query engine** — it does NOT add `queryCompiler` — so query
behavior, result types, and engine tracing spans are unchanged.
- A shared `buildDriverAdapterPool` builds each client's `pg.Pool` with
an explicit `max`, a bounded `connectionTimeoutMillis` (the
node-postgres pool otherwise waits unbounded on acquire), and an
`onPoolError` handler (an unhandled idle-connection error would
otherwise crash the process). Threaded through all four client builders
via a `useDriverAdapter` flag.
- Adds `@prisma/adapter-pg` + `@types/pg` to the webapp; `pg` is already
pinned at `8.15.6` (adapter-pg 6.x requires `pg < 8.17`).

## Connect-failure handling (the important correctness/security bit)

Under the adapter an unreachable DB no longer surfaces as
`PrismaClientInitializationError` / `P1001`; it becomes a `P2010`
"Database not reachable: <host>" (or a raw
`ECONNREFUSED`/`ENOTFOUND`-class error). Two handlers are updated so a
client on the adapter behaves like today:

- **`isInfrastructureError`** now recognizes those shapes (P2010 with a
connectivity message, and raw connectivity errno codes). Without this,
the DB **hostname would leak into API-client-facing errors** and the
failure would go unlogged. Security-relevant.
- **`isPrismaRetriableError`** treats the adapter's pool-acquire timeout
("timeout exceeded when trying to connect") as retriable, preserving the
`P2024` retry behavior the adapter otherwise drops.

## Evidence

Validated on an isolated stack that mirrors the production DB topology
(chained PgBouncers in front of writer + reader):

- **Behavioral parity:** raw-query results and Prisma error codes/`meta`
are byte-identical between the engine driver and the adapter across the
queried shapes (unique-constraint `meta.target`, record-not-found,
transaction-timeout, serialization-failure, etc.).
- **Feature matrix:** a full 380-project queue-ay pass shows no
adapter-caused regressions — pass/fail parity between adapter-off and
adapter-on, with the residual failures being pre-existing
known-failures/flakes common to both.

## Rollout / rollback

All flags default off; enable per client via env var, roll back by
unsetting and redeploying (no data migration). Recommended first target
is a single writer; enable one client at a time.

## Follow-ups (not in this PR)

- `$metrics`-based pool observability is removed under the adapter (the
Prometheus route + `db.pool.connections.*` instruments); the metrics
replacement (via `pg.Pool` counters) lands in a separate PR.
- Note for operators: on the adapter path, interactive-transaction
`maxWait` does not bound pool acquisition — `connectionTimeoutMillis`
does.

## Note on connection-string parameters

The adapter pool is built from the base DSN, so Prisma-specific DSN
parameters that node-postgres does not understand are not honored when a
client is on the adapter:

- **Prisma TLS spellings** (`sslaccept`, `sslcert`, etc.) —
node-postgres uses `sslmode`/`ssl` instead. Our production DSNs do not
use these Prisma-specific TLS params, but any deployment whose DSN
relies on them must be checked before enabling a flag.
- `pgbouncer=true` and `statement_cache_size` — effectively moot under
the adapter, which uses no persistent named prepared statements.

`connection_limit`, `pool_timeout`, and `schema` are handled explicitly
(passed as `max`/`connectionTimeoutMillis` and PrismaPg's `{schema}`
option).

refs TRI-13039

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 21:27:20 +01:00
Eric Allam c526528d8f feat(webapp,database): bound Prisma list filter arity (#4480)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
## Summary

Prisma expands `in` / `notIn` into one bind parameter per element, so
every distinct list
length is a separate prepared statement. Where the length tracks data
volume (a batch size,
a run-graph fan-out, a prior query's id set) one call site can mint
hundreds of them. Each
is used about once, but inserting it evicts an entry that was being
reused, so the cost
lands on unrelated queries sharing the pooler's statement cache. An
unbounded list also
risks the 65535 bind-parameter ceiling.

`boundedIn()` pads a filter list to the next power of two by repeating
its last element.
`IN` and `NOT IN` ignore duplicates, so results are unchanged, and a
call site drops from
one statement per length to at most `log2(cap)`. Applied to all existing
sites.

## Enforcement

Two oxlint rules require the helper: a list filter must be an inline
array literal or a
`boundedIn()` call.

- The first covers filters reached through `where` / `having` /
`cursor`, and deliberately
never descends into `data`, `create`, `update`, `set` or `equals`. A key
named `in` in
those positions is user data, not a predicate, and rewriting it would
corrupt what gets
  stored or compared.
- The second covers bare filter objects passed to where-building
helpers, which the first
cannot see. It found five sites in the run-graph batch loaders that were
otherwise
  invisible.

Both rules follow filters through the shapes they are actually written
in: conditional
expressions, logical-and objects, spread-conditional properties,
computed keys, and call
arguments. An array literal only counts as fixed-arity when nothing
spreads into it, since
`[...new Set(ids)]` has a runtime length. Twelve sites were hidden
behind those shapes
until the rules handled them.

Scoped to `in` and `notIn`. The scalar-list filters `hasSome` and
`hasEvery` compile to
`&& $1` and `@> $1`, passing the whole array as a single bind parameter,
so their arity never
reaches the statement text and there is nothing to bound.

Both rules are `error`, so new call sites fail CI. That ratchet has
already caught four
sites added by other PRs while this one was in review.

## Notes

`boundedIn` pads by repeating rather than with null: `x NOT IN (a, b,
NULL)` is never true,
so null-padding a `notIn` filter would silently return no rows. Lists
above 32768 are
returned unchanged so padding can never push a query past the parameter
limit.

Route modules reach the helper through `~/db.server` rather than
importing the database
barrel directly, since a value import of that barrel into a module that
also exports a React
component is only safe while dead-code elimination prunes it.

Measured on a local rig: 300 distinct list lengths produce 300 prepared
statements
unpadded, 10 padded. Verified end-to-end against a local stack with the
full task-suite
sweep, which surfaced no regressions.
2026-08-07 16:39:58 +01:00
Eric Allam 63176a6d69 fix(webapp): stop api inheriting inbound sampled traceparents so trace sampling applies (#4532)
## What

The internal tracing `ParentBasedSampler` in `tracer.server.ts` left
`remoteParentSampled` at its default of `AlwaysOn`. Any request arriving
with a `traceparent` whose sampled flag was set got recorded in full,
bypassing `INTERNAL_OTEL_TRACE_SAMPLING_RATE` entirely. Because the SDK
propagates its (always-sampled) trace context on calls back to the
platform from inside running tasks, the large majority of API server
spans inherited a sampled parent and ignored the divisor. The sampling
knob was effectively inert on the busiest service.

This registers a custom propagator
(`NonInheritingTraceContextPropagator`) that stops adopting the inbound
trace as the parent:

- `inject` still delegates to the standard W3C trace + baggage
propagators, so outbound propagation is unchanged.
- `extract` drops the parent span (`trace.deleteSpan`) while preserving
baggage, so every incoming request roots its own trace and the ratio
sampler applies uniformly.

`remoteParentSampled` is also set to the ratio sampler as a
belt-and-suspenders fallback, in case an inbound sampled parent ever
reaches the sampler another way.

Two effects: the divisor becomes effective on the API server, and the
API no longer stitches onto (and inflates) the propagated task-run
traces, which is where the very large, un-thinnable trace chains came
from. Rooting each request removes those chains rather than only
diluting them.

Only the internal APM trace pipeline
(`INTERNAL_OTEL_TRACE_EXPORTER_URL`) is affected. The user-facing
run-trace pipeline (`otel.v1.traces` -> ClickHouse) is a separate path
and is untouched. The only consumer of the global propagator's `extract`
is the OTel HTTP/Express auto-instrumentation, so the blast radius is
inbound-request trace shape.

## Evidence (local full-stack red/green, divisor 10)

A local OTLP/JSON sink counting spans; a driver fires N requests at a
real endpoint, each carrying a distinct sampled `traceparent`, then
counts how many spans/traces carry that run's marker.

| run | code | sent | kept traces | kept fraction |
| --- | --- | --- | --- | --- |
| before | unmodified | 500 | 500 | 1.00 |
| after | this PR | 500 | 67 | 0.134 |
| after | this PR | 2000 | 213 | 0.1065 |

Before: 100% of inherited-sampled requests kept, divisor ignored. After:
~10% kept (the divisor), converging on it at larger N. In every
after-run each kept request is a single self-rooted trace (kept spans ==
kept distinct traces), confirming the inherited chains are gone, not
just thinned. `typecheck` passes.

## Rollout / rollback

No flag. Behavior stays governed by the existing
`INTERNAL_OTEL_TRACE_SAMPLING_RATE`. Rollback is a straight revert with
no data migration.

## Notes

Internal dashboards that count raw span or request volume from this
pipeline will read lower once this ships. That is expected: those counts
were inflated by the bypass, not a real drop in traffic.
Latency/percentile monitors retain plenty of samples at the current
divisor.

refs TRI-13031
2026-08-07 15:13:42 +01:00
Iss 98cdf89c4f chore: vouch NERLOE (#4531)
Adds [NERLOE](https://github.com/NERLOE ) to the list of vouched outside
contributors so their PRs aren't auto-closed by the vouch check.
2026-08-07 13:54:16 +00:00
github-actions[bot] 72f50c2dad chore: release v4.5.10 (#4440)
🚀 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
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
2026-08-07 14:06:43 +01:00
Eric Allam 7246f677db fix(webapp): strip null bytes from idempotency and debounce keys at trigger (#4527)
## What

A trigger request carrying a Unicode NUL (`U+0000`) in the **idempotency
key** or **debounce key** reached `prisma.taskRun.create()` and failed
the insert, so the caller got an opaque 500 and the run was never
created.

These two keys are stored in `jsonb` columns (`idempotencyKeyOptions`,
`debounce`), and Postgres rejects a NUL inside a `jsonb` value with
`SQLSTATE 22P05` ("unsupported Unicode escape sequence ... cannot be
converted to text"). This fix strips the NUL from both keys at the
single trigger-input chokepoint (`#buildEngineTriggerInput`), which
every trigger path flows through (single, batch item, mollified, and
drainer replay).

Stripping matches the existing precedent for run errors and task events.
It does not change dedup behaviour: the idempotency **dedup identity**
is the hashed key (a clean 64-char digest), computed independently of
the raw key we clean, so dedup keeps working exactly as before. For
debounce the key is used directly, so the cleaned key also becomes the
grouping key, an acceptable change for input that is already malformed.

## Why not payload / metadata / tags

Those are `text` columns fed by `JSON.stringify`, which escapes a NUL to
a safe escape sequence, so they do not hit this failure on the normal
JSON path. (A raw NUL in a `text` column throws a different code,
`22021`, and is not what triggers this issue.) The observed failures are
the `jsonb` `22P05` variant, which is only reachable via the two key
fields.

## Evidence

Red then green (containerTest, real Postgres): with the fix reverted,
triggering through the real service with a NUL in
`idempotencyKeyOptions.key` / `debounce.key` fails with the exact
`22P05` signature; with the fix, the run is created and the stored key
has the NUL removed.

Full-stack e2e (isolated stack, real HTTP): `POST
/api/v1/tasks/:taskId/trigger` with a NUL inside
`idempotencyKeyOptions.key` (`"acme<NUL>inc"`) and, separately,
`debounce.key` (`"grp<NUL>1"`):

- both returned `HTTP 200` with a created run (previously `500`)
- stored `idempotencyKeyOptions` = `{ "key": "acmeinc", "scope": "run"
}` (7 chars, NUL removed)
- stored `debounce.key` = `"grp1"` (4 chars, NUL removed)
- both runs render in the dashboard

Unit tests cover the helper (strip, no-op fast path, object-reference
reuse, null/undefined pass-through).

## Rollout / rollback

Server-only webapp change, no flag. Zero behaviour change for clean
input; only affects inputs that previously 500'd. Rollback is a straight
revert, no data migration.

## Known limitation

A raw NUL in a plain-string idempotency key (not created via
`idempotencyKeys.create()`) lands in a `text` column and throws `22021`
instead. That variant is not addressed here because stripping it would
change the dedup identity, so it warrants a separate decision. Not
observed in practice.

refs TRI-13030
2026-08-07 13:28:52 +01:00
claude[bot] dc529414df feat(webapp): add /_/* redirect route (#4523) 2026-08-07 13:21:07 +01:00
Chris Arderne 0a44b88b39 fix: security release 2026-07-21 (#4528) 2026-08-07 12:25:40 +01:00
Eric Allam db67a856fe perf(webapp,database): index the newest-task-version lookup (#4518)
📦 Preview packages (pkg.pr.new) / Build and publish previews (push) Has been cancelled
📚 Publish docs / publish (push) Has been cancelled
Implementing PlanetScale Insights improvement.

## Summary

Validating a schedule (creating or updating one through the API or the
dashboard, and deploying a project that declares schedules) looks up the
newest version of a task by slug. That lookup reads *every* version of
the task and sorts them to return one. A project gains a row per task on
every deploy, so the work grows with the project's age: the oldest
projects pay the most, and dev-mode redeploys make it worse. This was
picked because it was the largest single consumer of database time on
the schedules path, and the fix is a sort key with no index behind it.

## Fix

`BackgroundWorkerTask` is indexed on `(projectId, slug)`, which serves
the equality but not the `ORDER BY createdAt DESC`. Postgres seeks the
index, then bitmap-scans and top-N sorts the whole group to produce a
single row. Adding `createdAt` to the index lets it scan backward and
stop at the first row.

The same call site also selected all 21 columns, including five JSON
blobs, to read one field (`triggerSource`), so it now selects that field
alone.

## Benchmark

Local Postgres 17, 997,000 seeded rows / 748 MB, group sizes chosen to
match the distribution seen in production.

| Group size | Before | After |
| --- | --- | --- |
| 15,000 versions of one task | 11.118 ms, 1,510 buffers, 15,000 rows
scanned | 0.027 ms, 4 buffers, 1 row |
| 2,000 versions of one task | 2.081 ms, 1,455 buffers, 2,000 rows
scanned | 0.022 ms, 4 buffers, 1 row |

```
before:  Limit -> Sort (top-N heapsort) -> Bitmap Heap Scan
after:   Limit -> Index Scan Backward using BackgroundWorkerTask_projectId_slug_createdAt_idx
```

An ascending index scanned backward is enough here, so no descending
index is needed.

## Impact and risk

Real-world gain lands between the two rows above and scales with how
many deploys a project has accumulated. Projects with few deploys will
see little change, since there is barely anything to sort.

The new index costs noticeably more than the existing two-column one: 43
MB against 7.3 MB on the benchmark rig. Adding `createdAt` makes every
key unique, which defeats btree deduplication, so this is a real disk
and write cost rather than a rounding error. Writes to this table happen
at deploy time, not on the run path, so the write amplification is
acceptable. The existing `(projectId, slug)` index is now a redundant
prefix and could be dropped, but this PR keeps it so index usage can be
observed before removing it.

Behavior is unchanged: same predicate, same ordering, same row returned.
The narrowed select is the only code change, and the field it keeps is
the only one the caller read.

Deploy note: the migration is
`20260806100000_add_background_worker_task_project_id_slug_created_at_index`
and uses `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, so it can be
pre-applied by hand before the deploy.
2026-08-07 11:17:10 +01:00
Eric Allam 6c6e58e6ff perf(webapp): batch declarative schedule cleanup queries (#4522)
## Summary

`syncDeclarativeSchedules` runs on every background-worker creation
(every deploy, and every file save during `trigger dev`). It issued one
instance-delete per declarative schedule the current worker no longer
declares, in a loop, and the overwhelming majority of those deletes
matched zero rows. This collapses the loop into at most two set-based
statements and skips the instance delete entirely when the current
environment owns no instance of the schedule.

## Why so many, and mostly no-op

The loop runs once per entry in `missingSchedules`, which starts as
every DECLARATIVE schedule for the whole project across all its
environments (the query filters only by `projectId`). A schedule leaves
that set only when a declared task matches it by `taskIdentifier`
**and** the schedule already has an instance in the current environment.

That last clause is the amplifier. When a task's schedule has no
instance in the current environment, the create branch inserts a
brand-new `TaskSchedule` row with an instance for this environment
rather than adding an instance to the existing row. So the same
scheduled task, once it has run in dev and been deployed to prod, exists
as two separate schedule rows: one carrying a dev instance, one carrying
a prod instance.

On a dev worker sync of that project:

- the dev-instance row matches the declared task and is removed from the
set
- the prod-instance row has the same `taskIdentifier` but no dev
instance, so it stays in the set and gets `deleteMany(taskScheduleId =
prodRow, environmentId = dev)`, which matches zero rows

So every declarative task that has been synced in another environment
contributes one guaranteed no-op delete per sync, and the count scales
with (declarative tasks x environments), plus any leftover rows from
renamed or removed tasks. A project does not need to have dropped a
schedule to generate these; it just needs the same declarative tasks
present in more than one environment, which is the normal
develop-in-dev, deploy-to-prod case.

## Fix

The candidate schedules are already loaded with their instances, so the
branch is decided in memory:

- schedules with no instances (or only current-environment instances)
are removed in a single `taskSchedule.deleteMany`
- schedules that still have another environment's instance have only the
current environment's instance detached, in a single
`taskScheduleInstance.deleteMany`, and only when such an instance
actually exists

Behavior is unchanged (cascade delete still removes the instances of a
deleted schedule); the difference is statement count. A zero-row delete
writes no WAL and creates no dead tuples, so the removed work was pure
query and commit overhead.

Verified with a testcontainer test (red before, green after) counting
the emitted deletes across the no-op, batched-detach, and
schedule-delete cases, and end to end through `trigger dev`: three
declarative schedules created, surviving a re-sync, then two removed in
a single batched delete with the third preserved.
2026-08-07 10:27:39 +01:00
Matt Aitken 04f9c4e1a5 fix(webapp,run-engine,core): drop the hidden debounce ceiling, fail fast on an unusable maxDelay (#4521)
Debouncing with a `delay` longer than an hour did nothing at all.

The engine applied a server-side ceiling on how long a debounced run
could be pushed back, measured from the run's `createdAt` and defaulting
to one hour. A run is only pushed back while its new execution time
stays inside that ceiling, so a `delay` at or above it could never push
anything: the waiting run was released, the trigger started its own run,
and the next trigger repeated it. A `delay: "12h"` produced one run per
trigger, each correctly delayed by 12h, with no error raised and nothing
on the run to show the debounce key had been ignored.

The ceiling is now unset by default. A debounce key with no `maxDelay`
keeps collapsing triggers for as long as they keep arriving, which is
what the docs have always described. Self-hosters who want a bound can
still set `RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS`.

That has a consequence worth stating plainly, so the docs now carry a
warning for it: with no `maxDelay`, a continuously triggered key never
executes. Set `maxDelay` when the work has to happen eventually.

**Failing fast on an unusable `maxDelay`.** A caller who sets `maxDelay`
no longer than their `delay` hits exactly the dead end described above,
so that pair is now rejected at trigger time instead of silently
behaving as if no debounce were set:

```
debounce.maxDelay (1h) must be longer than debounce.delay (12h). A debounced run is only
pushed back while it stays inside maxDelay, so with these values every trigger would create
its own run.
```

An unparseable `maxDelay` is rejected too, rather than quietly falling
back to no bound at all, and so is a `delay` given as a date rather than
a duration, which could never work because the value is re-applied on
every push.

The same check runs against a configured server ceiling, so a
self-hosted deployment that sets
`RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS` gets the error rather than the
silent failure this PR is about. With no `maxDelay` and no configured
ceiling, which is the default, there is nothing to conflict with and
nothing is rejected.

The docs, the `TriggerOptions` JSDoc and the engine option all now state
that the room available to push is the gap between `delay` and
`maxDelay`. The run engine suite gains the case that motivated this:
four triggers on one key with a 12h delay now collapse to a single run.
2026-08-07 07:55:35 +00:00
Matt Aitken c084fa6e29 fix(sdk,react-hooks): forward debounce when batch triggering with an array (#4520)
Passing `debounce` in the per-item options of a batch trigger did
nothing when the items were an array. The option was accepted by the
types and by the API, then dropped before the request went out, so every
item created its own run instead of collapsing onto the debounce key.

Four public entry points were affected: `task.batchTrigger`,
`task.batchTriggerAndWait`, `tasks.batchTrigger`, and
`tasks.batchTriggerAndWait`. The streaming (async iterable) forms of the
same calls were already correct, as were `batch.trigger`,
`batch.triggerAndWait`, `batch.triggerByTask`, and
`batch.triggerByTaskAndWait`.

`useTaskTrigger` in `@trigger.dev/react-hooks` had the same silent drop
on the single-trigger path, so that is fixed here too. It also drops
`machine`, `priority`, `region`, `idempotencyKeyTTL`, and
`idempotencyKeyOptions`; those are left alone, since forwarding them is
a behaviour change beyond this bug.

Each batch item builder constructs its options field by field, which is
why one of them could fall behind without anything catching it.
TypeScript did not help: the literal is returned from a `.map` callback
inside `Promise.all`, so excess-property checking never fired against
the `BatchItemNDJSON[]` annotation, and the server's schema silently
strips unknown keys. A misspelled option name therefore reproduced this
bug with no compile error and no server error. Every builder now ends in
`satisfies BatchItemNDJSON`, which does catch it:

```
error TS2561: Object literal may only specify known properties, but 'debounceTYPO'
does not exist in type '{ ... debounce?: {...} | undefined; }'.
Did you mean to write 'debounce'?
```

The new test drives all six public batch surfaces in both array and
async-iterable form and asserts on the NDJSON that actually reaches the
wire. Each item carries a distinct debounce key so the test catches a
wrong item-to-option pairing, not just a wholesale drop.

Fixes #3304
2026-08-06 16:54:24 +01:00
Matt Aitken f8e1c910f7 docs(ai-chat): guide for migrating an AI SDK route handler to chat.agent (#4519)
## Summary

Adds a docs page for developers who already have a working Vercel AI SDK
chat app (`useChat` on the client, an `app/api/chat/route.ts` calling
`streamText`) and want to move it to `chat.agent`. There was no page
covering that path. `ai-chat/upgrade-guide` reads like it should be the
one, but it covers moving prerelease `chat.agent` code to the Sessions
release, which is a different reader.

The page is structured around what stays, what goes, and what is new,
because the reassuring part of this migration is how much is untouched:
the `streamText` call, model config, tool definitions, `useChat`, and
all message rendering carry over as-is. What gets deleted is the route
handler, the persistence glue wired into it, and any resumable-stream
setup. What is new is the agent task, two server actions, and
`useTriggerChatTransport`.

Covers moving tools onto the agent config so `toModelOutput` survives
past turn one, where existing database persistence goes
(`hydrateMessages` plus the turn hooks), a short section on what
durability you get once you are across, a note that
Hono/SvelteKit/Express follow the same shape, and a gotchas list built
from the mistakes this specific migration produces.

## Head Start

The one thing this migration makes worse is the opening response of a
new chat. The route handler answered out of a warm process; the agent
run has to be dequeued and booted first. That is the complaint the page
has to answer head on, so Head Start gets a full section rather than a
closing aside, plus a callout up top next to the "what changes" table so
nobody plans the migration without knowing it exists.

The section walks the four steps: splitting tool schemas away from tool
executes (the bundle-isolation constraint the whole feature rests on),
building the handler, mounting it back at `app/api/chat/route.ts` with
the original auth check wrapped around it, and the transport option.
Both server actions stay, because Head Start only owns the first turn.
Three gotchas go with it: a slow first turn without Head Start, Head
Start on but the route bundle still heavy, and the route timing out
because the handler holds the SSE response open for the whole turn
rather than just step 1.

The coding-agent prompt names Head Start as explicitly out of scope, so
an agent handed the migration does not attempt the tool split
unprompted.

Also fixes the `chat.headStart` example on `ai-chat/fast-starts`, which
set `stopWhen: stepCountIs(15)` after the spread.
`toStreamTextOptions()` pins `stopWhen` to `stepCountIs(1)`, so
overriding it makes the warm handler run steps the agent is supposed to
own (and `stepCountIs` was never imported in that snippet either).

## Migration prompt

The page also ships a copy-pasteable prompt for handing the migration to
a coding agent. It tells the agent to run `npx trigger.dev@latest
skills` first, so it picks up guidance version-pinned to the SDK
actually installed in the project, then read `quick-start.md`,
`frontend.md`, and `reference.md` (with `llms.txt` as the index) before
editing anything. The instructions are explicit about preserving the
existing model, prompt, and tool schemas rather than rewriting them.

Registered in `docs.json` under Agents, directly after Quick Start, so
it is picked up by the generated `llms.txt` and the per-page `.md`
variants.
2026-08-06 17:37:23 +02:00
Chris Arderne 088f68b373 feat(webapp): share rate limit bucket across additional API keys per environment (#4508)
## What

Rate-limit the API by **environment** rather than per API key.

Previously the limiter keyed its bucket on the hash of the full
`Authorization` header — one bucket per key. With additional environment
API keys (`tr_*_sk_*`), an environment can mint many keys and each got
its own full bucket, so more keys = higher effective rate limit. This
collapses all of an environment's keys onto a single shared
per-environment bucket, so the ceiling is exactly the configured limit
regardless of key mix.

## How

- `authorizationRateLimitMiddleware` now lets the override return `{
config?, identifier? }`. `identifier`, when present, is the rate limit
bucket key; otherwise it falls back to the hashed `Authorization` header
(unchanged legacy behavior, still used by `engineRateLimiter` and any
unauthenticated fallthrough).
- `apiRateLimiter`'s override resolves the environment id and uses it as
the identifier:
- **Additional keys** (`isAdditionalApiKey`) resolve via a new
`resolveAdditionalApiKeyRateLimitScope()` — a **scope-agnostic** keyHash
→ (environmentId, org limiter config) lookup. It is deliberately
permissive (restricted keys resolve too) because it's used **only for
bucketing, never as an auth decision** — request auth still goes through
the RBAC bearer controller, which enforces scopes. Revoked/expired keys
are excluded so they can't hold a bucket warm.
- **Root/legacy keys** reuse the environment already resolved by
`authenticateAuthorizationHeader` and key on `environment.id` too.
- The identifier is always the stable environment id, never the secret
key (which can rotate and would split the bucket).
- The whole override result is cached per key by the existing SWR cache,
so **no extra per-request lookup and no separate Redis mapping** is
added.

## Behavior notes

- Root + additional keys of the same environment now share one bucket
(ceiling = configured limit, not a multiple of it). Restricted
additional keys are included — they were the biggest gap, since they
authenticate via the RBAC controller and previously fell back to per-key
buckets.
- **Public JWTs** keep their existing fixed-window, per-token bucketing.
- One-time bucket reset on deploy (bucket keys change); harmless.

## Tests

- New: two tokens resolving to the same identifier share one bucket.
- New: with no identifier, bucketing stays per-key (legacy behavior
preserved).
- Updated existing override tests to the new `{ config }` return shape.

Base: `feat/multi-keys-surface`. Closes TRI-12888.
2026-08-06 16:05:27 +01:00
Chris Arderne 9409ddf9bc feat(webapp): add multiple environment API key management (#4390)
## Summary

Projects can create, inspect, expire, and revoke multiple API keys for
each environment. Plaintext values are shown only at creation; stored
credentials are hashed and the API keys page displays only an obfuscated
suffix afterward.

Self-hosted installations support full-access additional keys by
default. Authorization extensions can provide additional access presets
and optional task selection. Additional keys can also mint scoped public
access tokens through the Trigger.dev API without receiving the
environment signing key.

## Feature notes
- Only admin+ can create API keys (Developer can make in Development
branch).
- JWT self-signing will be a server call when used with new `_ak_` keys.
- JWTs with long expiry can keep working even with api key deleted (gets
priveleges from api key, signed with root key)
- Unfiltered session listings intentionally preserve the existing broad
task-read behavior. Filtered listings enforce task-level scopes for
every requested task.
- Buffered runs without a task identifier are not safely authorizable,
so cancel/replay requests fail closed rather than resolving an unscoped
run.
- Batch and waitpoint endpoints intentionally return server-minted,
narrowly scoped public tokens to all callers. These tokens have bounded
lifetimes and may remain valid until expiry after API-key revocation.

## Deployment notes

Deploy the management UI and public-token endpoint with new key creation
disabled. Enable creation for selected organizations after the
authentication path and released SDK have been verified, then expand
availability gradually.

Revoking an API key prevents new bearer requests and new token minting.
Public tokens already minted by that key remain valid until their own
expiration because they are signed by the environment signing key.

## TODO
- [x] Add "Created by" to the key table
- [x] Document that streamed batch ingestion is non-atomic and may
 partially accept items before a validation or authorization error.

## Follow-ups

- [x] Add an organization-level feature flag for the API key management
UI and creation action.
- [x] Document rollout ordering: enable additional-key lookup before
enabling issuance.
- [x] Add a system-wide gate that can stop new key issuance without
disabling authentication for existing keys.
- [x] Replace the generic SDK compatibility warning with the first
published compatible version. Old SDK will mint an unusable token if
given an `_ak_` key.
- [x] Add public documentation covering creation, storage, expiration,
revocation, SDK compatibility, and public-token lifetime behavior.
- [x] Add observability for key creation, revocation, policy preparation
failures, and public-token mint failures.
- [ ] Exercise create, copy-once display, authenticate, mint, expire,
and revoke flows end to end before broad enablement.
2026-08-06 15:27:10 +01:00
Katia Bulatova 337dda1e97 feat(webapp): name of the page in tab titles (#4517)
Adds a shared `pageMeta()` helper and 74 route declarations, so a title
reads `run_abc | Runs | Trigger.dev` — the specific thing first, then
the page. Org pages also carry the organization: `Team | Acme |
Trigger.dev`. Inside a project no scope is added, because the dashboard
switches projects in every tab at once.

Page names are unchanged; what's new is that a page says which one it is
at all. Three wording changes on purpose: the queue page now names the
queue, the model page names the model, and entity pages carry their
section.
2026-08-06 10:34:30 +02:00
Wes Mason 66940c0384 fix(observability-map): narrow the required check and the report bot's comment lookup (#4507)
## Findings addressed

- **Report bot edited the wrong comment.** The comment-lookup step
matched on the marker body text with no author predicate, so it would
silently PATCH a human's comment that happened to quote the marker
(GitHub gates comment editing on write access, not authorship, so it
never 403'd). Now constrained to `.user.login == "github-actions[bot]"`,
the same identity `helm-prerelease.yml` already pins.
- **A required check asserted facts about the whole webapp namespace.**
`webappSymbols.test.ts` asserted that nobody anywhere in `apps/webapp`
(walking locals, params, object keys) declares names like
`createJWT`/`updateEnvVars`, so an unrelated PR naming a local variable
failed a required check with a message pointing at nothing. Those
negative self-tests move onto a package-owned fixture tree; the positive
resolution assertions stay required (their absence rotted the tool
before) but now name the list to edit.
- **The suite ran twice on shared paths.** `obsmap` and `internal` path
filters shared four generic paths (`package.json`, both lockfiles,
`pr_checks.yml`), so any lockfile bump ran the observability-map suite
in both jobs. Dropped from `obsmap` (where `internal` already covers
them). The test that should have caught it only checked the package's
own source path; it now asserts the two filters' path intersection is
empty.
- **PR-comment footer** reworded: it said the report gates nothing,
which is true of the report but misled now that the tool's test suite
does gate webapp PRs. Names both failure directions and where to read
the rules.
- **Nightly corpus** comment corrected (stale entry count; the
failure-notification gap is documented, not silently implied).

## Review

Two adversarial reviewers ran over the diff; both findings were verified
and fixed: a hollow fixture assertion (a shared name satisfied either
walker branch — now one name per declaration form, revert-confirmed) and
a filter-intersection test that could be fooled by apostrophes in
comment prose (now strips comment lines first). Full package suite green
(877 passed), typecheck and format clean.
2026-08-05 22:36:19 +01:00
Eric Allam b20806247f fix(run-store): stop run-create failing on a brief write stall (#4514)
## Summary

On the run-ops store, creating a run could intermittently fail with a
"Transaction already closed" error, and the run would never be created.
Single-write run creates no longer run inside an interactive
transaction, so a brief database write stall can't blow the transaction
budget and drop the run.

## Fix

The dedicated run-ops `createRun` / `createFailedRun` wrapped a single
nested `taskRun.create` in an interactive `$transaction`. Its default 5s
budget is wall-clock from `BEGIN`, so when a write briefly stalls the
transaction expires before the create completes and throws, even though
the statement itself is fast at the database.

A single-write create does not need an interactive transaction: Prisma's
implicit nested create is already atomic and holds no app-side budget,
so it now runs directly. Only the `triggerAndWait` path (run plus its
associated waitpoint, two writes that must commit together) keeps an
interactive transaction, now with headroom over the default.

Verified with a red/green test against the real split topology
(reproduces the exact expiry on the unchanged code, green after) and an
end-to-end run created and completed through the dedicated store.
2026-08-05 17:35:46 +01:00
Eric Allam 58bf4e2833 feat(webapp): per-client database pool and connect timeout overrides (#4515)
## Summary

Follow-on to #4513. The database connect timeout is now honored, but a
single global value has to serve three separate databases at once
(control-plane, legacy run-ops, and run-ops). This adds optional
per-client overrides for the Prisma pool and connect timeouts, one pair
for the writer and one for the read replica of each of the three
databases, each falling back to the shared `DATABASE_POOL_TIMEOUT` /
`DATABASE_CONNECTION_TIMEOUT` when unset.

That lets one database's clients run a fail-fast connect timeout (with a
bounded pool wait) while another keeps more headroom, without a single
knob forcing the same tradeoff everywhere. No behavior change until an
override is set.

It also tags each client's queries with its specific datasource
(`control-plane` / `legacy-run-ops` / `run-ops`, writer or replica) via
the `db.datasource` span attribute, so telemetry can attribute
connection behavior to a specific database instead of just
writer-vs-replica.
2026-08-05 17:26:41 +01:00
Chris Arderne 1a16d61a37 fix(build): support decorator metadata with TypeScript 7 (#4505)
## Summary

Allow projects using TypeScript 7 to enable `emitDecoratorMetadata()`
without adding the TypeScript 6 compiler to every Trigger.dev CLI
installation. Addresses #4500.

## Fix

The extension now resolves TypeScript from the project and
feature-detects the legacy compiler API. TypeScript 5 and 6 continue
using the project's compiler, while TypeScript 7 projects can install
Microsoft's optional `@typescript/typescript6` compatibility package
alongside TypeScript 7.

When no compatible compiler API is available, the build reports an
actionable installation error. The extension documentation includes
setup commands for npm, pnpm, and Bun.

Verified with TypeScript 5, TypeScript 6, TypeScript 7 with and without
the compatibility package, emitted decorator metadata, packed ESM and
CommonJS consumers, package export checks, and typechecking.
2026-08-05 16:33:32 +01:00
Eric Allam 771937adf5 fix(webapp): clamp run priority so a large value can't fail run creation (#4512)
## Summary

Triggering a run with a very large `priority` could fail run creation
outright with an opaque database error. `priority` is multiplied by 1000
and stored in a 32-bit integer column, with nothing bounding it, so a
big enough value overflowed the column and the create failed. The
trigger now caps the value to the highest supported priority instead of
erroring, so the run is still created.

## Fix

`priorityMs` (the stored `priority * 1000`) now goes through a
`clampPriorityMs` helper before the write. It rounds to a whole number
and clamps into the column range at both ends, so only a valid integer
ever reaches the column and an out-of-range priority caps rather than
failing. Single and batch triggers share the write path, so both are
covered.
2026-08-05 16:28:22 +01:00
Eric Allam 3039bc14d6 fix(webapp): honor the configured database connect timeout (#4513)
## Summary

Every Prisma client built its connection URL with a `connection_timeout`
query param, but the Postgres connector's parameter is
`connect_timeout`. The misspelled param is silently ignored, so all
clients fell back to Prisma's 5s default instead of the configured
timeout. When establishing a new connection briefly took longer than 5s
(for example during connection spikes), it failed with `Can't reach
database server` even though the database was healthy.

## Fix

All four client builders now construct their connection URL through one
shared helper (`buildPrismaConnectionUrl`) that sets `connect_timeout`,
so the configured value actually applies, and the parameter name lives
in exactly one place. Covered by a unit test.
2026-08-05 15:52:12 +01:00
Chris Arderne 85f5b37c68 chore: upgrade to TypeScript 7 (#4318)
## Summary

Upgrade the monorepo to TypeScript 7.0.2 and update package build
tooling for compatibility with the native compiler.

## Design

Package builds now use `tshy` 4, while the packages still using `tsup`
move to `tsdown`. The few scripts that depend on the legacy TypeScript
compiler API use an explicit TypeScript 6 alias; declaration portability
coverage invokes the TypeScript 7 CLI directly.

Turbo is updated so workspace tasks can read the regenerated pnpm
lockfile.

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-05 15:49:44 +01:00
nicktrn c01a4f18f4 feat(supervisor): cancel a resumed run's in-flight checkpoint (#4502)
A run controller must call the continue route to resume, so the
supervisor already knows synchronously that any checkpoint still running
for that run is pointless. It only acted on that for the compute path.

The continue route now cancels it for the Kubernetes path too, matching
what completion already does since #4493. Called after the reply so the
runner is never delayed, and skipped when there is no checkpoint client
or when the compute path owns the run. The request is bounded by a 5s
timeout so a hung call cannot leave the handler pending.

`checkpoint_cancel_requests_total{result}` records the outcome, using
the same label names as the delete path where they overlap: `sent`,
`no_client`, `not_applicable`, `http_error`.

No changeset: `CheckpointClient` is a server-only internal API, same as
#4493.

refs TRI-12915
2026-08-05 12:01:14 +01:00
Wes Mason ca9a74e84a feat(observability-map): static observability scorer for webapp route entry points (#4455)
A static observability scorer for the webapp's route entry points,
Lighthouse-style. The idea comes from evlog's `map` command, but that
tool has no Remix adapter and checks for its own logging API, so the
idea is ported rather than the tool.

It scans all 427 loader/action entry points in `apps/webapp/app/routes`
with the TypeScript compiler API and scores each against five checks:
error-classification, auth-boundary, auth-scope, request-context and
audit-trail. Current output on the real tree is **19/100** over 412
measured entry points.

```
cd internal-packages/observability-map
pnpm exec tsx src/cli.ts               # terminal report
pnpm exec tsx src/cli.ts --json        # machine output
pnpm exec tsx src/cli.ts api/v1/token  # one entry, per-check detail
```

The two findings at the top of the fix list are real: `/auth/sso` and
`/api/v1/authorization-code` mint or exchange credentials
unauthenticated, and `/_app/orgs/:organizationSlug/settings/team`
resolves its org from a URL slug and gates each mutating branch on an
RBAC check alone, which per `apps/webapp/CLAUDE.md` is not the tenant
floor on self-hosted.

Decisions worth knowing, all with the reasoning in the README:

- The score started at 83 during development and fell to 19. Every drop
was a perverse incentive being removed, not a regression: routes were
being paid for having no error handling, two checks were reading the
same fact, suppressing a failure raised the score, and a no-op `catch
(e) { throw e }` was worth 50 points a route.
- **A mutation corpus is the tool's main defence.** 44 entries apply
semantics-preserving edits to a copy of the real route tree and assert
the score cannot rise, per route as well as globally, because a mean can
hide one route going up by taking another down. One entry runs as a live
expected failure: `try { String(0); }` with a deciding catch is a known
open hole worth 19 to 44, and it is disclosed rather than quietly
excluded.
- `audit-trail` and `request-context` are reported as headline figures
rather than one finding repeated hundreds of times. Both still count in
full where they should.
- A cohort change moves the number without anything in the codebase
getting better. Widening the sensitive cohort from 26 to 67 took the
global from 15 to 19 with no webapp change at all, so the report prints
per-check applicability and what the global would be without each one.

CI: a report-only job posts a sticky comment when a PR moves the report,
and says nothing when it does not. The package's own tests gate through
`pr_checks.yml`. The diff-scoped merge gate is still deferred until the
report has been used in anger.

524 tests plus the corpus. No runtime or dependency changes to anything
that ships.

<!-- GitButler Footer Boundary Top -->
---
This is **part 1 of 4 in a stack** made with GitButler:
- <kbd>&nbsp;4&nbsp;</kbd> #4485
- <kbd>&nbsp;3&nbsp;</kbd> #4484
- <kbd>&nbsp;2&nbsp;</kbd> #4483
- <kbd>&nbsp;1&nbsp;</kbd> #4455 👈 
<!-- GitButler Footer Boundary Bottom -->
2026-08-04 15:33:32 +01:00
nicktrn 4f69c43e6b feat(supervisor): reclaim a run's checkpoint storage when it finishes (#4493)
When a run reaches a terminal state, ask the checkpoint service to
reclaim the storage its checkpoints occupied. Storage for finished runs
is not otherwise reclaimed, so nothing frees it today.

**Off by default** behind `DELETE_CHECKPOINTS_ON_COMPLETION`, and the
service-side handler ships separately, so merging this changes no
behaviour.

## Where the tenancy comes from

Addressing a run's checkpoints needs org, project, environment,
deployment version and run id. All five are already in hand at
`attempt.complete`, and three are **signed** by the deployment token:

| Value | Source | Trust |
| -- | -- | -- |
| org | claim `org_id` | signed |
| environment | claim `environment_id` | signed |
| deployment version | claim `deployment_version` | signed |
| project ref | `x-trigger-workload-project-ref` header |
runner-supplied |
| run | route param | runner-supplied |

`authorizeWorkloadRequest` previously returned only `environment_id`,
and only in enforce mode, so it now also returns the verified `claims`.
That difference is deliberate and documented on the method: claims are
used to address a run's **own** resources locally, never to scope the
platform, which is why `environmentId` stays enforce-only.

The two runner-supplied values are safe because the signed ones are
outermost - a runner lying about either can only name something inside
its own org and environment, and a project ref that doesn't pair with
its signed environment matches nothing. The run id is read from
`params.runFriendlyId`, the same value the platform just validated,
rather than from the body or a header. Where both a claim and a header
exist (`deployment_version`), the claim wins.

## Placement

The call sits after `reply.json(...)`, so the runner sees no added
latency - the same shape the suspend route already uses. The service
enqueues and returns 202, so it is one fast local hop.

Terminal means `RUN_FINISHED` **or `RUN_PENDING_CANCEL`** - a run
cancelled mid-execution never restores, and skipping it would leave its
storage behind. Retries are excluded deliberately: reclamation is
per-run, so a retry is covered by the final completion.

Also gated on `!snapshotService`, so it stays inert where checkpoints
aren't the kind this reclaims.

## Observability

`checkpoint_delete_requests_total{result}` counts `sent` **and every
reason we decide not to send**: `disabled`, `not_terminal`, `no_claims`,
`no_project_ref`, `http_error`.

The negative labels are the point - without them, "no requests are
happening" looks identical to the feature being switched off.
`no_claims` is reachable even under enforcement, since enforce only
rejects a *present-but-invalid* token; an absent or legacy id still
passes with no claims attached.

## Notes for review

- **No changeset**: `CheckpointClient` is `core/v3/serverOnly`, an
internal service-to-service API rather than customer-facing surface.
- **No `.server-changes/` note**: there is nothing a dashboard user
would notice here. Happy to add one if you disagree.
- `pnpm run typecheck` can't complete in my checkout -
`@trigger.dev/database` fails to build on a missing `tsc` in the pnpm
store, unrelated to this diff. Verified with `tsc --noEmit` against the
supervisor project instead: **zero errors in `apps/supervisor/src`**.
Worth noting it caught a real bug here - the completion response is
wrapped, so the status is `data.result.attemptStatus`.

refs TRI-12789
2026-08-04 15:01:29 +01:00
Matt Aitken e8398d13be chore: vouch Rohan170603 (#4501)
Adds `Rohan170603` to the list of vouched outside contributors so their
PRs aren't auto-closed by the vouch check.

Closes #4498
2026-08-04 13:22:24 +00:00
Katia Bulatova fbd6df33b4 feat(webapp): Themes + contrast settings update (#4206)
Adds System Preferences, Dark and Light themes, gated by the
`hasThemeSwitcher` feature flag (off by default — dark stays the default
theme for everyone).

Old theme is now "Classic"and set as default. 
"System preferences" theme has both Light and Dark modes and uses your
laptop settings to use a correct one.
It has less color accents (specifically less colored text), and they are
the same for both modes, only grayscale values change between them. And
Light/Dark themes can be used separately.

New Contrast setting is available for System Preferences, Dark and Light
themes - it changes the contrast for the whole app. All new visual
Settings live in Account.
2026-08-03 19:29:33 +02:00
Eric Allam 57254b57fb fix(webapp): make prop-types a production dependency (#4492)
## Summary

The webapp's server bundle imports `prop-types` directly, but the
package was declared only as a `devDependency`. A production install
therefore leaves it out and the built server fails to boot:

```
Failed to start server: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'prop-types'
  imported from /triggerdotdev/apps/webapp/build/server/assets/server-build-*.js
```

Moving it to `dependencies` is the whole change.

## Why the bundle imports it

Nothing in the webapp's own code uses `prop-types` — there is no
reference to it, or to `PropTypes`, anywhere under `apps/webapp/app`. It
arrives through `recharts`, whose `react-smooth` dependency still
declares `propTypes` on its components.

That was invisible until recently. While `recharts` was resolved at
runtime, its `prop-types` import was satisfied inside `recharts`' own
dependency tree, which is production all the way down. #4486 added
`recharts` and `victory-vendor` to `ssr.noExternal` to fix a hydration
mismatch on every server-rendered chart; that inlines `react-smooth`
into the server bundle, which moves its `prop-types` import into the
webapp's own resolution scope — where the package was not available in
production.

So the bundling change was correct about *which* d3-shape build both
sides resolve, and wrong about what the production runtime would be able
to find.

## Verification

`docker/Dockerfile` builds the runtime dependencies with `pnpm install
--prod` against a `turbo prune --scope=webapp --docker` output, so I
reproduced exactly that: pruned the workspace, installed with `--prod`,
and imported `prop-types` from `apps/webapp`.

| | result |
| -- | -- |
| `main` as it stands (devDependency only) | `FAILS:
ERR_MODULE_NOT_FOUND` |
| with this change | `prop-types resolves OK` |

It resolves both as a CommonJS `require` and as an ESM `import`, which
is the form the bundle uses.

I also checked this is not one symptom of a wider problem: of the 169
bare specifier roots the server bundle imports, `prop-types` is the
**only** one that is a devDependency and not a production dependency.
The rest are node builtins or production dependencies.

The hydration fix from #4486 is unaffected — the rebuilt bundle still
carries the rounding d3-path build.

## Notes

`prop-types` is inert in production (its entry point swaps in
`factoryWithThrowingShims`), so this adds a 124 KB package that does no
work at runtime. It has to be resolvable regardless, because the import
is real.

An alternative would be adding `prop-types` to `ssr.noExternal` so it is
inlined and needs no runtime resolution. That keeps the dependency list
honest about the fact that the webapp itself does not use it, at the
cost of bundling a CommonJS package into the ESM server output. This
route is the smaller, better-understood change.

Worth following up separately: a check that every bare import in the
server bundle resolves from a production install would have caught this
before it landed. Local development installs every devDependency, so the
gap is invisible when the built server is run from a working tree.
2026-08-03 16:09:03 +00:00
nicktrn 3fba04573d fix(supervisor): hold the last backpressure verdict when a read fails (#4444)
The dequeue brake released the moment its signal became unreadable.
`refresh()` caught any error from `source.read()` and set the verdict to
`null`, which `computeEngaged()` treats as not-engaged — so a few failed
reads dropped an engaged brake, silently, with no log and no metric.

That handling was symmetric while the risk is not. A source that has
stopped answering correlates with the pressure the brake exists for, so
releasing on read failure gives up protection at exactly the wrong
moment; holding too long only costs throughput.

Now a failed read keeps the last verdict instead of discarding it. The
verdict then ages normally, so the existing `maxVerdictAgeMs` check
becomes the grace window and still bounds how long a dead source can
hold the brake — a permanently unreachable source releases it rather
than pinning dequeuing forever. Because `computeEngaged()` only consults
staleness for an *engaged* verdict, a released one is unaffected and
stays released.

The default grace moves from 15s to 120s, comparable to how long the
brake normally stays engaged.

One guard worth calling out: holding is only safe when something bounds
it, so when `maxVerdictAgeMs` is unset the previous discard behaviour is
kept. Otherwise an unbounded hold could pin the brake indefinitely.

Read failures were previously invisible — the catch block neither logged
nor counted. Adds a `read_failures_total` counter, plus an error log on
the transition into failure rather than once per tick, since the refresh
loop runs every second.

The post-release ramp needs no change: it anchors off the
engaged-to-released transition, so a grace-window release still ramps
back up instead of snapping to full rate, which is what you want after a
blind period.

Tests cover holding while reads fail, releasing past the max age, and
the existing unbounded-config paths are unchanged.
2026-08-03 18:06:23 +02:00
nicktrn 8f9db53350 feat(supervisor): configurable tolerations for run pods (#4491)
## Summary

Self-hosted Kubernetes deployments can now add tolerations to run pods,
so runs
can schedule onto tainted nodes. Previously the only way to do this was
to patch
the supervisor.

`KUBERNETES_RUNNER_TOLERATIONS` takes a comma separated list of
`key=value:effect`, or `key:effect` to tolerate any value. It applies to
every
run pod, and for runs from a schedule tree it merges with the existing
`KUBERNETES_SCHEDULED_RUN_TOLERATIONS`. Left unset, nothing changes: no
tolerations are added and the pod spec leaves the field off entirely.

The Helm chart takes it as a list:

```yaml
supervisor:
  config:
    kubernetes:
      runnerTolerations:
        - dedicated=runs:NoSchedule
        - spot:NoExecute
```

## Naming

The issue proposed `KUBERNETES_WORKER_TOLERATIONS`. This ships as
`KUBERNETES_RUNNER_TOLERATIONS` instead, because `RUNNER_*` is already
the prefix
for run pod settings (`RUNNER_HEARTBEAT_INTERVAL_SECONDS`,
`RUNNER_ADDITIONAL_ENV_VARS`, and `DOCKER_RUNNER_NETWORKS` for the
Docker
equivalent), whereas "worker" refers to the supervisor itself throughout
this app.

## Validation

Keys and values are checked against the Kubernetes naming rules when the
supervisor starts, so `dedicated=prod runs:NoSchedule` fails immediately
with a
message naming the offending entry. Without that check a bad value is
accepted at
startup and then rejected by the API server on every pod create, which
stops all
runs with the cause buried in an API error.
`KUBERNETES_WORKER_NODETYPE_LABEL` is
trimmed and validated for the same reason: surrounding whitespace is not
valid in
a label value, so a padded value fails every pod create today.

## Node selector off switch

`KUBERNETES_WORKER_NODETYPE_LABEL` accepts an empty string to skip the
node
selector entirely, so runs schedule on any node. This already worked and
the Helm
chart has always shipped it empty, but it was not documented. It is now.

The issue also asked for general node affinity configuration. That is
not
included: the node selector off switch plus tolerations covers the
reported
problem, and a free form affinity setting is a much larger config
surface to
commit to.

Fixes #4458
2026-08-03 15:40:41 +00:00
Eric Allam 9d57aff542 fix(webapp): make the Queues hero charts environment-wide (#4486)
## Summary

The four charts above the queues table aggregated over **at most the 25
queues on the current page**. They reused the loader's already-paginated
queue array as a ClickHouse `queue IN (...)` filter, so paging or
re-sorting changed the values, and a name search matching nothing
blanked the whole chart row. The stat tiles above them were already
environment-wide, so the two rows disagreed.

They now read `env_metrics`, the environment-level rollup that already
exists for exactly this (the built-in Queues dashboard and the health
report read it). That is both correct and queue-count-independent: no
`GROUP BY queue` across an entire environment, and no client-side
summing.

Note this is not only a paging artifact: page 1 under-reported too. On
the seeded environment below, page 1 read 82% saturation against a true
87%, because the environment's running total is not the sum of one page
of per-queue gauges.

Three related fixes ride along.

**Scheduling delay and throttling sawed to zero.** Both are
event-driven, so at the 10-second bucket a short range picks, most
buckets hold no samples at all and were drawn as `0ms`. Measured over a
1-hour window: **232 of 349 buckets had no scheduling-delay samples**. A
bucket where nothing started is not a bucket where nothing waited, so
the line was both ugly and wrong. TRQL grows a `minBucketSeconds` floor,
plumbed through the metric resource route, and the hero tiles set 60s.
Buckets that still have no samples render as a gap instead of a dive to
zero.

**The floor must not feed a width-dependent headline.** Two of the four
headlines are not peaks, so widening the plotted buckets moved them:

- **Throttled** is a share of buckets that saw any throttling, so a
single brief throttle came to mark a whole minute instead of ten
seconds: the same seeded events read 17% at 10s and 85% at 60s.
- **Scheduling delay p95** is a percentile, and merging quantile states
over a wider bucket yields a p95 between the sub-buckets' own. Two 240s
samples among twenty in one 10-second sub-bucket give a worst-of-six p95
of 240,000ms against a merged 60-second p95 of 5,000ms — a 48x
understatement of a headline whose tooltip claims it is the worst in the
window.

Both charts keep the floor, since a readable line was the point of it.
Their headlines now come from a second query at the range's natural
bucket width, via an optional `readout` on the tile, so each means what
its tooltip says regardless of how the plotted buckets are sized.
Saturation and backlog are genuinely width-invariant (a max of maxes is
the same at any width), so they are unchanged and issue no extra query.
Both caught by Devin in review; I had wrongly lumped p95 in with the
peaks.

**Charts reported a hydration mismatch on every render.** Recharts
resolved victory-vendor's CJS entry on the server and its ESM entry in
the browser. Those bundle different d3-shape builds, and the CJS one
predates d3-path's digit rounding, so every server-rendered curve
carried full-precision coordinates while the client rounded to 3
decimals:

```
Server: M0,3C0.9305555555555555,3,1.8611111111111112,3,...
Client: M0,3C0.931,3,1.861,3,...
```

Bundling recharts for SSR makes both sides resolve the same ESM build.
Verified: 45 of 45 server-rendered chart curves now match the client,
and the page loads with an empty console.

## Verification

An isolated stack with 40 seeded queues (20 heavily loaded, 20 idle) and
90 minutes of 10-second buckets written into `queue_metrics_raw_v1`, so
the real materialized views built `queue_metrics_v1`, `env_metrics_v1`
and the 5m rollup. Ground truth for the environment: 260 running against
a limit of 300 (**87% saturation**), 800 queued.

| | before | after |
| -- | -- | -- |
| Saturation, page 1 | 82% peak | **87% peak** |
| Saturation, page 2 | 5% peak | **87% peak** |
| Backlog / delay, page 2 | "No activity" | **800 peak / 59.5s** |
| Name search matching nothing | all four charts blank | charts stay
environment-wide |
| Metric refetches on a page change | 4, each painting a skeleton | **0,
no skeleton** |
| Buckets drawn as 0ms with no samples | 232 of 349 | **0** |
| Throttled readout | 17% | **17%**, unchanged by the wider buckets |
| Worst-p95 readout source | plotted buckets | **natural width**, so a
sub-minute spike is not averaged away |
| Crosshair reach, hovering one detail-page chart | 2 of 4 others | **4
of 4** |
| SSR chart curves mismatching the client | 45 | **0** |

The bucket floor was measured across ranges: it widens 10s to 60s at 30m
and 1h, and is correctly a no-op at 12h (300s) and 7d (3600s). One extra
request per page load, for the throttled readout.

The built-in Queues dashboard, which reads `env_metrics` independently,
agrees at 86.7% and 260 of 300.

`internal-packages/tsql` suite green (612 tests), including 5 new ones
for the floor that fail without it. Webapp typecheck, oxfmt and oxlint
clean. Spot-checked the Run metrics dashboard and the per-queue detail
page for SSR regressions from bundling recharts: both render, console
clean.

The queue detail page carries the same event-driven series, so its
scheduling delay, throttling and per-key mean delay take the same
treatment.

## Screenshots

<img width="2540" height="580" alt="after-page1-charts"
src="https://github.com/user-attachments/assets/6cd23f9c-e7fd-4918-bcfa-b1d3340b16d1"
/>

## Rollout

Already behind the per-organization `queueMetricsUiEnabled` flag, so
only gated orgs see any of it. Blast radius is chart values on one page
plus the SSR bundling of recharts; rollback is a revert with no data
migration.

## Stated limitations

- `wait_ms_count` and the quantile state both only count `wait_ms > 0`,
so "nothing started in this bucket" and "everything started instantly"
are indistinguishable in storage. Both render as a gap. Distinguishing
them needs a schema change, which is not in this PR.
- The queue name search deliberately no longer narrows the charts. It
only did so incidentally and incorrectly before (first 25 matches, and
blanked on zero matches). Search-scoped charts would need the full
unpaginated matching set and a server-side aggregate; worth its own
ticket if we want it.
- Bundling recharts for SSR grows the server bundle slightly. That is
the cost of both sides resolving one d3-shape build.
- The plotted delay line is a smoothed 60-second view, so a sub-minute
spike above the one-minute warning threshold can fail to colour the line
even though the headline reports it and colours itself.
- Every chart inside one synced group shares the floor, because the
hover crosshair is a reference line on a category x-axis and only draws
where the hovered bucket exists in the other chart's own data. That
costs the queue detail page's gauges some resolution (1 minute instead
of 10 seconds) in exchange for the crosshair working across the row.

Separately, while taking the screenshots I found a pre-existing
rendering bug unrelated to this change: a **perfectly flat** saturation
series draws no line at all (the readout still shows the right
percentage), which looks like the threshold gradient's offset
degenerating when the series min equals its max. It reproduces on
`main`, so it is not a regression here and I have left it alone; filed
as its own issue.

Refs TRI-12784
2026-08-03 16:19:50 +01:00
Matt Aitken 75df940e4c chore: vouch Leafgard (#4489)
Adds `Leafgard` to the list of vouched outside contributors so their PRs
aren't auto-closed by the vouch check.

Closes #4487
2026-08-03 14:34:17 +00:00
Katia Bulatova 859f30e224 fix(webapp): report message catalogs survive the production bundle (#4488)
GET /api/v1/reports/health threw `no catalog registered for report
"health"` in production (fine in dev): the catalog registered itself as
a side effect of a bare import, which the SSR build tree-shakes under
`"sideEffects": false`. Verified on the built server bundle — main's is
missing the catalog, this branch's carries it.

Fix: catalogs are values on the report registry entries; the resolver
reads them from there and the mutable register-at-import step is gone.
2026-08-03 16:12:03 +02:00
Chris Arderne 763b5dc582 feat(webapp): enforce scopes for environment API keys (#4389)
## Summary

Environment API keys backed by the additional-key table can authenticate
API requests using their stored effective scopes. Revoked and expired
keys are rejected, branch environments retain their existing routing
behavior, and last-used timestamps are updated on a throttled
best-effort basis.

## Design

API route builders receive the resolved ability and reject restricted
keys on routes without an authorization declaration. Existing
deployment, environment variable, queue, run, task, batch, session, and
waitpoint routes declare the resources they access.

Trigger and batch responses return server-signed public access tokens,
so additional keys never need access to the environment signing secret.
Root-key rotation also keeps public tokens valid for the existing grace
window.

## Feature notes
- Root environment keys remain unrestricted for backward compatibility.
Additional keys enforce their persisted scopes and fail closed on routes
   without an authorization declaration.
- Machine-key requests never exchange one credential for another.
Additional keys cannot retrieve the root key, and rotated root keys are
not upgraded
   during their grace window.
- Public JWT validation remains host-owned, while installed RBAC plugins
continue to supply root-key abilities.
- Unfiltered session and run listings preserve existing broad task-read
behavior. Filtered requests enforce the supplied task identifiers.
- Related-run summaries remain embedded in run retrieval for API
compatibility. Retrieving or mutating a related run independently still
requires
   permission for that run.
- Queue management authorizes at collection scope, matching the queue
permissions currently issued.
- Batch responses deliberately include server-signed public access
tokens for all clients. Selected-task credentials continue using their
original
   credential for per-item authorization.
- Two-phase batches authorize declared task identifiers before creation
and authorize every streamed item. Streaming paths that cannot declare
the
   complete task set remain fail closed.
- Authentication telemetry records successful credential resolution
separately from subsequent resource-authorization failures.
- API keys are high-entropy random tokens. SHA-256 is intentionally used
for deterministic indexed lookup, not password hashing.

## Deployment notes

The schema migration must be present before this code is deployed.
Because bearer resolution runs on every authenticated request, deploy
the resolver with additional-key lookup disabled, verify root-key and
public-token parity, then enable lookup before any additional keys can
be issued.

The multi-task authorization tightening changes the result for narrowly
scoped tokens that request tasks outside their grants. Observe
would-deny results before enforcing that check. Request-idempotency keys
are also newly isolated by environment and task, so a retry crossing the
deployment boundary may execute once more before old cache entries
expire.

## Follow-ups

- [x] Add a system-wide kill switch for additional-key lookup, defaulted
off for the initial deployment.
- [x] Add authentication observability by credential kind, result,
latency, and lookup path without recording credential values.
- [ ] ~Add would-deny observability and an independent enforcement
switch for multi-task authorization.~
- [ ] ~Add an independent switch for server-issued batch tokens while
root-key parity is verified.~
- [ ] Confirm every API route reachable by a restricted key has an
explicit authorization declaration or intentionally fails closed.
- [x] Verify root-key rotation, revoked-key grace, and public-token
validation through each bearer resolver path.
2026-08-03 14:00:29 +01:00
nicktrn d9f4fea939 docs: restructure self-hosting kubernetes guide (#4481)
Restructures the Kubernetes self-hosting guide around two explicit paths
- an **evaluation install** (bundled datastores, one command) and a
**production install** (external datastores, your own secrets) - so
every configuration decision belongs to one path or the other instead of
being a flat list of options with caveats.

Also in this pass:

- Adds an architecture overview (component-to-`values.yaml` map) and a
post-install "verify it" step.
- Consolidates the previously scattered upgrade notes into a single
collapsible group, and cuts implementation detail and historical asides
that no longer apply.
- Removes a duplicated object-storage section (two configs in two
styles) and trims the Docker ClickHouse note down to what a self-hoster
needs to act on.
2026-08-03 11:57:24 +01:00
Eric Allam 5f29ae49ab feat(webapp): default the queue metrics period to 1 hour and remember it (#4438)
## Summary

The Queues list and queue detail pages opened on a 1 day window, and
went back to it every time you navigated between queues or reloaded.
They now default to the last hour, and the period you pick is remembered
across navigations and refreshes.

## Design

The last period is stored in a `queueMetricsPeriod` cookie, written
client-side whenever a `period` lands in the URL and read by both
loaders. A cookie rather than localStorage because the queues list
renders its per-queue metrics columns server-side: with localStorage the
page would paint the 1 hour default and then re-fetch, and the picker
would flash the wrong window.

Both pages resolve the window once, in one place, and pass it down:

```ts
period: resolveQueueMetricsPeriod({
  period: value("period"),   // a usable period in the URL wins
  from: value("from"),       // an absolute range means "no period"
  to: value("to"),
  defaultPeriod,             // otherwise the remembered default from the loader
}),
```

That keeps the picker pill and every chart query on the same value, so
no call site falls back to its own default. Periods the picker could
never produce (a hand-edited `?period=garbage`, or a window past the 30
day retention) fall back to the default, and the picker renders the
resolved window rather than the raw search param so the label can't
disagree with the data. Absolute from/to ranges, including drag-to-zoom,
are not remembered, since they would pin later visits to a window that
has gone stale.

While wiring that up: the two queue-metric queries that go straight to
ClickHouse (the list table and the concurrency-keys endpoint) never
applied the org's `queryPeriodDays` limit, so a hand-typed `?period=`
read further back than the plan allows. Everything behind
`/resources/metric` is already clipped that way by `executeQuery`; both
of these now clip with the same limit, capped at the retention window,
and the plan cap is resolved once per load and handed to the page
instead of each route deriving its own copy from the client-side
subscription.

Verified on both pages: default with no cookie is 1 hr, picking 6 hrs
survives navigating away and back to a param-free URL and a hard reload,
clearing the cookie returns to 1 hr, an oversized period falls back
without being remembered, and an absolute range still renders as a
range.
2026-08-03 10:09:34 +01:00
Iss 8f66af6e18 fix(webapp): stop the sidebar feedback popover from canceling the submit (#4445)
The Help & Feedback → "Contact us" form in the sidebar intermittently
failed to send. The `<Feedback>` dialog was nested inside the Help
popover, so clicking **Send** closed the popover and unmounted the form
mid-submit — canceling the `POST /resources/feedback` before it went
out. The message was silently lost (the success toast still shows). A
race, so it "worked sometimes"; the standalone "I'm stuck!" path was
unaffected.

**Fix:** host the Feedback dialog *outside* the popover (same pattern as
`AskAIRoot`) and open it from the menu item, so closing the popover no
longer tears down the form. `Feedback` gains an optional controlled
`open`/`setOpen` mode; existing `button`-triggered usages are unchanged.

## Changes

- `Feedback.tsx` — optional controlled `open`/`setOpen`; `button` now
optional.
- `HelpAndFeedbackPopover.tsx` — "Contact us…" opens a `<Feedback>`
hosted outside `PopoverContent`.
- `.server-changes/fix-sidebar-feedback.md` — user-facing note.

## Testing

Webapp typecheck passes. Sidebar "Contact us…" now sends on every
attempt (Network: `POST /resources/feedback` → `204`, never
`(canceled)`); "I'm stuck!" and the `?feedbackPanel=` open path
unchanged.
2026-08-02 14:49:09 +01:00
James Ritchie 14824b0955 feat(webapp): fix agent overview page scroll bug + layout fixes on task and agent pages (#4454)
## Summary

The task, scheduled task and agent pages now name their runs table with
its own title bar, and the controls that page the table sit beside it
rather than in the bar at the top of the page. The top bar keeps just
the date filter.

Two agent page layout bugs are fixed along the way: scrolling a wide
runs table sideways dragged the charts off screen with it, and the
details panel stopped short of the bottom of the window.

## Fix

The charts moved because the runs table had no horizontal scroller of
its own. `stickyHeader` swaps the table's `overflow-x-auto` for
`overflow-visible`, so the overflow escaped up to the page scroll box,
and setting only `overflow-y-auto` on that box leaves the computed
`overflow-x` at `visible`, which CSS then promotes to `auto`. The chart
grid is a sibling inside that box, so it scrolled too. The table now
keeps its own scroller (the same rule the queues list already documents)
and the page box clips x so this cannot recur.

The short panel was a second `PageContainer` wrapping the agent routes.
`PageContainer` is `grid-rows-[auto_1fr]`, so a lone child lands in the
`auto` row and its `h-full` resolves against content height instead of
the viewport.

This also reverts the global tooltip `max-w-[230px]` introduced in
[#4131](https://github.com/triggerdotdev/trigger.dev/pull/4131), so
longer tooltips are no longer squeezed into a narrow column.

### Agent overview page showing table now scrolling
<img width="3452" height="1648" alt="CleanShot 2026-08-01 at 12 04
38@2x"
src="https://github.com/user-attachments/assets/ef1ac55d-8ffb-4278-983b-031ed21c1f55"
/>
2026-08-01 16:26:26 +01:00
Matt Aitken cb9aefd49b fix(hosting): deploy ClickHouse from the official image instead of Bitnami (#4249)
## Summary

Self-hosted deployments now run ClickHouse from the official
[`clickhouse/clickhouse-server`](https://hub.docker.com/r/clickhouse/clickhouse-server)
image instead of `bitnamilegacy/clickhouse`. Bitnami's free image
catalog is EOL and the frozen legacy archive tops out at ClickHouse
25.7.5, below the 25.8 minimum the platform requires since v4.5.0, which
broke every ClickHouse insert on chart-bundled deployments. Both stacks
now default to 26.2, the same version the platform is developed and
tested against.

Existing deployments keep their ClickHouse data with no manual
migration.

Fixes #4197.

## Details

**Docker Compose**: the `clickhouse` service uses the official image
with its native env vars, plus the recommended `nofile` ulimits. It
reuses the same named volume as before: a `data-paths.xml` config
override points ClickHouse at the `data/` subdirectory of the volume,
which is exactly the layout the Bitnami image used, so old volumes work
in place (including SQL-created users) and fresh installs get the
identical layout. The service follows the required-secrets model:
`CLICKHOUSE_PASSWORD` must be set, matching the other services.

**Helm chart**: the Bitnami ClickHouse subchart is replaced by a
chart-owned single-node StatefulSet and Service running the official
image (non-root, HTTP `/ping` probes, config overrides mounted into
`config.d`, and the same `data-paths.xml` layout compatibility). On
upgrade, the chart automatically adopts the data PVC left behind by the
old subchart (`data-<release>-clickhouse-shard0-0`) via `lookup`, and
`fsGroup` relabeling handles the uid change on first mount. Both the
ClickHouse server and the webapp read the password from the same
chart-managed datastore secret (auto-generated and retained across
upgrades), so the server credential and the app's connection URL always
match. Existing `clickhouse.*` values keep working: `auth` (including
`existingSecret`/`existingSecretKey`), `persistence` (including
`global.storageClass`), `resources`, `secure`, `external.*`,
`configdFiles`, and now `nodeSelector`/`tolerations`/`affinity`.
Bitnami-only keys (`shards`, `replicaCount`, `keeper`,
`resourcesPreset`) are gone; default `resources` requests/limits match
what the old preset applied. The docs state the 25.8 minimum for
bring-your-own ClickHouse.

## Upgrade caveats

An adversarial review of the upgrade path found a few cohorts that need
awareness (all documented):

- **GitOps tools that render with `helm template`** (no cluster access):
PVC auto-detection can't run, so `clickhouse.persistence.existingClaim`
must be set to the old PVC name or ClickHouse starts on a fresh empty
volume. Documented in the values file and the Kubernetes self-hosting
docs. Tools that run real helm installs (e.g. Flux) adopt automatically.
- **A pinned `CLICKHOUSE_IMAGE_TAG`** pointing at a Bitnami tag must be
updated to an official image tag; documented in the Docker self-hosting
docs.
- **Storage without `fsGroup` support** (NFS, hostPath): set
`clickhouse.volumePermissions.enabled: true` for a one-time
ownership-fixing init container.
- **Rollback is not automatic**: once the official image has run, file
ownership changes and the Bitnami image can no longer read the volume
without a manual chown, and ClickHouse does not support downgrades
across the version gap.

## Verification

- Full upgrade simulation for Compose, twice (before and after rebasing
onto the required-secrets release): booted the ClickHouse service from
the old compose file on `main` (Bitnami), wrote thousands of rows, then
brought the same project up with this branch's compose file. The
official 26.2 server came up healthy on the same volume with all rows
intact, SQL-created users working, and writes succeeding.
- Adoption scenarios tested against real containers: old volume + root
entrypoint (Compose), old volume owned by the Bitnami uid + non-root 101
with fsGroup-style group permissions (Kubernetes), and fresh volumes for
both.
- `helm lint`, `helm template` (default values, `existingClaim` set,
external ClickHouse, volumePermissions/scheduling toggles, and the
production example) and kubeconform all pass, mirroring the release CI
steps. The rendered webapp Deployment and ClickHouse StatefulSet resolve
to the same datastore secret key.
- Inserts using
`input_format_json_infer_array_of_dynamic_from_array_of_different_types`
(the setting that fails on 25.7.5) succeed on the upgraded volume.

## Upgrade preflight and docs

A production upgrade report on this branch surfaced two hazards that
predate this PR — both landed in chart 4.5.6 (#4316) — so they are fixed
here rather than left for the next person to hit.

**`secrets.existingSecret` gained two required keys.** The webapp
started reading `PROVIDER_SECRET` and `COORDINATOR_SECRET`, and when
`existingSecret` is set the chart generates nothing, so a missing key
only surfaced as a `CreateContainerConfigError` partway through the
webapp rollout. The pre-install/pre-upgrade validation now looks the
Secret up and fails with the complete list of missing keys, leaving the
running release untouched. It is skipped under `helm template` and
client-side dry-run, where `lookup` cannot read the cluster.

**Bundled datastore credentials moved into the chart-managed Secret**
(`<release>-clickhouse`/`admin-password` →
`trigger-datastore`/`clickhouse-admin-password`). The chart wires both
ends itself, but consumers outside it — maintenance CronJobs, Grafana
datasources, secret syncs — have to be repointed. A new `## Upgrading`
section in the Kubernetes docs carries the old→new mapping, the two new
keys, and a pointer to the ClickHouse image notes.

The existingSecret key list in the docs also named
`OBJECT_STORE_ACCESS_KEY_ID`/`OBJECT_STORE_SECRET_ACCESS_KEY`, which are
env var names rather than keys the chart reads; corrected to the real
key names and the condition under which they apply.

Verified on a throwaway kind cluster with `--dry-run=server`: a
pre-4.5.6 Secret fails with both key names listed, the documented
`kubectl patch` clears it, and default values, `existingClaim`, external
ClickHouse, volumePermissions/scheduling and the production example all
still render. A real `helm install` followed by an upgrade against an
incomplete Secret aborts with the release still at revision 1 and
`deployed`. `helm lint`, the CI render and kubeconform (59 resources, 0
invalid) pass.

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2026-08-01 14:17:27 +01:00
Eric Allam db6228dd1e chore(webapp,core,sdk): upgrade @s2-dev/streamstore to 0.25 and migrate S2 hosts (#4349) 2026-08-01 11:33:34 +01:00
Eric Allam f9c8d518c7 perf(webapp,run-engine,database): resolve the newest worker and deployment by createdAt (#4452) 2026-08-01 11:32:21 +01:00
Eric Allam 0445b8ec27 fix(webapp,clickhouse): keep the rest of a ClickHouse batch when one run or span has un-ingestable JSON (#4358)
## Summary

A single run output, trace span, or payload carrying JSON that
ClickHouse can't ingest (for example nesting past its depth limit) used
to fail the whole insert batch, so unrelated runs and spans silently
disappeared from the runs list, traces, and logs. This keeps the rest of
the batch and handles the offending row instead of dropping everything
around it.

## Fix

Recovery is per-table, matched to what each table needs:

- **Runs** (`task_runs_v2`) keep their status. We follow ClickHouse's
failing-row hint to strip just the un-ingestable JSON column(s) so the
run still lands (its output reads from Postgres on the detail page), up
to a configurable limit (`RUN_REPLICATION_MAX_POISON_STRIPS_PER_BATCH`,
default `1`). Past the limit we stop and land the batch with
`allow_errors` in a single pass, skipping the remainder. Cost stays a
fixed handful of inserts no matter how large or poisoned a flush is.
- **Trace events and payloads** (high volume, append-only) recover with
a single `allow_errors` insert: the good rows land in one pass and only
the un-ingestable rows are skipped.

Before falling back, a lightweight sanitizer still repairs what it can
losslessly (lone UTF-16 surrogates, out-of-range integers) so a
repairable row lands in full.

To read the failing-row hint we patch `@clickhouse/client-common`: its
error parser truncates the server response and discards the `(at row N)`
position, so the patch preserves the full text for the recovery path to
read.
2026-08-01 09:17:20 +01:00
James Ritchie fc69101252 feat(webapp): AI agent logo experiments (#4399)
## Summary

Adds an admin-only "AI agent" storybook page exploring an animated
identity for the dashboard agent: a resting dot logo that animates while
the agent is thinking, then settles once it is done.

The lead experiment is a 5x5 dot matrix. Shapes are five-line string
bitmaps, a bright head walks each shape's route on a fixed beat, and it
only hands off between shapes on a dot the two share, so the rhythm
never breaks. It comes with 26 faces, six gradient palettes, and light
and dark treatments. Two earlier prototypes (a crisp logo that scatters
into orbiting dots, and a dotted triangle on tilted 3D orbits) are kept
in their own tabs for comparison. Everything is plain canvas code with
no new dependencies.

Also adds an `ask-ai` Button variant: secondary styling with a soft
trigger-green border and padding tuned around the leading logo. The
variant supplies the agent logo itself, so callers write `<Button
variant="ask-ai/small">Ask AI</Button>`. Passing a `LeadingIcon`
overrides it, which is how the thinking animation gets driven.

No release note: the storybook is admin gated and the button variant is
not used in product UI yet.
2026-07-31 22:09:11 +01:00
James Ritchie 55e6225b0f fix(webapp): focus the search field when a filter sub-menu opens (#4443)
## Summary

Opening a filter sub-menu that has its own search field left the cursor
outside it, so you had to click into the field before you could type.
The cursor now lands in the search field every time a sub-menu opens.

`ComboBox` now focuses its input whenever the popover is open and the
field is present, so the cursor lands there both when a menu opens
normally and when a sub-menu mounts its field late. It is a no-op
wherever focus already worked.

Verified in the dashboard against the Tags menu: before, the field
mounted with focus still on the popover container; after, it mounts
focused and accepts typing straight away.
2026-07-31 21:57:53 +01:00
nicktrn b42e5c3771 fix(supervisor): count pods from a limit=1 list instead of an aggregate metric (#4442)
The pod-count backpressure source read
`apiserver_storage_objects{resource="pods"}` from an apiserver
`/metrics` scrape. That gauge is a periodically-refreshed cached count,
and it is served by whichever apiserver replica the scrape lands on —
replicas disagree with each other at the same instant, by enough to
swamp the engage/release hysteresis band. Engage and release timing was
therefore partly a function of scrape routing.

This replaces it with a single `limit=1` list of the workload namespace
and computes `remainingItemCount + items.length`. One pod object
transferred, no informer, no watch cache.

Two request-shape constraints are load-bearing and called out in the
code: passing a label or field selector makes the apiserver omit
`remainingItemCount` entirely, and setting `resourceVersion` serves a
cached count rather than a quorum read. Neither is passed.

`remainingItemCount` is only set when the list is truncated, so
`_continue` is the truncation signal — if it is absent the returned page
is the whole collection and `items.length` is already exact. If the list
*is* truncated and the count is missing or implausible, the fetcher
throws rather than guessing.

Failure semantics are unchanged: a throw lands in the monitor's existing
catch, exactly as the previous parse did. The hysteresis, verdict shape,
and gauge are untouched. RBAC is unchanged — the existing role already
grants `pods: list`.

The `/metrics` non-resource grant in the deployment role becomes unused,
and the scrape-timeout env var is now a slight misnomer. Both left alone
deliberately: the grant may be wanted again for other apiserver signals,
and renaming the var would need a coordinated config change for no
behavioural gain.

Tests cover the not-truncated, truncated, missing-count, negative-count
and timeout paths.
2026-07-31 19:45:37 +01:00
Eric Allam f10bc23785 perf(run-engine,run-store): one execution snapshot per triggered run (#4419)
A non-delayed run used to get two execution snapshots the moment it was
triggered: `RUN_CREATED` nested in the run-create transaction,
immediately followed by `QUEUED` from its own `BEGIN`/`INSERT`/`COMMIT`.
It now gets a single `QUEUED` snapshot written inside the create, and
the trigger path only publishes to the queue. One fewer row per run on
`TaskRunExecutionSnapshot`, and one fewer round trip on the trigger hot
path.

`EnqueueSystem` gains a `publishRun` seam that enqueues without writing
a snapshot. Every re-enqueue path (waitpoint resume, checkpoint restore,
delayed enqueue, pending version, retry requeue) still calls
`enqueueRun` and writes its own `QUEUED`, so only the first enqueue
changes. The `QUEUED` snapshot still commits before the queue message,
so a dequeue sees a dequeueable status exactly as before.

Two things for reviewers. Nesting the write skips
`createExecutionSnapshot`, which is what emits
`executionSnapshotCreated` and therefore the run timeline's `[engine]
QUEUED` entry, so the trigger path now emits it directly, the same way
the dequeue and attempt-start paths already do for their nested creates.
And `RUN_CREATED` is still written when a dequeued run has no background
worker yet, so the status and both `statuses.ts` helpers stay live and
existing rows keep reading correctly.

Delayed runs are untouched: `DELAYED` then `QUEUED` are two genuinely
different moments and stay two snapshots.

Rollback is a revert. Create-and-enqueue happen in one request in one
process, so no in-flight run needs both code paths to agree during a
rollout.


One note for whoever debugs this path later. The `QUEUED` snapshot now
commits before the queue publish, so a failed publish leaves the run
recorded as `QUEUED` with no queue message. That state was already
reachable, since the publish was never part of the snapshot transaction,
but it used to be recorded as `RUN_CREATED`, which was distinctive
because it never otherwise persisted. `QUEUED` with no message is
indistinguishable from a run waiting on a concurrency slot, so
trigger-time publish failure is now one more cause of an apparently
stuck queued run.
2026-07-31 16:12:07 +01:00
nicktrn a91c08c731 fix(core): retry run start-attempt on transient connection errors (#4441)
## What
`startRunAttempt` — the run controller's first call when a run starts —
had no retry on transient connection errors. A brief connection blip on
that call would abandon the start and send the run back through the
queue, delaying its first attempt.

This adds a jittered backoff retry, matching the existing
`continueRunExecution` path with a shorter budget, so a transient blip
is ridden out in place instead of bouncing the run.

## Why a shorter budget
The continue path retries generously. Start-attempt keeps a tighter
budget (6 attempts, ~25-40s jittered) so it rides out a transient blip
but never keeps retrying past the point the run would already have been
requeued.

## Safety
Retrying is safe: start-attempt is guarded server-side by the snapshot
id — a retry after a start has already committed is rejected, so it can
never double-start an attempt. A pure connection error (the common case)
never reached the server.

## Scope
One retry-options object on `startRunAttempt`; no other behavior change.
Warm starts share this path and get the same resilience.
2026-07-31 13:57:45 +00:00
Eric Allam c72ebf9084 fix(webapp,run-engine): stop batchTriggerAndWait hanging when item streaming never completes (#4397)
## Summary

`batchTriggerAndWait()` could leave a parent run waiting forever. The
2-phase batch API blocks the parent on the batch's waitpoint as soon as
the batch is created, but the batch is only sealed at the end of item
streaming. If streaming never completed, nothing sealed the batch,
nothing completed the waitpoint, and the parent stayed suspended with no
timeout and no way to recover.

Supersedes #4016, which added the reaper alone.

## Fix

Admission for item streaming was being decided twice. Batch creation
passes its own rate limiter, which fixes `expectedCount` and blocks the
parent, and then the item stream had to pass the general API limiter as
well, competing with unrelated traffic. A second limiter could therefore
veto work the first had already committed the parent to. Creation now
mints a bounded grant that the item stream spends, so an admitted batch
can finish streaming. The grant is capped per batch rather than
exempting the path, and every failure mode (no grant, spent grant,
unreachable store) falls back to the normal limiter.

That makes stranding much rarer but not impossible, since a request
timeout or a crash can still end streaming for good. So a seal-timeout
reaper aborts any batch still unsealed after `BATCH_SEAL_TIMEOUT_MS` and
completes the parent's waitpoint with an error, letting
`batchTriggerAndWait()` reject instead of hang. It is race-safe against
a late seal, and it is only scheduled for batches that actually block a
parent, so fire-and-forget batches cost nothing.

Finally, the batches page used to report "Batch completion checked." for
these batches while doing nothing, because the completion path returns
early on an unsealed batch. It now says the batch cannot be resumed.

Rate limiting is no longer the reason a batch strands, so the reaper's
default stays at 30 minutes, comfortably above the SDK's worst-case
stream-retry budget.

## Verification

Unit and container tests cover the grant cap, the bypass ordering (it
runs after the authorization check, so it can never skip
authentication), and the reaper's abort, seal race, idempotency, and
no-waitpoint cases.

Also verified end-to-end against a running stack. With the general limit
exhausted, batch creation and other API calls returned 429 while a
granted batch still streamed and sealed; an ungranted batch id was rate
limited rather than bypassed; and the grant cut off exactly at its
configured attempt count. Reproducing the stranded state on a real
parent run, the batch was aborted at the timeout, the waitpoint
completed with an error, and the parent resumed and finished instead of
hanging. A parentless batch left unsealed was untouched well past the
reaper window.

## Verified against deployed runs

The reaper was proven end to end with a real deployed run (locally-run
supervisor, containerised
run) and a real network fault, rather than a simulated one: toxiproxy
severs the phase 2 item
stream mid-flight so every SDK stream retry genuinely fails, while phase
1 still succeeds. Only
the batch calls traverse the fault, so control-plane traffic is
untouched.

The reproduction is the shape that actually strands a parent: the task
catches the
`BatchTriggerError` the SDK throws and carries on, so the phase 1 block
outlives the thrown error
and the parent hangs at its next suspension point.

With the reaper disabled, the parent sat in `EXECUTING_WITH_WAITPOINTS`
for over 24 minutes holding
two blockers, and stayed stuck across a full infrastructure restart:

```
 type     | status    | has_timeout
 BATCH    | PENDING   | f            <- orphan, completedAfter NULL
 DATETIME | COMPLETED | t            <- the wait already elapsed
```

With the reaper enabled the same task under the same fault completed in
about 75 seconds with zero
blockers left, the batch `ABORTED`, and its waitpoint completed carrying
the error.

Two conditions are required to observe this at all, which is worth
knowing for any future test:
the run must be deployed rather than `trigger dev` (dev runs execute in
process and finish while
still holding blocker rows), and the wait after the caught error must
exceed the checkpoint
threshold, or it is served in process and never suspends.

### Why completing the batch waitpoint is sufficient

`batchTriggerAndWait` runs create, then stream, then wait. A phase 2
failure throws before the wait
is ever reached, and the reaper only fires on an unsealed batch, so the
parent is never suspended
awaiting the batch when it runs. The parent therefore does not need a
synthetic result, only to stop
being blocked. Note this reasoning depends on that ordering: if the wait
were ever reached with an
unsealed batch, completing the batch waitpoint alone would not settle
the caller.

## Follow-ups

- Batches stranded before this ships still need a one-off recovery; the
reaper only schedules at creation time.
- That same property leaves a gap if the process dies between creating
the batch and scheduling the job. A periodic sweep would close it, but
wants a supporting index.
- When a partially streamed batch aborts, children already enqueued keep
running while the parent fails. Left as-is deliberately, since
cancelling triggered work is a bigger semantic call.
2026-07-31 11:55:25 +01:00
claude[bot] 17d849b2d6 feat(cli): expose region option on the MCP trigger_task tool (#4439)
<!-- ccr-slack-attribution -->
_Requested by **Eric Allam** · [Slack
thread](https://triggerdotdev.slack.com/archives/C0BEM9Z73TM/p1785491472104199)_

## Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

Static checks only, all clean:

- `pnpm run typecheck --filter trigger.dev`
- `pnpm run format`
- `pnpm run lint`

No live task was triggered against a running project, so the "ran and
tested" box above is left unchecked.

---

## Changelog

**Before:** triggering a task through the MCP server always ran it in
the project's default region. There was no way to pick one.

**After:** the `trigger_task` tool accepts an optional `region` option,
so you can choose the region a run executes in.

**How:** `region: z.string().optional()` was added to
`TriggerTaskInput.options` in `packages/cli-v3/src/mcp/schemas.ts`. No
call-site change was needed — `tools/tasks.ts` passes `options` through
verbatim, and `TriggerTaskRequestBody.options.region` already existed.
The tool description in `docs/mcp-tools.mdx` gained a matching line, and
a patch changeset is included.

There is no batch-trigger MCP tool, so there is no sibling tool to
mirror this change on.

---

## Screenshots

N/A — no UI changes.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 11:54:42 +01:00
Wes Mason efcb89ac26 fix(webapp): add hasAdminDisplayAccess to the env param test mock (#4430)
`test/envParamRoute.ownership.test.ts` fails on main: 3 of its 4 tests
throw

```
Error: [vitest] No "hasAdminDisplayAccess" export is defined on the
"~/services/session.server" mock. Did you forget to return it from "vi.mock"?
```

#4421 added a `hasAdminDisplayAccess(user)` call to the `env.$envParam`
loader, and the test's `vi.mock` of `session.server` only returns
`requireUser`, so the call blows up. Both changes were green in their
own PR and only conflict once merged together, which is why nobody
caught it.

The mock now mirrors the real implementation rather than returning a
constant, so it stays correct if the test's user fixture is ever varied.
No assertions were changed: the tests were right, the mock was stale.

Worth flagging separately: no workflow runs on push to main, so this has
been red since #4421 landed without showing up anywhere. Every PR opened
since has inherited the failure.
2026-07-31 09:44:16 +01:00
claude[bot] debfa2b733 feat(webapp): impersonation consent page and a view-as-user toggle (#4421) 2026-07-30 21:28:44 +01:00
Chris Arderne 68ed809416 test(clickhouse): keep queue metrics fixtures within TTL (#4428)
## Summary

Keeps the queue metrics ClickHouse tests stable as wall-clock time
advances.

## Root cause

The fixtures used fixed timestamps. Once those timestamps crossed the
tables' 30-day retention boundary, ClickHouse immediately expired the
inserted aggregate rows and all six tests read empty results.

The fixtures now derive a recent minute-aligned timestamp once per test
file. The second 10-second bucket and ranking window are derived from
the same anchor, preserving deterministic bucket relationships while
keeping rows inside both the raw and aggregate table TTLs.

Verified with `pnpm --filter @internal/clickhouse exec vitest run
src/queueMetrics.test.ts`.
2026-07-30 21:20:44 +01:00
Oskar Otwinowski 4efe0a07c4 fix(webapp): create dev environments for SSO and Directory Sync members (#4426)
Members added by SSO just-in-time provisioning or Directory Sync never
got
their per-member DEVELOPMENT environments - only invite acceptance and
project creation created them. `trigger dev` returned "Environment not
found" for those members and the dashboard had no dev view.

ensureOrgMember now queues provisioning for every membership it settles,
so
both paths are covered and members missing environments are repaired on
their next sync. Provisioning runs as a common-worker job to keep
sign-in
and directory webhooks off the per-project write loop. A failed enqueue
surfaces for Directory Sync, whose worker retries the idempotent effect,
and is swallowed for sign-in, where the next login enqueues again.
Environment creation now tolerates a concurrent creator so the
project-creation loop and the job cannot collide on the unique index.

Also fixes environment resolution ignoring dev-environment ownership: a
member without their own dev environment could be handed a colleague's
and
have it persisted as their dashboard preference.
2026-07-30 21:50:15 +02:00
Iss d90f06ba5e feat(webapp): migrate Plain to @team-plain/graphql + attribute support threads to org tenant (#4368)
## What

Two changes, shipped together:

1. **SDK migration (TRI-12460).** `@team-plain/typescript-sdk` is
deprecated. Move the webapp to its successors — `@team-plain/graphql`
(client) and `@team-plain/ui-components` (`uiComponent` builder).
Behaviour-preserving: the `PlainClient` customer upsert + thread
creation move to the new `client.mutation.*({ input })` shape; the
client now throws on failure, so `sendToPlain` wraps its calls and logs,
staying best-effort.

2. **Org tenant attribution (TRI-12461).** When org context is
available, `sendToPlain` now upserts a Plain tenant keyed by `externalId
= org_id`, links the customer to it, and stamps the created thread with
that tenant — so support threads become attributable to a Trigger.dev
org. Wired into the four add-on quota requests and the plan-cancellation
feedback (which already have org context). The tenant steps are isolated
in their own try/catch and the thread's `tenantIdentifier` is gated on
their success, so a tenant failure never blocks thread creation.

## Not affected

- `customer.externalId` stays `User.id` — the customer cards +
impersonation link are unchanged.
- No ticket content leaves Plain.
- Callers without a single org (e.g. the feedback widget) are unchanged
— the org params are optional.

## Deploy prerequisite

The webapp's Plain API key needs three **new** scopes for attribution to
work (it already has `customer:create`, `customer:edit`,
`thread:create`):

- [x] `tenant:create`
- [x] `tenant:edit`
- [x] `customerTenantMembership:create`

Until granted, nothing breaks — `sendToPlain` logs the forbidden error
and creates the thread without attribution.

## Testing

- `pnpm typecheck --filter webapp` passes; oxfmt + oxlint clean.
- Ran the real `sendToPlain` end-to-end via a throwaway vitest harness
against live Plain — confirmed the code path executes; the live write is
gated only by the key scopes above.
2026-07-30 14:55:20 -04:00
github-actions[bot] 86b948b47a chore: release v4.5.9 (#4408)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 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
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
2026-07-30 10:12:14 +01:00
Eric Allam 6e5f0f0fe7 fix(webapp,clickhouse): stop invalid customer queries alerting, and isolate Sentry scope per request (#4372)
## Summary

A query sent to the query API with a typo in it, like a column name that
does not exist, was being reported as a server error. That put customer
SQL mistakes into our error alerting, where they made up almost all of
the volume on one of our noisiest alerts, and it drowned out the
failures that are actually ours to fix. This makes the level match who
is at fault, and fixes two related problems found alongside it.

## Invalid queries are the caller's, not ours

The query API route already got this right. It checks for `QueryError`,
logs at warn, and returns a 400, with a comment saying the system
handles it gracefully and no alert is needed.

The layer underneath ignored that. `executeTSQL` logged every exception
out of its catch block at error, including the compile failures the
route was about to turn into a 400, and error-level logs are forwarded
to error reporting.

The TSQL package already draws the line we need:

```ts
export class ExposedTSQLError extends BaseTSQLError {
  /** An exception that can be exposed to the user. */
}

export class InternalTSQLError extends BaseTSQLError {
  /** An internal exception in the TSQL engine. */
}
```

`SyntaxError` and `QueryError` extend the first. So the catch block now
branches on `ExposedTSQLError` and logs those at warn, keeping error for
`InternalTSQLError` and anything unanticipated, which is a genuine
compiler bug.

## SQL the caller wrote is their mistake, not ours

The same asymmetry showed up one level down. A query that compiles fine
can still be rejected by ClickHouse at execution, and most of those
rejections mean the caller's SQL is wrong rather than that we generated
something bad.

This is where the volume actually is. Checking production, one error
group alone, a missing `GROUP BY` on the public query API
(`NOT_AN_AGGREGATE`), accounts for over a million events across hundreds
of users. It is by far the largest error group in the project, and
classifying only by resource limit would have left every one of those at
error level.

So rejections are split three ways in `ClickhouseClient`, which is the
only place holding the parsed `ClickHouseError` and its symbolic type.
By the time the error reaches `executeTSQL` it has been wrapped and the
type is gone, and the type never appears in the message text, so it
cannot be recovered by string matching.

- **Resource limits** (memory ceiling, timeout, row/byte caps) log at
warn. The query is valid, it just asked for more than it is allowed to
spend.
- **Invalid SQL** (`NOT_AN_AGGREGATE`, `UNKNOWN_IDENTIFIER`,
`SYNTAX_ERROR`, the type and parse families) logs at warn **only when
the caller wrote the SQL**.
- **Everything else** keeps alerting.

That gate matters. The client is shared, so the identical rejection on
TRQL *we* generated is our bug and has to stay at error. Callers opt in
with `userAuthoredQuery`:

| caller | who wrote the SQL | opts in |
| --- | --- | --- |
| public query API | the customer | yes |
| query editor | the customer | yes |
| agent charts | the agent's model | yes |
| built-in dashboard tiles | us, in code | no |
| queue metric cards | us, in code | no |
| health report | us, in code | no |

The agent is the one judgement call. Its TRQL is not typed by a person,
but it is also not something a code fix makes correct, so a query it
gets wrong is not worth waking anyone for. The same endpoint serves
built-in tiles whose TRQL we do write, so the opt-in lives with the
caller rather than the route.

Separately, when one of these queries did fail, the log recorded the
generated ClickHouse SQL but not the query the caller actually wrote,
which made the reports hard to act on. `queryWithStats` takes an
optional `logFields` that `executeTSQL` uses to attach the original
TSQL.

## Events were attributed to the wrong request

Chasing the above turned up something broader: only a tenth of the
events on that alert pointed at the query API. The rest were pinned to
unrelated requests that happened to be in flight at the same time, so
the alert looked like the trigger endpoint was failing.

`Sentry.init` runs with `skipOpenTelemetrySetup: true`, because we
register our own OTel pipeline. That skips `initOpenTelemetry`, and one
of the things it does is:

```js
api.context.setGlobalContextManager(new SentryContextManager());
```

The async-context strategy is still installed, but `withIsolationScope`
only marks the OTel context and delegates the actual fork to that
context manager:

```js
// "We depend on the otelContextManager to handle the context/hub"
return api.context.with(ctx.setValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, true), ...)
```

`provider.register()` installed a plain
`AsyncLocalStorageContextManager`, which does not know that key. The
lookup found no scopes on the context and fell back to the
process-global default isolation scope, so every request wrote its
request data into the same object and the last writer won.

The tracer now registers `SentryContextManager`, which subclasses
`AsyncLocalStorageContextManager`, so OTel behaviour is unchanged. It is
also registered on the path where tracing is disabled, which previously
never called `register()` at all and so had no context manager of its
own.

Tenant tags were always correct, because those come from our own async
local storage rather than the isolation scope. That is why the
attribution being wrong was not obvious.

This affects every error report the webapp sends, not just the query
API.

## Verification

`internal-packages/clickhouse`: 76 tests pass, including eight covering
each level decision against a real ClickHouse container. Three pairs pin
the gate open and shut at both layers: an invalid query, a compile
failure, and a real limit breach driven with `max_rows_to_read` each log
at warn with `userAuthoredQuery` and at error without it.

The isolation fix has a test that reproduces the leak before asserting
the fix. Two overlapping requests each tag their own isolation scope;
with the plain context manager the slower one reads back the other's
tag, and with `SentryContextManager` each reads back its own.

Measured separately against a faithful reproduction of the server's
wiring (own OTel pipeline, CommonJS entry) at 200 concurrent requests:
per-request attribution goes from 0.5% to 100%, while span nesting,
context propagation across awaits, and distinct trace IDs are identical
before and after.
2026-07-30 09:04:15 +01:00
Chris Arderne 2f1734c858 fix(core,webapp): redact sensitive fields in logs by default and cap their size (#4401) 2026-07-29 17:59:47 +01:00
Chris Arderne 8ebc8a41af fix(webapp,redis-worker): stop logging raw metadata, alert payloads, and job items (#4403) 2026-07-29 17:59:36 +01:00
Chris Arderne a09817169f fix(webapp): stop logging full batch item contents in batchTriggerV3 (#4404) 2026-07-29 17:59:27 +01:00
Chris Arderne ed8f5e1297 fix(webapp): stop logging every environment on a lookup miss (#4402) 2026-07-29 17:37:58 +01:00
Chris Arderne 878c15811a fix(cli): redact environment values from build debug logs (#4420) 2026-07-29 16:36:25 +00:00
Chris Arderne a81ad4949c feat(database,rbac): add multiple environment API key foundations (#4388)
Adds the storage model and authorization contracts needed for multiple
environment API keys. Credentials are represented by hashed values,
revocation and expiration state, and persisted effective scopes.

The built-in authorization fallback exposes full-access policy
preparation, while optional authorization extensions can supply
additional presets and task-aware scope generation. This change does not
create, display, or authenticate additional keys.
2026-07-29 16:24:00 +00:00
Eric Allam 4eb9292cbe feat(webapp,run-engine): queue metrics and health dashboard (#4131)
## Summary

Three related changes, each independently gated:

**Queue metrics and health.** Per-queue depth, throughput (enqueued,
started, completed), concurrency, whether a queue is throttled, and
scheduling delay (how long a run waits between becoming eligible and
actually starting), plus a per concurrency-key breakdown for keyed
queues. Collected from inside the run queue itself, stored in
ClickHouse, and surfaced on the Queues list, a new per-queue detail
page, the task pages, and the run inspector. The question it answers is
"does this queue have enough concurrency to keep up, and if not, which
key or which limit is the constraint".

**Percent-based queue concurrency limits.** A queue's concurrency
override can now be expressed as a percentage of the environment limit,
stored as the source of truth and re-materialized whenever the
environment limit changes. Absolute overrides above the environment
limit are now **rejected with a 400** instead of being silently capped,
which is a behavior change on `POST
/api/v1/queues/:queue/concurrency/override`.

**The `health` report.** A server-computed verdict on whether work is
flowing, whether the runs that do start are healthy, and whether
telemetry is fresh, rendered as text with sparklines. Available as `GET
/api/v1/reports/:key`, `trigger report`, and the `get_report` MCP tool
(plus a `report` MCP prompt, which shows up as a slash command in hosts
that support prompts).

With the flags off, the Queues page renders the pre-metrics component
verbatim, nothing is emitted, and nothing is written to ClickHouse.

## Configuration

Two independent gates, on purpose. Emission is global so data accrues
for everyone before anyone can look at it; the view is per organization
so it can be turned on for one org at a time without a deploy.

**Runtime flags (no restart)**

| Flag | Store | Gates |
| --- | --- | --- |
| `queue_metrics:enabled` | run-queue Redis key (`"1"`/`"0"`, off by
default) | All emission, gauges and counters. Cached in-process for 10s
with stale-while-revalidate, warmed eagerly at boot so the first op
after a deploy is not dropped. |
| `queue_metrics:gauge_sample_rate` | run-queue Redis key, `0..1` |
Fraction of queue ops that emit a gauge. Counters are never sampled, so
throughput stays exact at any rate. |
| `queueMetricsUiEnabled` | feature-flag catalog: global `FeatureFlag`
row, per-org `Organization.featureFlags` override wins | Whether an org
sees the metrics view at all: the Queues list variant, the queue detail
route, the built-in Queues dashboard, the concurrency-keys endpoint, and
the metrics blocks on task pages and the run inspector. Off by default;
a gated org gets a 404 on the detail route rather than an empty page. |

Both Redis keys are readable and writable from `/admin/queue-metrics`
(super-admin UI, with a live per-shard stream-health table) and
`GET`/`POST /admin/api/v1/queue-metrics` (admin PAT). The admin surface
uses its own Redis client, so it works on any instance regardless of
whether that instance runs the emitter or the consumer.

**Environment variables (boot time)**

| Variable | Default | Notes |
| --- | --- | --- |
| `QUEUE_METRICS_EMIT_ENABLED` | `0` | Constructs the emitter and
injects it into the run engine. Without it the run queue has no emitter
at all. |
| `QUEUE_METRICS_CONSUMER_ENABLED` | `0` | Boots the stream consumer on
this instance. Independent of emission, so consumers can be sized
separately from the API. |
| `QUEUE_METRICS_STREAM_SHARD_COUNT` | `4` | Stream shards, hashed per
queue. |
| `QUEUE_METRICS_CONSUMER_BATCH_SIZE` | `1000` | Poll batch equals
insert batch, so an ack can never outrun a write. |
| `QUEUE_METRICS_REDIS_{HOST,PORT,USERNAME,PASSWORD,TLS_DISABLED}` |
falls back to the run-queue Redis | Set `HOST` to move the metrics
stream onto a dedicated instance so a metrics backlog cannot compete
with the run queue for memory. Self-hosters can leave it unset and get a
single-Redis deployment. |
| `QUEUE_METRICS_COUNTER_STREAM_MAXLEN` | `2000000` shared, `8000000`
dedicated | Bound on how much a stalled consumer can hold. The default
is deliberately lower when the stream shares the queue-critical Redis. |
| `QUEUE_METRICS_COUNTER_ODOMETER_TTL_SECONDS` | `604800` | TTL on the
per-queue cumulative counter key, refreshed on every write, so only
queues idle for the whole window are purged. |
| `QUEUE_METRICS_MAX_QUEUE_NAMES_PER_ENV` | `1000` | Distinct queue
names tracked per environment; overflow collapses into `__overflow__`. |
| `QUEUE_METRICS_MAX_CONCURRENCY_KEYS_PER_QUEUE` | `10000` | Same idea
one level down, per queue. |
| `QUEUE_METRICS_GAUGE_SAMPLE_RATE` | `1` | Default for the live
sample-rate key above. |
| `QUEUE_METRICS_QUERY_TABLES_VISIBLE` | `0` | Lists the queue-metrics
tables in the Query page, its schema docs, the schema API and the AI
query context. Off keeps them unlisted while the feature is dark; a
query naming them still runs either way. |
| `QUEUE_METRICS_CLICKHOUSE_URL` | falls back to the shared wiring |
Runs queue metrics on their own ClickHouse service: the consumer's
inserts and every queue-metrics read go through it, so a metrics-heavy
chart refresh never competes with runs-list or trace reads. Unset
reproduces the previous split exactly (inserts on `CLICKHOUSE_URL`,
reads on the query pool). |
| `QUEUE_METRICS_CLICKHOUSE_READER_URL` | the write URL | Reader split,
so the consumer's inserts can never land on a read endpoint. |
|
`QUEUE_METRICS_CLICKHOUSE_{KEEP_ALIVE_ENABLED,KEEP_ALIVE_IDLE_SOCKET_TTL_MS,MAX_OPEN_CONNECTIONS,LOG_LEVEL,COMPRESSION_REQUEST}`
| `1`, unset, `10`, `info`, `1` | Pool tuning, matching the other
per-workload ClickHouse clients. |

Migrations to apply: ClickHouse `036_create_queue_metrics_v1.sql`, and a
Postgres migration adding the nullable
`TaskQueue.concurrencyLimitOverridePercent`. Both are additive.

## How collection works

Queue operations produce two kinds of signal, and they have opposite
failure modes, so they are handled differently.

**Gauges** (queued, running, queue limit, env queued, env running, env
limit, throttled, plus keys-with-backlog and worst-key wait on keyed
queues) are read *inside* the same Redis script that performs the
enqueue or dequeue, so the reading is atomic with the operation it
describes rather than a racy follow-up read. The script returns them on
its reply and the app forwards them to the stream. Gauges are sampled
and drop-tolerant: they are aggregated with `max`, so a lost reading
costs resolution, never correctness.

**Counters** (enqueued, started, completed, plus nack and dead-lettered)
are cumulative odometers. Each event increments a per-queue key on the
metrics Redis and emits the absolute total, and ClickHouse takes the
difference across buckets at read time. This is the important property
of the design: a summed-delta counter undercounts permanently on any
lost event, while a cumulative one self-heals, because the next
surviving reading restates the whole total. Only bucket granularity can
be lost, never the total. A queue returning after its odometer TTL
expired restarts at 1 and reset detection handles it, which is safe
precisely because expiry only spans a window with no activity.

Both land on one sharded Redis stream. A consumer reads it with a
consumer group, reclaims stale pending entries on a 15s interval rather
than on every poll, maps one entry to one or two ClickHouse rows
(whole-queue and, for keyed queues, per-key), and acks only after the
insert lands. Each batch carries a dedup token derived from its
stream-entry ids, and the target tables set
`non_replicated_deduplication_window`, so a retried batch cannot
double-count either the raw rows or the aggregates that hang off them.
Consumer and emitter both emit OTel metrics
(`queue_metrics.emitter.emitted`,
`queue_metrics.consumer.{entries,rows_inserted,insert_errors,insert_duration,stream_depth,group_lag,pending,lag_unknown}`);
stream depth and group lag are the two worth alerting on, and
`lag_unknown` exists because Redis can report a null lag after a trim,
which must not be read as zero.

## Storage and read path

`queue_metrics_raw_v1` is a short landing table with a 6 hour TTL. Four
aggregate tiers are materialized straight from raw, never cascaded off
each other, each with a 30 day TTL:

- `queue_metrics_v1`, 10 second buckets per queue, the default read path
- `queue_metrics_5m_v1`, 5 minute buckets per queue, for wide ranges and
cross-queue ranking
- `env_metrics_v1`, 10 second buckets per environment, queue-independent
so it stays cheap at any range
- `queue_metrics_ck_v1`, 10 second buckets per concurrency key

Every tier is an MV from raw because the counter states do not survive a
cascade: their merge is order sensitive, so a `-MergeState` chain off
the 10s table inflates the result, and the same property means an
aggregate state may only be merged inside one queue. That constraint is
now enforced by the query engine rather than by reviewer discipline: a
column can declare a `mergeGroupKey`, and any query that references it
without grouping by, or pinning to a single value of, every named key
fails to compile with an actionable message.

On the read side, TRQL gains three tables (`queue_metrics`,
`env_metrics`, and a `queue_metrics_by_key` that is hidden from the
editor, schema docs and schema API but still queryable, so per-key rows
can never silently merge into a plain per-queue query), plus
`deltaSumTimestampMerge` and `quantilesTDigestMerge`. Two schema-level
optimizations ride along: a table can declare coarser rollups, so a
query whose bucket interval is 5 minutes or wider is routed to the 5m
table with no change to the query itself, and it can opt into the
ClickHouse query cache with time bounds floored to a fixed grid, so the
auto-refreshing dashboards actually share cache entries instead of
missing on every tick. Both are caller-side substitutions, so the
printer stays unaware of physical layout.

All of this can also live on its own ClickHouse service. A table
declares the pool its reads run on, the three queue-metrics tables name
the dedicated one, and the ingestion consumer writes through the same
client, so both directions move together with one env var and nothing
else routes differently.

The other engine change is opt-in gap filling: charts can request rows
for empty buckets, where counters zero-fill and gauges carry forward.
Grouped gauge series are densified per group and carried inside a
partition, so a quiet queue's line holds its last value without bleeding
another queue's value into it.

## Queue concurrency limits

`concurrencyLimitOverridePercent` on `TaskQueue` is the source of truth
when an override is set as a percentage; the absolute `concurrencyLimit`
is materialized from it (floored, clamped to at least 1 so a percentage
can never act as a pause, and never above the environment limit). Every
path that changes an environment limit now recalculates the
environment's percent-based overrides afterwards, outside the
transaction, and pushes changed limits to the engine. The push is
attempted even when the stored value did not change, so a previously
failed sync self-heals rather than leaving the database and the engine
diverged; paused queues are skipped so a recalculation cannot
effectively unpause one.

The API accepts exactly one of `concurrencyLimit` or `percent`, and the
reject-instead-of-clamp change above means a request asking for more
than the environment allows now fails loudly. The percent bound (greater
than 0, at most 100) is defined once and shared by the zod schema, the
dashboard mutation handler and the service, so the three cannot drift.

The concurrency-keys table on a queue is now paginated against the
ClickHouse per-key tier, ranked by peak backlog with the total on every
row from a single scan, and only the keys on the current page are
enriched with live counts from Redis. That replaces a hard top-50 cap
with something whose cost is a function of page size rather than key
cardinality.

## The health report

`GET /api/v1/reports/:key?period=&format=markdown|ansi|json`. The
verdict is computed on the server and is deterministic, not
model-generated. Three independent analyzers run over one input
snapshot: flow (is work moving, and if not, is the cause a limit,
throttling, one bad queue, or dead-lettering), execution (are the runs
that start succeeding, and at what latency), and liveness (how fresh is
the telemetry). When telemetry is genuinely stale, the first two are
forced to unknown and every actionable field is stripped, so no surface
ever advises action off stale data.

Authorization is per query table rather than a blanket query grant: a
JWT must be scoped to every table the report reads (`runs`,
`env_metrics`, `queue_metrics`), so a narrowly scoped token cannot pull
a report that reads more than it was granted. `period` is validated as a
shorthand with a 90 day ceiling at the edge. The report catalog is a
registry of `{ load, interpret }` entries, so the next report is a new
entry and no change to the route, the view model, the renderers, the CLI
or the MCP tool.

`trigger mcp` no longer launches the install wizard when stdout is a
TTY, which fixed a real failure: hosts spawn the server over a PTY, so
the wizard would open and the client would time out waiting for a server
that never started. The wizard now needs `trigger mcp --install`.

## The part that is live regardless of every flag

The enqueue and dequeue scripts now return a 2-tuple so a gauge reading
can ride back on the reply. Every return site in the eight affected
scripts is wrapped, and a `nil` original is converted to `false` on the
way out, because a raw `nil` in the first slot would make Lua truncate
the multi-bulk reply and silently drop the gauge on the throttled and
empty-queue paths. The reply shape and the destructuring on the app side
are exercised on every queue operation whether or not metrics are
enabled, so that is the part of `run-engine` worth the closest review.

One behavior fix in the same area: the scheduling-delay anchor is set
only on a run's first entry into the queue. Anchoring it to trigger time
on re-enqueues made waitpoint and checkpoint resumes report the entire
wait as scheduling delay. Queue ordering is untouched, so a re-enqueued
run keeps its position, and nacks deliberately keep the original anchor
because a rolled-back dequeue is the same continuous wait.

A pending-version promotion still anchors to trigger time, on purpose:
that promotion is the run's first real entry into the queue, since the
trigger deliberately held it back waiting for a worker version, and the
TTL is armed at the same point for the same reason. The consequence is
worth naming, because it is a judgement call: a run that waits on a
deployment reports that wait as scheduling delay on its queue, which is
time unrelated to queue capacity.

## Verification

Unit and integration suites across the new package, the run queue, the
mapping layer, the query engine and ClickHouse (including a test that
applies migration 036 through the same splitter CI uses, and a
regression test that inserts the same batch three times to prove the
aggregates do not inflate). Beyond that, the whole path was driven end
to end against a live stack with real runs: emitter to Redis stream to
consumer to ClickHouse to the dashboards, for both the local dev path
and the deployed path where a supervisor drives the dequeue, with
assertions on exact counter reconstruction per queue and per concurrency
key, throttling, environment saturation, scheduling delay, and a
deliberate mid-stream reading drop to confirm the cumulative counters
still reconstruct the correct total. The gated-off state was checked on
every touched surface.

The dedicated ClickHouse service was verified against a second,
separately-schema'd instance: with it configured, the driven counters
reconstruct exactly on the dedicated instance, the shared instance gains
no rows for that window, a read through the query API returns the value
that exists only on the dedicated instance, and a `runs` query still
succeeds (it would fail outright if it were mis-routed to a service
without that table). With the variable unset, the full suite passes
unchanged.

---------

Co-authored-by: Katia Bulatova <katia@trigger.dev>
Co-authored-by: Katia Bulatova <katherine.bulatova@gmail.com>
Co-authored-by: James Ritchie <james@trigger.dev>
2026-07-29 16:45:24 +01:00
claude[bot] 639eaf6e82 fix(webapp): don't apply an invite's role to an existing org member (#4409)
<!-- ccr-slack-attribution -->
_Requested via [Slack
thread](https://triggerdotdev.slack.com/archives/C097ZHVKZFA/p1785249693523749)_

## Summary

Accepting an old invitation could change the role of someone who was
already in the organization. A long-pending invite can carry a lower
role than the member has since been promoted to, so accepting it was a
silent demotion. When the accepting user was the organization's only
Owner, the role layer refused that demotion, and the refusal (an
expected, protective outcome) was logged as an error.

An invitation now only sets a role on a membership the accept actually
created, and people who are already in an organization are skipped when
invitations are sent.

## How

`acceptInvite` already skipped the `OrgMember` create when it found an
existing membership, but the `rbac.setUserRole` call below it was gated
only on `invite.rbacRoleId`. It now also tracks whether this accept
created the membership. A create that loses the unique-constraint race
counts as pre-existing, since whichever flow won it owns that
membership's role.

Skipping existing members outright would regress one case: a member with
no RBAC role at all would never receive the invitation's role.
`ensureOrgMember` handles that with `healMissingRoleAssignment`, which
fills in a null role but never overwrites a real one, so
`assignInviteRbacRole` takes the same gate. An established role is never
touched; an absent one is filled in.

`assignInviteRbacRole` branches on the result's machine-readable `code`
instead of logging every refusal at `error`. `last_owner` goes to
`logger.info`, matching the two directory-sync role paths; everything
else, including a refusal that carries no code, goes to `logger.warn`.
The helper is best-effort and never throws, so no outcome it produces
warrants `error`. No string matching on the error text is involved.

`inviteMembers` resolves the organization's members by email and skips
those addresses before creating invites. The invite table's
`@@unique([organizationId, email])` only dedupes *pending invites*, so
it could never catch this.

## Invite surfaces

Skipping addresses means a batch can now come back empty, and neither
caller handled that:

- The dashboard action built its redirect from
`invites[0].organization`, so a batch where every address was skipped
threw a `TypeError` that reached the admin as a raw error string. It
also reported the submitted count rather than the created one. It now
names what it skipped ("No invitations sent: 1 already a member of this
organization") and counts what it actually created.
- The invites API derived `alreadyInvited` as "everything not created",
so an existing member was reported as though they had already been
invited. `inviteMembers` now returns the two groups separately and the
endpoint reports `alreadyMembers` alongside `alreadyInvited`.

## Testing

`apps/webapp/test/member.server.test.ts` passes 16/16 locally, up from
12.

Getting there needed a harness fix. The `~/db.server` mock did not
export `Prisma`, so any code reaching
`PrismaNamespace.PrismaClientKnownRequestError` threw before it could
branch, leaving every duplicate-key path in `member.server.ts`
unreachable from tests. The mock now re-exports the real `Prisma`, and
there is a case covering the pending-invite skip.

New cases: the invite role is applied when the accept creates the
membership; it is not applied when the member already has a role; it is
applied when an existing member has no role assigned; the organization
is still joined when the assignment is refused with `last_owner`; and
`inviteMembers` reports members separately from pending invites. Forcing
the gate off fails exactly the "already has a role" case, so the
coverage is load-bearing.

`pnpm run typecheck --filter webapp` and `oxfmt --check` both pass.

## Changelog

Accepting an old invitation could change the role of someone who was
already in the organization. An invitation now leaves an existing
member's role untouched, people who are already in an organization are
no longer sent invitations to it, and the invite form says which
addresses it skipped instead of failing with an unhelpful error.

---

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

## Screenshots

No visual changes. The invite form's toast copy changes, as described
above.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2026-07-29 15:31:59 +01:00
DKP 8d321f8d6e docs: give docs pages unique title tags and redirect stale pages (#4416)
## Summary

Several docs pages rendered identical `<title>` tags, which weakens
search indexing and makes results ambiguous. Each affected page now has
a unique, descriptive title while keeping its existing sidebar label
unchanged.

Alongside the retitles:

- Removed two stale build-system upgrade pages that were no longer in
the navigation, with redirects to the current package upgrade guide.
- Redirected the build-extensions group index to its overview page so
the two URLs stop sharing a title.
- Dropped a leftover orphaned API reference page (its old URL already
redirects to the management overview).

No links break: nothing in the docs points at the removed pages, and
every redirect target exists.
2026-07-29 12:09:45 +01:00
Chris Arderne 15e160d767 chore(ci): cache typecheck work across runs (#4415) 2026-07-29 11:19:16 +01:00
James Ritchie a11e5ffbc6 fix(webapp): fade overflowing side menu selector labels (#4412)
Long organization, project, and environment names in the side menu were
cut off mid-character. They now fade out at the right edge like the rest
of the side menu items already did.

### Example of faded long names:
<img width="246" height="200" alt="CleanShot 2026-07-28 at 22 59 24"
src="https://github.com/user-attachments/assets/efa60b87-286f-4ab0-9d4e-490ef2de53e5"
/>
2026-07-29 10:16:45 +01:00
James Ritchie 1e14e29d71 fix(webapp): restyle the leave and remove team member dialogs (#4411)
## Summary

The confirmation dialog for leaving a team or removing a teammate was
still built on the old `Alert` primitive: the entire question sat in the
title, there was no header divider or `Esc` affordance, and the footer
used small buttons pinned to the right.

It now uses the standard `Dialog` layout the rest of the dashboard uses.
The title is static ("Remove team member" / "Leave team"), the question
moves into the body with the person's name and the organization
highlighted, and the footer is a bordered row with medium Cancel and
confirm buttons. A member who has not set a name is now identified by
their email instead of "them".

Verified against a local dashboard on both dialogs. Confirming a removal
posts the member id, deletes the membership and shows the success toast.
Cancel, `Esc`, and Enter while Cancel is focused all close the dialog
without issuing a request, leaving the member in place.

No release note needed: this is a visual restyle of an existing dialog
with no behaviour change.
2026-07-28 23:05:20 +01:00
Matt Aitken 205bdc3103 docs(wait): separate compute billing from concurrency release (#4405)
The wait docs describe the 5 second compute-billing threshold as if it
were also the suspension threshold. It isn't, and the gap is confusing
when you're sizing a poll interval:

- **Compute** stops being charged for any wait longer than 5 seconds.
- **Concurrency** is only released once the machine has been snapshotted
and shut down. For `wait.for` and `wait.until` that happens 60 seconds
into the wait — a shorter wait stays `EXECUTING` and holds its
concurrency slot for the whole wait, even though the compute is free.

So `await wait.for({ seconds: 30 })` in a polling loop never releases
its slot, which looks like a bug if the docs told you waits over 5
seconds checkpoint.

## Changes

**`docs/snippets/paused-execution-free.mdx`** — rendered on `/wait`,
`/wait-for` and `/wait-until`. Drops "we checkpoint and" from the
billing sentence so it's purely about compute, then adds one paragraph
for the concurrency half.

**`docs/queue-concurrency.mdx`** — the "Waits and concurrency" section
states flatly that waiting runs don't consume slots. Adds a short
subsection for the time-based exception.

**`docs/how-to-reduce-your-spend.mdx`** — "Waits longer than 5 seconds
automatically checkpoint your task, meaning you don't pay for compute" →
the compute claim only. Code comments follow, plus a pointer that
waiting doesn't always free concurrency.

**`docs/how-it-works.mdx`** — the Checkpoint-Resume walkthrough used
`wait.for({ seconds: 30 })` as *the* example of a wait that suspends.
Bumped to 5 minutes and noted the sub-60s exception.

No behaviour change — docs only.
2026-07-28 15:20:00 +01:00
Chris Arderne 38bf82aebe feat(cli,webapp): target notifications by minimum CLI version (#4407) 2026-07-28 14:23:41 +01:00
Saadi Myftija 44eca4d166 feat(webapp): org-gated internal API origin in run env vars (#4366)
Adds an opt-in way for operators to route deployed runs' API traffic
through a different origin than the public one, per organization. Set
`INTERNAL_API_ORIGIN` on the webapp and enable the
`internalApiOriginEnabled` feature flag (globally or per org, with the
org override winning in both directions): deployed runs for enabled orgs
then get `TRIGGER_API_URL` set to the internal origin instead of
`API_ORIGIN`. Useful for gradually moving run traffic onto a private
network path.

## Design

The origin is resolved when an attempt starts, so flag changes take
effect on the next attempt and roll back the same way, with no task
redeploys. The org override is read fresh per attempt; the global
default comes from the cached flags registry (a cold read fails safe to
the public origin). When `INTERNAL_API_ORIGIN` is unset the flag is a
no-op and no extra queries run, so existing deployments are unaffected.
Dev runs always use the public origin, and `TRIGGER_STREAM_URL` remains
unchanged.
2026-07-28 11:28:09 +02:00
claude[bot] ec562c0e68 fix(webapp): remove unused Electric sync trace routes (#4400)
<!-- ccr-slack-attribution -->
_Requested by **Eric Allam** · [Slack
thread](https://triggerdotdev.slack.com/archives/C0AU83M3136/p1785222101937829?thread_ts=1785207509.304669&cid=C0AU83M3136)_

Removes two dead Remix routes and the helpers only they used.

`app/routes/sync.traces.runs.$traceId.ts` (`/sync/traces/runs/:traceId`)
and `app/routes/sync.traces.$traceId.ts` (`/sync/traces/:traceId`) were
added with the original ElectricSQL run page and lost their only
consumers when the dashboard hooks that called them were deleted.
Nothing in the repo references either route today.

Also removed, because the deleted routes were their only callers:

- `OtelTraceIdSchema`, `RESERVED_ELECTRIC_SHAPE_PARAMS`, `TraceScope`,
`buildElectricTraceWhereClause` from `app/v3/electricShape.server.ts`
(the file stays — `UNSAFE_REALTIME_TAG_CHARS` /
`sanitizeRealtimeTagForSql` / `sanitizeRealtimeTagsForSql` are still
used by `realtime.v1.runs.ts` and `realtimeClient.server.ts`)
- the loader-specific cases in
`apps/webapp/test/spanTraceRoutes.replicaLag.test.ts` and
`internal-packages/run-store/src/runOpsStore.routesSpanTraceReadView.replicaLag.test.ts`

`app/utils/longPollingFetch.ts` is untouched —
`realtimeClient.server.ts` still uses it. `runOpsStore.ts` /
`PostgresRunStore.ts` are untouched too; the unrouted-lookup mechanism
there is generic and stays.

As a plain code fact: the run lookup these loaders performed keyed on
`TaskRun.traceId` alone, which is not an index-backed query shape. That
is noted only as context for why the code is not worth keeping around
unused.

### Judgement call worth a maintainer's opinion

The request was specifically about `/sync/traces/runs/:traceId`, the
route that looks up a run by `traceId`. This PR **also** deletes its
sibling `/sync/traces/:traceId`. The reasoning:

- both routes came in with the same ElectricSQL run-page work
- both lost their only consumers in the same later commit
- neither has any caller anywhere in the repo
- they share the same helper module, so keeping one means keeping the
helpers half-used

If you would rather keep the sibling, reverting just that one file
deletion is easy and does not affect the rest of this PR — say the word
and I will restore it along with the helpers it needs.

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Verification run locally from the repo root:

| Command | Result |
| --- | --- |
| `pnpm run format` | clean, no changes produced |
| `pnpm run lint:fix` | clean |
| `pnpm run lint` | pass (exit 0, no findings) |
| `pnpm run typecheck --filter webapp` | pass |
| `pnpm run typecheck --filter @internal/run-store` | pass |

A ripgrep sweep for `sync.traces`, `sync/traces`, `syncTraceRunsLoader`,
`buildElectricTraceWhereClause`, `OtelTraceIdSchema` and
`RESERVED_ELECTRIC_SHAPE_PARAMS` (excluding `node_modules`) returns zero
hits.

**Not fully verified:** both edited test files are testcontainers suites
and need a Docker runtime, which was not available in my environment. I
confirmed each file *collects* correctly with exactly the three intended
remaining tests and no import errors — notably, dropping the
`session.server` / `controlPlaneResolver.server` / `longPollingFetch` /
`env.server` mocks does not break module loading for the surviving
loaders. The assertions themselves then failed only on `Could not find a
working container runtime strategy`. CI should be the real signal here.

Per `apps/webapp/CLAUDE.md`, `pnpm run build --filter webapp` was
deliberately not run.

---

## Changelog

Removed two unused sync routes left over from the original ElectricSQL
run page, along with the helpers and tests that existed only to serve
them. No behaviour change — neither route had any caller.

---

## Screenshots

_n/a — no user-visible surface changes._

💯

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 09:00:48 +01:00
Matt Aitken 3ed48516df ci: let the claude bot trigger the PR audit workflows (#4392)
🚀 Publish Trigger.dev Docker / units (push) Failing after 1s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 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
🧭 Helm Chart Prerelease / lint-and-test (push) Has been cancelled
Workflow Checks / Actionlint (push) Has been cancelled
Workflow Checks / Zizmor (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
🧭 Helm Chart Prerelease / prerelease (push) Has been cancelled
## Summary

PRs opened by the claude GitHub app fail both the agent instructions
audit and the REVIEW.md drift audit before Claude gets a chance to run.
`claude-code-action` refuses any actor whose account type is not `User`
unless the actor is listed in `allowed_bots`:

```
Workflow initiated by non-human actor: claude (type: Bot).
Add bot to allowed_bots list or use '*' to allow all bots.
```

So those PRs land with two permanently red checks and no audit coverage
at all. Both workflows already allowlist Devin; this adds the claude app
alongside it.

## Why this does not open the workflows up to outside contributors

`allowed_bots` is only consulted for non-`User` actors. Humans,
contributor or maintainer, take the separate write-permission path and
are unaffected by what is in the list.

Beyond that, both jobs are guarded by
`github.event.pull_request.head.repo.full_name == github.repository`, so
a fork PR skips the job entirely, and they trigger on `pull_request`
rather than `pull_request_target`, so a fork-triggered run would get no
API key and a read-only token anyway.

The bot is named explicitly instead of using `"*"`, which would let
every bot trigger these audits, dependabot's PR stream included.
2026-07-27 17:22:46 +01:00
Eric Allam fc576436e2 perf(run-ops-database): index BatchTaskRun for the batches list on the dedicated schema (#4396)
## Summary

The batches list page orders by `(createdAt DESC, id DESC)`, which is
why [#4361](https://github.com/triggerdotdev/trigger.dev/pull/4361)
added a matching index on `BatchTaskRun`. That index only landed in
`@trigger.dev/database`.

The dedicated run-ops database has its own migration history, so it
never received the index. `BatchListPresenter` reads both databases and
merges, so for environments whose batches live in the dedicated database
the page kept falling back to a scan and in-memory sort, which is the
exact behaviour #4361 set out to fix.

## Fix

Adds the index to the run-ops schema with its own migration. `CREATE
INDEX CONCURRENTLY IF NOT EXISTS`, so it is a no-op where the index
already exists and still records its ledger row.

The second half is the interesting part. Because the two packages own
separate migration histories, a run-graph schema change has to be
authored twice, and nothing made the miss visible: the run-ops status
check truthfully reports "up to date" against its own history, so the
apply step just skips.

`schemaParity.test.ts` compares the physical shape of every model the
run-ops schema declares against its counterpart in
`@trigger.dev/database`: scalar fields with their attributes, plus
`@@index`, `@@unique`, `@@id` and `@@map`. Relation navigation fields
are excluded, since the run-ops schema deliberately drops relations that
would cross a database boundary while keeping the scalar FK column. A
field counts as a relation when its type resolves to a model name, which
keeps enum-typed columns in scope.

Two models are listed as run-ops-only: `CompletedWaitpoint` and
`WaitpointRunConnection`, both explicit FK-free replacements for a
control-plane implicit many-to-many, since an implicit m2m carries a
foreign key that cannot resolve across databases. The test also asserts
that exception list is exhaustive, so a new unpaired model fails rather
than being silently skipped.

Confirmed the guard actually fails: reverting the index turns
`BatchTaskRun` red with the missing `@@index` named in the diff.
2026-07-27 16:59:43 +01:00
github-actions[bot] d189ce17d3 chore: release v4.5.8 (#4364)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 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
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
## Summary
2 new features, 9 improvements, 3 bug fixes.

## Highlights

- Allow additional environment API keys to create scoped public access
tokens through the Trigger.dev API. Use server-issued public access
tokens for batch operations so environment-scoped API keys can read
batch results.
([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387))

## Improvements
- Preserve the partial assistant message when a chat turn's model stream
fails mid-response. `chat.agent` now passes the recovered partial to
`onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it
before rethrowing, instead of dropping the streamed-so-far output.
([#4348](https://github.com/triggerdotdev/trigger.dev/pull/4348))

## Server changes

These changes affect the self-hosted Docker image and Trigger.dev Cloud:

- Favorite any dashboard page to a new Favorites section in the side
menu, and customize the sidebar by renaming favorites, hiding items, and
reordering items and sections.
([#4375](https://github.com/triggerdotdev/trigger.dev/pull/4375))
- List API endpoints now clamp the page size to a maximum of 100.
Requests asking for a larger page size return up to 100 items and keep
paginating, rather than pulling an unbounded page.
([#4360](https://github.com/triggerdotdev/trigger.dev/pull/4360))
- Organizations without billing alerts now get default spend alert
thresholds, so you're notified before usage grows unexpectedly. The
billing limit page no longer pre-selects an option before you've set a
limit and prompts you to configure one. Alert previews now update
immediately after you change your billing limit.
([#4328](https://github.com/triggerdotdev/trigger.dev/pull/4328))
- When you create a Personal Access Token, the generated token now shows
its first and last few characters instead of being fully hidden, so you
can confirm you copied the right value.
([#4363](https://github.com/triggerdotdev/trigger.dev/pull/4363))
- Add metrics to the realtime backend that measure how often a single
changed run is served to multiple subscriptions in one batch.
([#4341](https://github.com/triggerdotdev/trigger.dev/pull/4341))
- Realtime run subscriptions can now be configured to read run data
straight from the primary database, so a run's latest state is never
served from a lagging replica. Off by default; replica reads are
unchanged unless you turn it on.
([#4378](https://github.com/triggerdotdev/trigger.dev/pull/4378))
- SSO and Directory Sync are no longer restricted to Enterprise plans —
get in touch and we can turn them on for your organization whatever plan
you're on.
([#4393](https://github.com/triggerdotdev/trigger.dev/pull/4393))
- Improved supervisor observability: it now reports metrics for its
outbound requests, making failed calls to upstream services easier to
monitor.
([#4350](https://github.com/triggerdotdev/trigger.dev/pull/4350))
- The runs list on a task's page now updates live — run statuses change
and newly triggered runs appear without a manual refresh, matching the
main Runs page.
([#4377](https://github.com/triggerdotdev/trigger.dev/pull/4377))
- Speed up the Batches list page for environments with a large number of
batches, which could previously time out while loading.
([#4361](https://github.com/triggerdotdev/trigger.dev/pull/4361))
- Container startup no longer prints database and ClickHouse connection
strings (with credentials) to the logs.
([#4346](https://github.com/triggerdotdev/trigger.dev/pull/4346))
- The tasks page no longer runs two queries whose results were never
displayed, cutting wasted work on every page load and removing a source
of hidden server errors
([#4380](https://github.com/triggerdotdev/trigger.dev/pull/4380))

<details>
<summary>Raw changeset output</summary>

# Releases
## @trigger.dev/build@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.8`
## trigger.dev@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.8`
  - `@trigger.dev/build@4.5.8`
  - `@trigger.dev/schema-to-json@4.5.8`
## @trigger.dev/core@4.5.8

### Patch Changes

- Allow additional environment API keys to create scoped public access
tokens through the Trigger.dev API. Use server-issued public access
tokens for batch operations so environment-scoped API keys can read
batch results.
([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387))
## @trigger.dev/python@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/sdk@4.5.8`
  - `@trigger.dev/core@4.5.8`
  - `@trigger.dev/build@4.5.8`
## @trigger.dev/react-hooks@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.8`
## @trigger.dev/redis-worker@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.8`
## @trigger.dev/rsc@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.8`
## @trigger.dev/schema-to-json@4.5.8

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.8`
## @trigger.dev/sdk@4.5.8

### Patch Changes

- Preserve the partial assistant message when a chat turn's model stream
fails mid-response. `chat.agent` now passes the recovered partial to
`onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it
before rethrowing, instead of dropping the streamed-so-far output.
([#4348](https://github.com/triggerdotdev/trigger.dev/pull/4348))
- Allow additional environment API keys to create scoped public access
tokens through the Trigger.dev API. Use server-issued public access
tokens for batch operations so environment-scoped API keys can read
batch results.
([#4387](https://github.com/triggerdotdev/trigger.dev/pull/4387))
- Updated dependencies:
  - `@trigger.dev/core@4.5.8`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-27 16:40:31 +01:00
James Ritchie 73eb4c5c16 feat(webapp): Improve the Integrations page layout (#4379)
## Summary

The project Integrations page now uses the same settings layout as the
org SSO page: a centered column of titled rows with dividers, instead of
headings over bordered boxes. GitHub, Vercel and build settings read as
one consistent list, and the page titles itself "Integrations".

Confirmations persist rather than vanishing once you move past them
(`GitHub app: Installed`, `Vercel project: Connected`), plan-gated rows
offer an Upgrade button instead of a dead toggle, a disabled toggle
explains why in place and highlights the control that unlocks it, and
warnings are rows with a hazard icon and their recovery action on the
right. Copy throughout leads with the outcome instead of restating the
field label.

Two fixes along the way: a nested `<form>` in the Vercel panel that
failed hydration and silently truncated the page, and every settings row
carrying a few pixels more space above its title than below its
description.

### Before
<img width="1160" height="1972" alt="CleanShot 2026-07-26 at 21 56
42@2x"
src="https://github.com/user-attachments/assets/ed0fd676-36d8-4eb7-a16e-827a24f007d9"
/>


### After
<img width="1358" height="4455" alt="CleanShot 2026-07-26 at 19 14
28@2x"
src="https://github.com/user-attachments/assets/6a635e6a-c0eb-4a4c-a68f-fcde4d25e8a6"
/>
2026-07-27 16:34:44 +01:00
James Ritchie d30ee6e570 feat(webapp): favorite pages and sidebar customization (#4375)
## Summary

Favorite any dashboard page and it appears in a new "Favorites" section
at the top of the side menu. The star next to the page title (or
Option+F) saves the exact view, filters and tabs included, with a name
derived from the URL ("Runs: Completed successfully, last 7d", "Run:
05hrqq9n") that you can rename inline from each item's hover menu.

The sidebar is customizable too: "Customize sidebar" (on section header
menus and in each "More" menu) opens a modal where you can reorder
sections, drag items into a new order, hide items behind a per-section
"More" popover, and rename or remove favorites. Changes apply on
Confirm, Reset restores the default layout without touching favorites,
and everything is stored per user in dashboard preferences.

## Screenshots

| Favorites in the side menu | Customize sidebar modal |
| --- | --- |
| ![Favorites section with rename and remove
menu](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/favorites-side-menu.png)
| ![Customize sidebar
modal](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/customize-sidebar-modal.png)
|

![Favorite star and tooltip in the page
header](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/star-tooltip.png)

## Design notes

- Favorite links carry a small marker search param so the favorite, not
its identical main menu item, highlights as active. Markers from shared
or stale links are cleaned on load, and changing any filter hands the
highlight back to the regular menu item.
- Preference writes are serialized with a row lock: several writers
(debounced collapse and width saves, favorite toggles, the customize
modal) can land concurrently and would otherwise clobber each other's
read-modify-write of the JSON column.
- Option+F is matched on `event.code` with a raw listener because macOS
reports Option-modified letters as symbols, which the `event.key` based
shortcut hook can't capture.

Verified end-to-end in the browser: star toggle and shortcut, instant
section appearance, inline rename and staged modal removal, filter-aware
labels and unique active states, shared-link normalization, drag
reordering, and persistence across reloads.
2026-07-27 16:29:36 +01:00
Chris Arderne efd0ee8d74 feat(core,sdk): support additional environment API keys (#4387)
## Summary

Additional environment API keys can use SDK APIs that require public
access tokens. The SDK detects the additional-key format and asks the
Trigger.dev server to mint scoped tokens instead of attempting to sign
them locally.

Root environment keys retain their existing local-signing behavior.
Trigger and batch clients also prefer server-issued tokens returned in
response headers while preserving compatibility with older servers.

## Deployment notes

This package update is safe to publish before servers expose additional
key creation. Existing root keys continue to use the current path, while
an additional key used with an older server fails with an actionable
upgrade error.
2026-07-27 15:19:27 +00:00
James Ritchie 3e53404f40 feat(webapp): live-update the runs list on task pages (#4377)
## Summary

The runs list on a task's page now updates live, matching the main Runs
page. Run rows update their status, duration, and cost in place as runs
progress, and a "N new runs" button appears in the header when newer
runs come in so you can pull them into the list without a manual
refresh. This applies to both standard and scheduled task pages.

## Design

It reuses the Runs page's polling hook. A task page scopes its runs by
the task in the URL path rather than a `tasks` query filter, so the hook
now takes an optional task slug and scopes new-run detection to it. The
"new runs" button sits in the header, outside the deferred runs table,
so the count is lifted to the page and the click action is passed
through a ref. That keeps the table streaming on first load instead of
blocking the header on the runs query.

When newer runs come in, a `1 new run` button appears in the task page
header, to the left of the time filter. Clicking it pulls the new runs
into the list.
2026-07-27 15:41:47 +01:00
nicktrn e8a2dbd605 chore: ignore local docs/superpowers planning docs (#4395)
Adds a gitignore rule for `**/docs/superpowers/` so locally-generated
planning and design scratch docs under that path aren't committed;
preventive only, no-op for existing tree.
2026-07-27 12:44:14 +00:00
Matt Aitken 269470fd87 feat(webapp): gate SSO on an entitlement instead of the Enterprise plan (#4393)
The SSO & Directory Sync settings page decided access by comparing the
organization's plan code against the literal string `"enterprise"`. The
webapp now reads a `hasSso` entitlement from plan limits.

## Changes

- **`settings.sso` route** — `planAllowsSso` reads `limits.hasSso`
rather than the plan code; the loader and the action gate on a shared
`getSsoEntitlement` helper.
- **`platform.v3.server`** — new `getSsoEntitlement(orgId)` returning
`entitled | not_entitled | unknown`, behind a new SWR cache namespace
(60s fresh / 120s stale, memory + Redis). This replaces an uncached
billing round-trip that previously ran on every settings load, so the
page gets cheaper than it was.
- **`directorySyncEffects`** — the entitlement is now checked before
applying membership effects, per organization and memoised across a
batch.
- **`@trigger.dev/platform` 1.2.0 → 1.3.0** — required, see below.

## Behaviour worth reviewing

**Revocation now stops SCIM.** Previously the plan check existed only on
the settings page, so an org that lost access kept receiving
directory-sync pushes indefinitely; only the config UI froze. Provision
*and* deprovision are gated, so a revoked entitlement can't remove
members either.

**An unreadable entitlement throws instead of skipping.** Effects are
idempotent and the worker retries, so retrying is lossless where
dropping would silently lose a directory change. It's raised at `warn`
level so a transient billing blip doesn't page anyone.

**The login path is deliberately untouched.** A hard entitlement check
there turns a billing outage into a login outage. Consequence: an org
that loses the entitlement keeps its existing SSO logins working until
the connection is removed. Gating sign-in is a separate decision.

**Self-hosted is unaffected.** With no billing service configured the
helper returns `entitled`, leaving plugin presence and the kill switch
as the only gates — a self-hoster who installed the plugin isn't locked
out of it.

## The dependency bump is load-bearing

The `Limits` schema is a plain `z.object`, so it *strips* unknown keys.
On 1.2.0 the `hasSso` field was silently discarded during parsing and
read as `undefined` no matter what billing sent — a structural accessor
would not have helped. Verified against both builds:

```
1.2.0 → parsed: true | hasSso survives: false
1.3.0 → parsed: true | hasSso survives: true
```

This PR therefore cannot merge before 1.3.0 is published, which it now
is.

## Testing

`apps/webapp/test/directorySyncEffects.server.test.ts` — 7 tests over
the gate: applies when entitled, skips provision and deprovision when
not, throws a warn-level retryable error when unreadable, resolves once
per org across a batch, and gates per org so one unentitled org doesn't
block another.

`pnpm run typecheck --filter webapp` passes (18/18), oxfmt and oxlint
clean.
2026-07-27 13:08:16 +01:00
claude[bot] 72c2b2c650 chore(deps): bump express-rate-limit and ip-address (#4391)
**Before:** `ip-address` resolved twice in `pnpm-lock.yaml` — `8.1.0`
under `@jsonhero/json-infer-types`, and `10.0.1` under
`express-rate-limit`.

**After:** a single `ip-address@10.2.0` entry, shared by both chains.

**How:** `express-rate-limit@8.2.1` pinned `ip-address` to an exact
version, so the parent itself had to move — `8.5.1` onwards declares a
range instead, and `@modelcontextprotocol/sdk` already allows `^8.2.1`,
so scoping that parent to `^8.6.0` lets `ip-address` resolve on its own.
`@jsonhero/json-infer-types` caps `ip-address` at `^8.1.0` and is
already at its latest published release, so that chain gets a scoped
override instead of a parent bump. `jsbn` and `sprintf-js` drop out of
the tree as a side effect.

Both overrides are parent-scoped, so the `cli-v3` chain is deliberately
untouched: it resolves `@modelcontextprotocol/sdk` 1.25.2, which
declares `express-rate-limit ^7.5.0` and pulls in no `ip-address` at
all.

`pnpm-lock.yaml` regenerated. `package.json` and `pnpm-lock.yaml` are
the only two files changed.

Nothing in the repo imports `ip-address` or `express-rate-limit`
directly. Both chains are transitive under `apps/webapp` —
`@jsonhero/schema-infer` (used by `TestTaskPresenter.server.ts`) and
`@vercel/sdk` — so no published `@trigger.dev/*` package is affected.

---

## Testing

- `pnpm install --lockfile-only` regenerates cleanly, and `pnpm install
--frozen-lockfile --lockfile-only` passes, so the lockfile matches the
manifests.
- Package churn is limited to the intended set: `express-rate-limit`
8.2.1 to 8.6.0, `ip-address` 8.1.0 and 10.0.1 collapsing to 10.2.0, and
`jsbn` / `sprintf-js` removed. No other resolution moved.
- `@jsonhero/json-infer-types` only calls `new Address4()` / `new
Address6()` inside a try/catch to classify strings. Ran that exact logic
against both `8.1.0` and `10.2.0` over 27 inputs (v4, v6, zone IDs,
CIDR, IPv4-mapped, malformed, empty, non-strings): identical results in
all 27. Both are still CJS named exports in `10.2.0`, with the same
`engines` floor.
- Drove the real `inferSchema()` path from `@jsonhero/schema-infer` with
`ip-address` forced to `10.2.0`; it still detects `ipv4` and `ipv6`
formats correctly.
- `express-rate-limit` 8.6.0 keeps the same `express` peer range (`>=
4.11`) and the same node floor as 8.2.1. Its new `debug` dependency
resolves to a version already present in the tree.
- `oxfmt --check` passes on the modified `package.json`.
- Both bumped versions clear the repo's `minimumReleaseAge` window; the
newest `express-rate-limit` (8.6.1) and `ip-address` (10.2.1+) releases
do not yet, which is why this lands on 8.6.0 and 10.2.0.
- Not run here: a full monorepo install, typecheck and test suite. No
TypeScript changed, and neither package leaks types into ours —
`ip-address` is not referenced in `json-infer-types`' or
`schema-infer`'s declaration files — so CI should be the judge of the
wider suite.

---

## Changelog

Routine dependency maintenance, no behaviour change. No changeset or
`.server-changes/` entry: the diff touches only the root `package.json`
and `pnpm-lock.yaml`, not `packages/*`, `integrations/*`, `apps/webapp/`
or `apps/supervisor/`.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2026-07-27 11:09:49 +00:00
claude[bot] d91818f198 fix(webapp): remove unawaited task list metrics promises (#4380)
<!-- ccr-slack-attribution -->
_Requested via [Slack
thread](https://triggerdotdev.slack.com/archives/C097ZHVKZFA/p1785082528841609)_

`TaskListPresenter` created promises that nothing ever consumed. Two of
the three deferred metrics promises it returned had no reader, no
`await` and no `.catch()`, so when the query behind one of them failed
the rejection had nowhere to go.

## Before / After

**Before**

- `TaskListPresenter.call()` returned four things: `tasks`, `activity`,
`runningStats` and `durations`. Its only caller reads `tasks` and
`runningStats`.
- Every load of the tasks page therefore fired two ClickHouse queries
whose results were thrown away.
- If either of those two queries failed, the resulting promise rejection
was unhandled — nothing was awaiting it and nothing had attached an
error handler, so it surfaced as an unhandled rejection at the process
level rather than as an error anyone could attribute to a request.

**After**

- `TaskListPresenter.call()` returns `tasks` and `runningStats` only.
- Two fewer queries run per tasks-page load.
- There is no longer an unconsumed promise that can reject without a
handler. `runningStats` is awaited by its caller, so its failures
continue to be handled the way they always were.

Nothing changes on screen: the tasks page renders `hourlyActivity` and
`runningStates`, and neither of the removed values fed either of those.

## How

The removed values were verified unreferenced before deleting anything:

- `TaskListPresenter` has exactly one caller,
`UnifiedTaskListPresenter`, which reads `taskResult.tasks` and
`taskResult.runningStats` and nothing else.
- No file anywhere in the repo — app code, tests, or type re-exports —
reads an `activity` or `durations` field off the presenter's result.
- `UnifiedTaskListPresenter` builds its own
`unifiedTaskListHourlyActivity` query for the 24h chart the page
actually renders, which is what made the presenter's separate 7-day
daily activity data redundant.
- `getDailyTaskActivity` and `getAverageDurations` on
`ClickHouseEnvironmentMetricsRepository` had no callers other than the
two lines being deleted, so they and their now-orphaned helpers and
types were removed too.

Changes:

- `apps/webapp/app/presenters/v3/TaskListPresenter.server.ts` — drop the
`activity` and `durations` fields (both from the main return and from
the no-current-worker early return) and the two repository calls behind
them. Drop the unreferenced `TaskActivity` type alias. The "don't await
this" comment on the remaining `runningStats` promise now spells out
that the caller has to consume it.
- `apps/webapp/app/services/environmentMetricsRepository.server.ts` —
remove `getDailyTaskActivity` and `getAverageDurations` from the
`EnvironmentMetricsRepository` interface and its ClickHouse
implementation, along with `fillInDailyTaskActivity` and the
`DailyTaskActivity` / `AverageDurations` types.

`getCurrentRunningStats` is the control that shows the diagnosis is
right. It throws on query failure in exactly the same way as the two
removed methods — `if (queryError) throw queryError` — but it never
produced an unhandled rejection, because `UnifiedTaskListPresenter`
passes its promise into a `Promise.all(...).then(...)` chain that the
route then awaits. Same failure mode, opposite outcome, and the only
difference is whether anything consumes the promise.

Follow-ups, not in this PR:

- `AgentListPresenter` returns three sparkline promises in the same
shape and they look similarly unconsumed. Left alone here to keep this
change reviewable.
- With these two callers gone, the `getTaskActivity` and
`getAverageDurations` query builders in `@internal/clickhouse` have no
remaining callers in this repo. Whether to remove them is a separate
call for someone who owns that package.

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

- `pnpm run typecheck --filter webapp` — passes. This is the meaningful
check here: it proves nothing still references the removed fields,
methods or types.
- `pnpm run format` and `pnpm run lint:fix` — clean, no changes
produced.
- No test file referenced the removed symbols, so no test needed
updating.

---

## Changelog

Server-only change, so this carries a `.server-changes/` note rather
than a changeset:
`.server-changes/task-list-remove-unused-metrics-queries.md`.

> The tasks page no longer runs two queries whose results were never
displayed, cutting wasted work on every page load and removing a source
of hidden server errors

---

## Screenshots

_No visual change — the removed data was never rendered._

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 11:39:43 +01:00
claude[bot] 4d8b5d6f87 chore(ci): remove per-repo Dependabot alert workflows (#4384)
##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [ ] I ran and tested the code works — n/a, this PR only deletes two
workflow files

---

## Summary

**Before:** two scheduled workflows in this repo posted Dependabot
digests to Slack — a critical-alert check every morning at 08:00 UTC,
and a summary of all open alerts on Mondays at 08:00 UTC.

**After:** neither runs. This reporting is handled centrally now, so the
two in-repo workflows were duplicating it.

**How:** deletes `.github/workflows/dependabot-critical-alerts.yml` and
`.github/workflows/dependabot-weekly-summary.yml`. Both were
self-contained — inline shell, no shared scripts or composite actions —
so nothing else in `.github/` referenced them.

Dependabot itself is unchanged: `.github/dependabot.yml`, alerts, and
version updates all keep working. This removes only the two Slack
notifiers.

The `ENABLE_DEPENDABOT_ALERTS` repository variable existed only to
switch these two workflows off. Nothing else reads it, so it can be
removed from the repository settings if it's set.

---

## Testing

No runtime code changes — this PR only removes two scheduled workflow
files. Verified that nothing else in the repo references either
filename, either workflow name, or the `ENABLE_DEPENDABOT_ALERTS`
variable.

---

## Changelog

Removed the two in-repo scheduled workflows that posted Dependabot
digests to Slack.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 09:51:05 +00:00
Eric Allam d3906241a5 feat(webapp): read realtime run rows from the primary, not the replica (#4378)
🦋 Changesets PR / Create Release PR (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 2s
🚀 Publish Trigger.dev Docker / units (push) Failing after 2s
🚀 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
## Summary

The realtime runs feed hydrates run rows from read replicas, which means
it needs a replica-lag gate to avoid serving a run's previous state
right after a write. Setting
`REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY=1` reads those rows from
each run store's primary instead, so there is no lag to gate against: no
probe, no wake delay, no stale-read retries. Off by default, so nothing
changes unless you set it.

## Design

The run stores already decide replica-vs-primary from the *brand* on the
read client they are handed: a branded replica keeps the read on the
owning store's replica, an unbranded writer escalates it to that store's
own primary. So this is a one-line choice at the hydrator, and it stays
correct across topologies. With the run-ops split on, each leg lands on
its own writer and the caller's client is never forwarded across
databases; with the split off, it is the single database's primary.

```ts
const runReader = new RunHydrator({
  readClient: runReadsFromPrimary ? prisma : $replica,
  runStore,
});
```

The same flag skips constructing the lag estimator, since probing a
replica the feed no longer reads would be measuring the wrong thing.

Independently, `AuroraReplicaLagSource` detected Aurora by letting
`aurora_replica_status()` fail, on the assumption that the app-level
catch made that free. It isn't: an unresolvable function is a query
error the driver reports to the error log on every sample, so a
non-Aurora replica produced a continuous stream of error events while
the estimator quietly fell through to its next candidate. It now
resolves the function with `to_regproc` and memoizes the answer, so the
unparseable call never reaches the wire.
2026-07-26 19:43:41 +01:00
2215 changed files with 258963 additions and 32033 deletions
@@ -1,5 +0,0 @@
---
"@trigger.dev/sdk": patch
---
Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output.
+2 -2
View File
@@ -56,8 +56,8 @@ Every PR gets a performance pass — not just the ones that look perf-sensitive.
## Always check
- **Tests use testcontainers, not mocks.** Vitest with `redisTest` / `postgresTest` / `containerTest` from `@internal/testcontainers`. Any new `vi.mock(...)` on Redis, Postgres, BullMQ, or other infra is wrong here — 🔴 if added in production-path tests, 🟡 if isolated unit test.
- **Public-package changes have a changeset.** `pnpm run changeset:add` produces `.changeset/*.md`. Required for any edit under `packages/*`. Missing → 🟡; missing on a breaking change → 🔴.
- **Server-only changes have `.server-changes/*.md`.** Required for `apps/webapp/`, `apps/supervisor/` edits with no public-package change. Body should be 1-2 sentences (it has to fit as one bullet in a future changelog). Missing → 🟡.
- **User-facing public-package changes have a changeset.** `pnpm run changeset:add` produces `.changeset/*.md`. Changesets are user-facing release notes, not a catalog of every change: required when a `packages/*` or `integrations/*` change is something a user would notice or act on, skipped for internal-only changes, refactors, chores, and packages not consumed independently (e.g. `@trigger.dev/redis-worker`). Missing on a user-facing change → 🟡; missing on a breaking change → 🔴. Do not flag a missing note when the change is not user-facing.
- **User-facing server-only changes have `.server-changes/*.md`.** Required for user-facing `apps/webapp/`, `apps/supervisor/` edits in a PR with no package or integration change that requires a changeset; skip internal-only or admin-only changes, refactors, and chores. Body should be 1-2 sentences (it has to fit as one bullet in a future changelog). Missing on a user-facing change → 🟡.
- **Lua script naming.** Coexisting scripts use behavior-descriptive suffixes (`Tracked`), never `V2`. Old name must keep working until the next deploy clears it.
- **RunQueue payload shape.** V2 run-queue payload's `projectId` is consumed by `workerQueueResolver` for override matching. If a PR drops it from the payload, 🔴.
- **`safeSend` scope.** Defensive IPC wrappers belong on loop / interval / handler contexts, not one-shot terminal sends. If the PR adds `safeSend` to a single terminal call for consistency, 🟡 with a "remove this" suggestion.
+13
View File
@@ -0,0 +1,13 @@
---
name: code-reviewer
description: Adversarially verifies one landed packet against its requirement; read-only.
model: opus
---
You are an adversarial code reviewer for one landed packet. READ-ONLY: never modify code, never commit, never push, never post to GitHub.
- Try to refute that the change answers its stated requirement; look for the failure scenario, not confirmation.
- Check the diff for unrelated drift, dead code, broken semantics of neighbors, and whether tests prove the actual invariant (would the test fail if the fix were subtly wrong?).
- Check the change landed in the correct PR/branch of the stack.
- Distinguish fact from inference; cite exact file:line evidence.
- Return: verdict (approve / needs-changes) with evidence per concern, and the exact minimal correction when needs-changes.
+16
View File
@@ -0,0 +1,16 @@
---
name: code-writer
description: Implements exactly one work packet — minimal diff, targeted checks, own-paths-only commits.
model: opus
---
You are a code writer. Implement exactly the one work packet in your prompt.
- Minimal diff; match surrounding style and idiom.
- Prefer no comment at all; comment only a non-obvious constraint, max 2 short lines. All texts (comments, commit messages) short, clear, simple.
- Verify the packet's own diagnosis against the code before applying; if it is wrong, STOP without committing and report why.
- Run only the targeted checks for your packet: the relevant vitest files, `pnpm run typecheck --filter <pkg>` when the change warrants it. Never full suites unless asked.
- `pnpm run format` on touched files before committing.
- Stage and commit ONLY your packet's files. Conventional commit message. NO Claude attribution, no Co-Authored-By.
- Push only if the packet explicitly says to.
- Return: what changed, evidence (test output), commit SHA, and anything contradicting the diagnosis.
+12
View File
@@ -0,0 +1,12 @@
---
name: software-architect
description: Resolves contested design questions against the specs; decision + rationale, never code.
model: opus
---
You are a software architect. Resolve exactly the contested design question in your prompt against the given specs/contracts. READ-ONLY.
- Ground the decision in the actual code and the project's design contracts (GUIDEBOOK, Linear specs) — not in generic best practice.
- Weigh stack boundaries: which PR owns the change, what merges independently.
- Prefer the smallest decision that unblocks the packet; flag speculative architecture rather than endorsing it.
- Return: the decision, its rationale, rejected alternatives (one line each), and exactly what the dependent packet should do.
+2 -2
View File
@@ -5,7 +5,7 @@ paths:
# Server App Changes
When modifying server apps (webapp, supervisor, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset:
`.server-changes/` files are user-facing release notes, not a catalog of every change. When a user-facing server app change (webapp, supervisor, etc.) is in a PR with **no package or integration change that requires a changeset**, add a `.server-changes/` file instead of a changeset. Skip it for internal-only or admin-only changes, refactors, and chores:
```bash
cat > .server-changes/descriptive-name.md << 'EOF'
@@ -20,6 +20,6 @@ EOF
- **area**: `webapp` | `supervisor`
- **type**: `feature` | `fix` | `improvement` | `breaking`
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
- If the PR also touches `packages/` or `integrations/` and that change needs a changeset, the changeset covers it (no `.server-changes/` needed). If the package or integration change is internal and needs no changeset, still add a `.server-changes/` file for the user-facing server change.
The body ships **verbatim in user-facing release notes**. Keep it to 12 short sentences, non-technical, written for a dashboard user: describe what changed for them, never the implementation (no header names, endpoints, middleware, storage mechanisms, internal tools). See `.server-changes/README.md` for full guidance.
+1 -1
View File
@@ -154,7 +154,7 @@ PASS: one run, `run_<RID>` (status maps to `FAILED`). Proves `filter[error]` ->
### 6. Attribution — `mint-token` -> JWT exchange records the acting user
```bash
TOKEN=$(cli mint-token --profile $PROFILE --client errors-api-e2e 2>/dev/null) # UAT
TOKEN=$(cli mint-token --profile $PROFILE --client errors-api-e2e --cap read:errors,write:errors 2>/dev/null) # UAT
ENVJWT=$(curl -sS -X POST "$B/api/v1/projects/$REF/dev/jwt" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"claims":{"scopes":["read:errors","write:errors"]}}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
+2
View File
@@ -23,6 +23,8 @@ NODE_ENV=development
CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_ENABLED=1
# LOGS_SEARCH_PROJECTOR_ENABLED=1
# LOGS_SEARCH_PROJECTOR_PREVIEW_ENABLED=1
# Store task run spans/traces in ClickHouse so the dashboard trace view is
# populated in local dev. The local stack is ClickHouse-backed (see above), so
# leaving this unset falls back to the "postgres" store and dev run traces show
+5
View File
@@ -0,0 +1,5 @@
# Generated, not hand-written: collapsed in diffs and excluded from language stats.
internal-packages/dashboard-agent-db/drizzle/meta/*.json linguist-generated=true
internal-packages/dashboard-agent-db/drizzle/meta/** linguist-generated=true
**/__snapshots__/*.snap linguist-generated=true
pnpm-lock.yaml linguist-generated=true
+7 -1
View File
@@ -24,4 +24,10 @@ jrossi
ThullyoCunha
ConProgramming
saasjesus
brentshulman-silkline
brentshulman-silkline
Leafgard
Rohan170603
NERLOE
Jakub-Vacek
gtremper
wuweiweiwu
+272
View File
@@ -0,0 +1,272 @@
name: 🐳 Deploy base images
# Publishes the deploy base images (see base-images/README.md) to Docker Hub.
# Tags are mutable and rebuilt in place; the CLI pins digests, so consumers
# only move when a release bumps its pins.
on:
workflow_dispatch:
inputs:
debian_snapshot:
description: "Debian snapshot timestamp (YYYYMMDDTHHMMSSZ). Defaults to yesterday 00:00 UTC."
required: false
type: string
push:
branches: [main]
paths:
- "base-images/**"
- ".github/workflows/base-images.yml"
pull_request:
paths:
- "base-images/**"
- ".github/workflows/base-images.yml"
concurrency:
group: base-images-${{ github.ref }}
cancel-in-progress: false
permissions: {}
jobs:
setup:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
images: ${{ steps.config.outputs.images }}
packages: ${{ steps.config.outputs.packages }}
build_packages: ${{ steps.config.outputs.build_packages }}
suite: ${{ steps.config.outputs.suite }}
snapshot: ${{ steps.config.outputs.snapshot }}
publish_id: ${{ steps.config.outputs.publish_id }}
source_date_epoch: ${{ steps.config.outputs.source_date_epoch }}
push: ${{ steps.config.outputs.push }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Read image matrix and resolve snapshot
id: config
env:
SNAPSHOT_INPUT: ${{ inputs.debian_snapshot }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
run: |
PACKAGES="$(jq -er '.packages' base-images/images.json)"
BUILD_PACKAGES="$(jq -er '.buildPackages' base-images/images.json)"
SUITE="$(jq -er '.suite' base-images/images.json)"
# Values land in build args and shell lines; keep them boring.
# NUL-delimited whole-record match so multi-line values can't sneak through
printf '%s\0' "$PACKAGES" | grep -zqxE '[a-z0-9][a-z0-9 .+:=~-]*' || { echo "invalid packages value"; exit 1; }
printf '%s\0' "$BUILD_PACKAGES" | grep -zqxE '[a-z0-9][a-z0-9 .+:=~-]*' || { echo "invalid buildPackages value"; exit 1; }
printf '%s\0' "$SUITE" | grep -zqxE '[a-z]+' || { echo "invalid suite value"; exit 1; }
jq -e '.images | length > 0 and all((.repo | test("^[a-z0-9-]+$")) and (.tag | test("^[a-z0-9.-]+$")) and (.base | test("^[a-zA-Z0-9./:@-]+$")))' base-images/images.json > /dev/null \
|| { echo "invalid images entries"; exit 1; }
SNAPSHOT="$SNAPSHOT_INPUT"
if [ -z "$SNAPSHOT" ]; then
SNAPSHOT="$(date -u -d yesterday +%Y%m%dT000000Z)"
fi
printf '%s\0' "$SNAPSHOT" | grep -zqxE '[0-9]{8}T[0-9]{6}Z' || { echo "invalid debian_snapshot: $SNAPSHOT"; exit 1; }
# Snapshot-derived timestamps: reproducible, with a real created date
EPOCH="$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s)"
# Future snapshots resolve to "latest" and break mtime normalization
[ "$EPOCH" -le "$(date -u +%s)" ] || { echo "debian_snapshot is in the future: $SNAPSHOT"; exit 1; }
# Pull requests and branch dispatches build without pushing
if [ "$EVENT_NAME" = "pull_request" ] || [ "$REF" != "refs/heads/main" ]; then
PUSH=false
else
PUSH=true
fi
{
echo "images=$(jq -c '.images' base-images/images.json)"
echo "packages=$PACKAGES"
echo "build_packages=$BUILD_PACKAGES"
echo "suite=$SUITE"
echo "snapshot=$SNAPSHOT"
echo "publish_id=${SNAPSHOT:0:8}-${SNAPSHOT:9:6}-${SHA:0:7}"
echo "source_date_epoch=$EPOCH"
echo "push=$PUSH"
} >> "$GITHUB_OUTPUT"
publish:
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
image: ${{ fromJSON(needs.setup.outputs.images) }}
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Before any pull so rate limits are authenticated; fork PRs skip (no secrets)
- name: 🐳 Login to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 🐳 Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
image: docker.io/tonistiigi/binfmt:latest@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Build both targets before pushing either so the tag pair can't skew
- name: 🐳 Build both targets (no push)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: base-images
file: base-images/Dockerfile
target: build
platforms: linux/amd64,linux/arm64
provenance: false
outputs: type=image,push=false,rewrite-timestamp=true
tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build
build-args: |
BASE_IMAGE=${{ matrix.image.base }}
DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }}
DEBIAN_SUITE=${{ needs.setup.outputs.suite }}
PACKAGES=${{ needs.setup.outputs.packages }}
BUILD_PACKAGES=${{ needs.setup.outputs.build_packages }}
SOURCE_DATE_EPOCH=${{ needs.setup.outputs.source_date_epoch }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }}
- name: 🐳 Push runtime image
id: build_runtime
if: needs.setup.outputs.push == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: base-images
file: base-images/Dockerfile
target: runtime
platforms: linux/amd64,linux/arm64
provenance: false
outputs: type=image,push=true,rewrite-timestamp=true
# The dated tag is immutable and keeps every published digest
# tag-referenced forever; shipped CLI releases pin these digests
tags: |
triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}
triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-${{ needs.setup.outputs.publish_id }}
build-args: |
BASE_IMAGE=${{ matrix.image.base }}
DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }}
DEBIAN_SUITE=${{ needs.setup.outputs.suite }}
PACKAGES=${{ needs.setup.outputs.packages }}
SOURCE_DATE_EPOCH=${{ needs.setup.outputs.source_date_epoch }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }}
- name: 🐳 Push build-variant image
id: build_toolchain
if: needs.setup.outputs.push == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: base-images
file: base-images/Dockerfile
target: build
platforms: linux/amd64,linux/arm64
provenance: false
outputs: type=image,push=true,rewrite-timestamp=true
tags: |
triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build
triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build-${{ needs.setup.outputs.publish_id }}
build-args: |
BASE_IMAGE=${{ matrix.image.base }}
DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }}
DEBIAN_SUITE=${{ needs.setup.outputs.suite }}
PACKAGES=${{ needs.setup.outputs.packages }}
BUILD_PACKAGES=${{ needs.setup.outputs.build_packages }}
SOURCE_DATE_EPOCH=${{ needs.setup.outputs.source_date_epoch }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }}
# An auto-created private repo would publish green while customer pulls fail
- name: 🔎 Verify anonymous pullability
if: needs.setup.outputs.push == 'true'
env:
IMAGE_REPO: ${{ matrix.image.repo }}
RUNTIME_DIGEST: ${{ steps.build_runtime.outputs.digest }}
BUILD_DIGEST: ${{ steps.build_toolchain.outputs.digest }}
run: |
for digest in "$RUNTIME_DIGEST" "$BUILD_DIGEST"; do
TOKEN="$(curl -fsS --connect-timeout 10 --max-time 60 "https://auth.docker.io/token?service=registry.docker.io&scope=repository:triggerdotdev/$IMAGE_REPO:pull" | jq -r .token)"
curl -fsS --connect-timeout 10 --max-time 60 -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; }
done
# Builds are reproducible, so re-running a red publish re-pushes the
# same digests and re-attests them
- name: 🔏 Attest runtime image provenance
if: needs.setup.outputs.push == 'true'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }}
subject-digest: ${{ steps.build_runtime.outputs.digest }}
push-to-registry: false
- name: 🔏 Attest build-variant image provenance
if: needs.setup.outputs.push == 'true'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }}
subject-digest: ${{ steps.build_toolchain.outputs.digest }}
push-to-registry: false
- name: 📋 Record digests
if: needs.setup.outputs.push == 'true'
env:
IMAGE_REPO: ${{ matrix.image.repo }}
IMAGE_TAG: ${{ matrix.image.tag }}
RUNTIME_DIGEST: ${{ steps.build_runtime.outputs.digest }}
BUILD_DIGEST: ${{ steps.build_toolchain.outputs.digest }}
SNAPSHOT: ${{ needs.setup.outputs.snapshot }}
run: |
{
echo "### triggerdotdev/$IMAGE_REPO:$IMAGE_TAG"
echo '```'
echo "runtime: $RUNTIME_DIGEST"
echo "build: $BUILD_DIGEST"
echo "debian snapshot: $SNAPSHOT"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
results:
needs: [publish]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Fail if any image build failed
env:
RESULT: ${{ needs.publish.result }}
run: |
[ "$RESULT" = "success" ] || { echo "one or more image builds failed: $RESULT"; exit 1; }
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_sticky_comment: true
allowed_bots: "devin-ai-integration[bot]"
allowed_bots: "devin-ai-integration[bot],claude[bot]"
claude_args: |
--max-turns 30
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_sticky_comment: true
allowed_bots: "devin-ai-integration[bot]"
allowed_bots: "devin-ai-integration[bot],claude[bot]"
claude_args: |
--max-turns 25
+3
View File
@@ -36,3 +36,6 @@ jobs:
- name: 🔎 Lint
run: pnpm exec oxlint .
- name: ✂️ Check unused code and dependencies
run: pnpm run knip
+51 -4
View File
@@ -3,9 +3,19 @@ name: "🤖 Deploy dashboard agent"
# Deploys the @internal/dashboard-agent chat.agent to its Trigger.dev project
# with --skip-promotion, so a deploy never becomes "current" on its own. The
# consuming app cuts over by pinning DASHBOARD_AGENT_VERSION to the new version.
# Runs a leg per environment (staging + prod), each gated by its own environment;
# a push to main that touches the agent or its store triggers both. Version
# numbers are per-environment, so pin each environment to its own leg's version.
# Runs a leg per environment (staging + prod); a push to main that touches the
# agent or its store deploys both. Version numbers are per-environment, so pin
# each environment to its own leg's version.
#
# The deploy lands dormant, so it doesn't need a reviewer gate: nothing goes live
# until DASHBOARD_AGENT_VERSION is flipped. The `environment:` below is kept only
# to scope the deploy token per environment; its required-reviewers rule is
# removed in repo settings so pushes deploy unattended. workflow_dispatch takes an
# optional ref (SHA, branch, or tag) to deploy a specific commit instead of head.
#
# The deployed ref must be an ancestor of main, so only reviewed, merged code ever
# runs with the deploy token (the checked-out build + trigger.config.ts execute
# with it). A push is always on main; a dispatched ref is checked before deploy.
on:
push:
@@ -14,6 +24,11 @@ on:
- "internal-packages/dashboard-agent/**"
- "internal-packages/dashboard-agent-db/**"
workflow_dispatch:
inputs:
ref:
description: "Commit SHA, branch, or tag to deploy. Defaults to the ref the workflow runs from."
required: false
type: string
permissions: {}
@@ -27,9 +42,15 @@ jobs:
max-parallel: 1
matrix:
environment: [staging, prod]
# Per-environment reviewer gate + source of the scoped deploy PAT.
# Kept to scope the deploy token per environment. The required-reviewers rule
# on these environments is removed in repo settings, so this no longer gates.
environment: dashboard-agent-${{ matrix.environment }}
concurrency:
# Queue a superseding deploy behind an in-flight one; do NOT cancel it.
# Cancelling the runner wouldn't stop the remote build (it finishes
# server-side), and a second concurrent deploy of the same project would
# race the indexer. Deploys are short now the gate is gone, so a brief queue
# is fine and can't pile up.
group: dashboard-agent-deploy-${{ matrix.environment }}
cancel-in-progress: false
permissions:
@@ -41,8 +62,34 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# push: the pushed commit. workflow_dispatch: the input ref if given,
# otherwise the head of the ref the run was launched from.
ref: ${{ github.event.inputs.ref || github.sha }}
# Full history so the ancestor-of-main check below can find a merge base.
fetch-depth: 0
persist-credentials: false
- name: Require the ref to be an ancestor of main
# The deploy token runs the checked-out code, so refuse anything that
# hasn't landed on main. A push is main's tip (ancestor of itself); this
# only ever rejects a dispatched, unmerged ref.
#
# NOTE: this in-file check only constrains WHICH commit is deployed. It
# can't protect the token on its own, because workflow_dispatch runs the
# workflow file from the selected ref. The real guard is the deployment
# branch policy on the dashboard-agent-* environments (main only), set in
# repo settings, which GitHub enforces server-side against GITHUB_REF.
run: |
set -euo pipefail
# An explicit `ref:` checkout doesn't create remote-tracking branches,
# so fetch main before comparing against it.
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main
if ! git merge-base --is-ancestor HEAD origin/main; then
echo "::error::Refusing to deploy $(git rev-parse HEAD): not an ancestor of origin/main. Only merged code can be deployed."
exit 1
fi
echo "$(git rev-parse --short HEAD) is an ancestor of origin/main"
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
@@ -1,87 +0,0 @@
name: Dependabot Critical Alerts
on:
schedule:
- cron: "0 8 * * *" # Daily 08:00 UTC
workflow_dispatch:
inputs:
severity:
description: "Severity to alert on"
type: choice
options:
- critical
- high
- medium
- low
default: critical
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: read
jobs:
alert:
name: Post critical alerts
# Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off
# the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the
# DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled.
if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }}
runs-on: warp-ubuntu-latest-x64-2x
environment: dependabot-summary
env:
SEVERITY: ${{ inputs.severity || 'critical' }}
steps:
- name: Fetch alerts
id: alerts
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh api -X GET "/repos/$REPO/dependabot/alerts" \
-F state=open -F severity="$SEVERITY" --paginate > pages.json
jq -s 'add' pages.json > alerts.json
TOTAL=$(jq 'length' alerts.json)
echo "total=$TOTAL" >> "$GITHUB_OUTPUT"
if [ "$TOTAL" = "0" ]; then
exit 0
fi
LIST=$(jq -r '
map("• <\(.html_url)|#\(.number)> *\(.dependency.package.name)* - \(.security_advisory.summary)")
| join("\n")
' alerts.json)
{
echo "list<<EOF"
echo "$LIST"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build Slack payload
if: steps.alerts.outputs.total != '0'
env:
REPO: ${{ github.repository }}
CHANNEL: ${{ vars.SLACK_CHANNEL_ID }}
TOTAL: ${{ steps.alerts.outputs.total }}
LIST: ${{ steps.alerts.outputs.list }}
run: |
jq -n \
--arg channel "$CHANNEL" \
--arg repo "$REPO" \
--arg total "$TOTAL" \
--arg list "$LIST" \
--arg severity "$SEVERITY" \
'{
channel: $channel,
text: ":bufo-alarma: `\($repo)` - *\($total) open \($severity) alert(s)*\n\($list)\n\n<https://github.com/\($repo)/security/dependabot?q=is%3Aopen+severity%3A\($severity)|View \($severity) alerts>"
}' > payload.json
- name: Post Slack alert
if: steps.alerts.outputs.total != '0'
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload-file-path: payload.json
@@ -1,210 +0,0 @@
name: Dependabot Weekly Summary
on:
schedule:
- cron: "0 8 * * 1" # Mon 08:00 UTC
workflow_dispatch:
# Single-purpose monitoring workflow; serialise on workflow name only - we never
# want two concurrent summary runs racing to post the same digest.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: read # gh CLI baseline
pull-requests: read # gh pr list (open dependabot PRs)
actions: read # gh run list / view (parse latest dependabot run logs)
jobs:
summary:
name: Post weekly Dependabot summary
# Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off
# the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the
# DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled.
if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }}
runs-on: warp-ubuntu-latest-x64-2x
environment: dependabot-summary
env:
# Severities surface in the actions list when their remaining TTR drops
# below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS.
THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }}
steps:
- name: Fetch alerts and compute summaries
id: alerts
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}
REPO: ${{ github.repository }}
run: |
if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then
echo "total=?" >> "$GITHUB_OUTPUT"
ERR=$(head -c 200 err.txt | tr '\n' ' ')
echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT"
echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT"
exit 0
fi
jq -s '[.[][] | select(.state == "open")]' pages.json > open.json
TOTAL=$(jq 'length' open.json)
echo "total=$TOTAL" >> "$GITHUB_OUTPUT"
if [ "$TOTAL" = "0" ]; then
echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT"
echo "actions=_None_" >> "$GITHUB_OUTPUT"
exit 0
fi
# Severity breakdown - real newlines so jq --arg in the payload
# builder encodes them as proper \n in JSON (Slack renders as breaks).
BY_SEV=$(jq -r '
group_by(.security_advisory.severity)
| map({sev: .[0].security_advisory.severity,
count: length,
weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])})
| sort_by(.weight)
| map("• *\(.count)* \(.sev)")
| join("\n")
' open.json)
{
echo "by_severity<<EOF"
echo "$BY_SEV"
echo "EOF"
} >> "$GITHUB_OUTPUT"
# Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline)
# Grouped by (package, severity); shows earliest deadline per group.
ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" '
[.[]
| (.security_advisory.severity) as $sev
| ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr
| select($ttr != null)
| ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age
| {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)}
]
| group_by([.pkg, .sev])
| map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)})
| map(select(.min_remaining < $threshold))
| sort_by(.min_remaining)
| if length == 0 then "_None_"
else (map(
"• *\(.pkg)* (\(.sev))" +
(if .count > 1 then " ×\(.count)" else "" end) + " - " +
(if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d"
else "\(.min_remaining)d remaining" end)
) | join("\n"))
end
' open.json)
{
echo "actions<<EOF"
echo "$ACTIONS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Fetch open dependabot PRs
id: prs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
REPO_URL: https://github.com/${{ github.repository }}
run: |
if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then
ERR=$(head -c 200 err.txt | tr '\n' ' ')
echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT"
exit 0
fi
LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" '
if length == 0 then "_None_"
else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n"))
end
')
{
echo "list<<EOF"
echo "$LIST"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Find latest npm dependabot run
id: latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
# Repos without a dependabot.yml have no "Dependabot Updates" workflow;
# treat the lookup failure as "no recent run found" rather than failing.
if ! RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq 'first(.[] | select(.name | startswith("npm_and_yarn")) | .databaseId) // empty' 2>/dev/null); then
RUN_ID=""
fi
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
- name: Extract stuck deps (only if actions pending)
id: stuck
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_ID: ${{ steps.latest.outputs.run_id }}
ACTIONS: ${{ steps.alerts.outputs.actions }}
run: |
# Skip the stuck section entirely when nothing in the actions list
# - keeps the digest tidy when there's nothing to actually act on.
if [ "$ACTIONS" = "_None_" ]; then
echo "section=" >> "$GITHUB_OUTPUT"
exit 0
fi
HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n'
if [ -z "$RUN_ID" ]; then
{
echo "section<<EOF"
echo "${HEADER}_(no recent npm run found)_"
echo "EOF"
} >> "$GITHUB_OUTPUT"
exit 0
fi
gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true
STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true)
if [ -z "$STUCK" ]; then
{
echo "section<<EOF"
echo "${HEADER}_None_"
echo "EOF"
} >> "$GITHUB_OUTPUT"
exit 0
fi
LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}')
{
echo "section<<EOF"
echo "${HEADER}${LIST}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build Slack payload
env:
REPO: ${{ github.repository }}
CHANNEL: ${{ vars.SLACK_CHANNEL_ID }}
TOTAL: ${{ steps.alerts.outputs.total }}
BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }}
PRS_LIST: ${{ steps.prs.outputs.list }}
ACTIONS: ${{ steps.alerts.outputs.actions }}
STUCK: ${{ steps.stuck.outputs.section }}
run: |
# Build payload via jq so PR titles or error strings containing
# quotes/backslashes/newlines can't break the JSON.
jq -n \
--arg channel "$CHANNEL" \
--arg repo "$REPO" \
--arg total "$TOTAL" \
--arg by_severity "$BY_SEVERITY" \
--arg prs_list "$PRS_LIST" \
--arg actions "$ACTIONS" \
--arg stuck "$STUCK" \
--arg threshold "$THRESHOLD_DAYS" \
'{
channel: $channel,
text: ":calendar: *Weekly Dependabot summary* - `\($repo)`\n\n*Open alerts (\($total)):*\n\($by_severity)\n\n*Open Dependabot PRs:*\n\($prs_list)\n\n*Actions needed (<\($threshold)d remaining):*\n\($actions)\($stuck)\n\n<https://github.com/\($repo)/security/dependabot|Dependabot alerts>"
}' > payload.json
- name: Post Slack summary
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload-file-path: payload.json
+1 -2
View File
@@ -99,11 +99,10 @@ jobs:
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
docker pull postgres:14
docker pull redis:7.2
docker pull testcontainers/ryuk:0.11.0
docker pull testcontainers/ryuk:0.14.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
+57 -12
View File
@@ -15,9 +15,16 @@ jobs:
e2eTests:
name: "🧪 E2E Tests: Webapp"
runs-on: warp-ubuntu-latest-x64-16x
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2]
shardTotal: [2]
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
SHARD_INDEX: ${{ matrix.shardIndex }}
SHARD_TOTAL: ${{ matrix.shardTotal }}
steps:
- name: 🔧 Disable IPv6
run: |
@@ -57,7 +64,7 @@ jobs:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6
with:
node-version: 24.18.0
cache: "pnpm"
@@ -73,17 +80,52 @@ jobs:
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
- name: 📥 Prepare deps and testcontainer images
run: |
echo "Pre-pulling Docker images with authenticated session..."
docker pull postgres:14
docker pull redis:7.2
docker pull testcontainers/ryuk:0.11.0
echo "Image pre-pull complete"
# Pull images concurrently with dependency installation. Retry each pull because
# registry timeouts are a recurring transient CI flake.
pull() {
for attempt in 1 2 3; do
docker pull "$1" && return 0
echo "::warning::docker pull $1 failed (attempt ${attempt}/3); retrying in 10s"
sleep 10
done
echo "::error::docker pull $1 failed after 3 attempts"
return 1
}
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
pull_images() {
local pids=()
local failed=0
for image in \
postgres:14 \
redis:7.2 \
testcontainers/ryuk:0.14.0 \
ghcr.io/s2-streamstore/s2:0.40.0@sha256:b26249e2ede0949755f5af8028185dc2bcfc3aa2db21eb9610543d144eb6ee9d \
minio/minio:latest
do
pull "$image" &
pids+=("$!")
done
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
failed=1
fi
done
return "$failed"
}
echo "Installing dependencies and pre-pulling Docker images..."
pull_images &
pull_pid=$!
install_status=0
pnpm install --frozen-lockfile || install_status=$?
pull_status=0
wait "$pull_pid" || pull_status=$?
if (( install_status != 0 || pull_status != 0 )); then
exit 1
fi
echo "Dependency install and image pre-pull complete"
- name: 📀 Generate Prisma Client
run: pnpm run generate
@@ -91,7 +133,10 @@ jobs:
- name: 🏗️ Build Webapp
run: pnpm run build --filter webapp
- name: 🎭 Install Playwright Chromium
run: cd apps/webapp && pnpm exec playwright install chromium
- name: 🧪 Run Webapp E2E Tests
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
WEBAPP_TEST_VERBOSE: "1"
+35
View File
@@ -0,0 +1,35 @@
name: "🛡️ FK Cascade Index Guard"
on:
workflow_call:
permissions:
contents: read
jobs:
fk-cascade-guard:
runs-on: warp-ubuntu-latest-x64-16x
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 🛡️ FK cascade index guard
run: pnpm --filter webapp run guard:fk-cascade-index -- --check
+386
View File
@@ -0,0 +1,386 @@
name: 🗺️ Observability Map
on:
# No paths filter, deliberately. GitHub evaluates one per workflow, so a pull request whose diff
# stops matching does not start the workflow at all: the resolved state cannot fire and a comment
# from an earlier push stands for ever showing findings that are no longer in the diff. Verified on
# a throwaway pull request whose only route change was reverted, and the realistic case is worse
# than that empty diff, because a pull request touching a route and other files, whose author
# reverts the route change and keeps the rest, still has a non-empty diff that no longer matches.
# The gating moved into the jobs below instead, where it can read whether a comment exists.
pull_request:
types: [opened, synchronize, reopened]
# The corpus job below is gated to this package's own paths, so a scheduled run is what still
# scans the tree as it drifts. Nightly rather than per route pull request: a new route can make a
# known laundering shape start paying, but that is a property of the tree accumulating, not of any
# one pull request, and it does not need catching within five minutes of the merge.
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
concurrency:
group: observability-map-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# The whole cost of a pull request that touches nothing this workflow watches: a checkout, a paths
# filter and one comment lookup. Everything expensive is gated on this job's outputs, and the
# lookup is here rather than in the report job so that gate can read it and the report job need
# never start.
changes:
name: 🔍 What moved
# Only the pull request path reads this job's output. On a schedule the action has no base to
# diff, warns that `before` is missing and reports the files in the last commit on main, which
# nothing then consults. Skipping it there keeps the nightly off a job it does not need.
if: github.event_name == 'pull_request'
runs-on: warp-ubuntu-latest-x64-2x
permissions:
contents: read
# Reading the pull request's comments, to find one an earlier push left. Read only: the write
# stays on the report job, which is the only job that posts.
pull-requests: read
outputs:
# The corpus job's gate. Narrower than the report's on purpose: what the corpus measures is
# the tool's resistance to laundering, which only an edit to the tool can weaken.
package: ${{ steps.filter.outputs.package }}
# The report job's gate, the union: a route change moves the report as well.
report: ${{ steps.filter.outputs.package == 'true' || steps.filter.outputs.routes == 'true' }}
# The id of a marker comment an earlier push left, empty if there is none, and the one source
# both the render and upsert steps read it from.
comment: ${{ steps.comment.outputs.id }}
# Set only by a lookup that finished cleanly, so anything else, retries exhausted or the step
# dying somewhere unforeseen, reads as "do not touch this pull request's comments".
lookup: ${{ steps.comment.outputs.ok }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
package:
- 'internal-packages/observability-map/**'
- '.github/workflows/observability-map.yml'
routes:
- 'apps/webapp/app/routes/**'
# Looked up here because the report job's gate needs it: with the watched paths unmoved, a
# pull request that already has a comment gets a resolved state rather than being left with
# findings that no longer exist, and one that does not gets no job at all.
#
# On a failure that outlasts the retries this reports nothing, and the report job's gate reads
# that as "post nothing this run". Guessing is worse than silence: this step is the only thing
# that knows which comment to PATCH, so a guess of "no comment exists" POSTs, which either
# adds a second marker comment beside the stale one or says "the findings an earlier push
# reported are gone" on a pull request that never had findings. Worst case now is no comment
# this run, which the next push fixes.
- name: 🔍 Look for a comment from an earlier push
id: comment
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
found=""
ok=""
for attempt in 1 2 3; do
# Matched by login, not .user.type == "Bot": other bots and apps on the same PR are
# also type Bot, and login is the exact identity this token's own comments carry.
if found=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
--jq '[.[] | select(.user.login == "github-actions[bot]" and ((.body // "") | startswith("<!-- observability-map-report -->")))][0].id // empty'); then
ok=1
break
fi
echo "comment lookup attempt ${attempt} failed" >&2
sleep $((attempt * 5))
done
if [ -z "$ok" ]; then
echo "comment lookup failed after 3 attempts; this run posts nothing" >&2
exit 0
fi
# --paginate runs the jq once per page, so a marker comment on more than one page yields
# one id per page. Unhandled, that puts a newline in the PATCH url and the step dies under
# continue-on-error. The oldest wins: it is the one the upsert has been updating.
count=$(printf '%s\n' "$found" | grep -c '[0-9]' || true)
if [ "$count" -gt 1 ]; then
echo "warning: ${count} marker comments on this pull request; updating the oldest" >&2
fi
{
echo "id=$(printf '%s\n' "$found" | awk 'NF { print $1; exit }')"
echo "ok=ok"
} >> "$GITHUB_OUTPUT"
# The tree-scale mutation corpus: every known laundering shape applied to the whole route tree,
# asserting the score does not rise. 53 entries, a couple of minutes of a runner and a good deal
# longer on a laptop, which is why it is gated out of the package's default `pnpm test` and run
# here instead. Unlike the report job
# below it has no token to lose, so it runs for fork PRs too, and unlike the report job it is
# allowed to fail the build.
#
# Gated to this package's own paths rather than running on every route pull request. What the
# corpus measures is the TOOL's resistance to laundering, and only an edit to the tool can weaken
# that, so a routes-only change was paying a couple of minutes of a 4x runner for a result that
# could not differ from the last one. It was also the worst kind of job to spend that on: a red x
# that fires on a large share of webapp pull requests, is allowed to fail, and gates nothing, which is
# the shape people learn to scroll past.
#
# What this gives up is real and small. A route landing a shape no corpus entry has seen can make
# a known laundering mutation start paying, and that is now caught by the nightly rather than by
# the pull request that caused it. Tree drift accrues over months, so a day is the right
# granularity for it; the tool's own regressions, which are the ones a single commit can cause,
# still gate per pull request.
#
# Nothing in this repo watches whether the nightly itself succeeds: no Slack webhook and no
# issue-on-failure step here or in e2e-webapp-auth-full.yml, the only other scheduled workflow, so
# there is no house pattern to follow. A broken corpus fails quietly on the 3am cron, red only in
# the Actions tab,
# for as long as nobody checks it. Wiring up a real notification needs infrastructure (a Slack
# webhook secret, at minimum) that does not exist here yet, so this is a known, unfixed gap
# rather than a fixed one.
mutation-corpus:
name: 🧬 Mutation corpus
needs: changes
# `!cancelled()` is here for the nightly, not for tidiness. `needs` carries an implicit
# success() on the job it names, and that implicit test outranks the `||` below: with a plain
# condition, a `changes` job that failed or was skipped skips this one, so the nightly would
# stop scanning for tree drift and report nothing about having stopped. A status-check function
# in the `if` is what drops the implicit success(), so the event test below decides alone.
# `!cancelled()` rather than `always()` because `cancel-in-progress` above is a real path and a
# superseded run should not finish this job.
#
# Pull request behaviour is deliberately unchanged: on a PR a failed `changes` leaves
# `needs.changes.outputs.package` empty, so the corpus still skips. The nightly is the backstop
# for that, which is the same trade the paths gate already makes for routes-only pull requests.
if: >-
!cancelled() &&
(github.event_name != 'pull_request' || needs.changes.outputs.package == 'true')
runs-on: warp-ubuntu-latest-x64-4x
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6
with:
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 🧬 Run the corpus
env:
OBS_MAP_MUTATION_CORPUS: "1"
run: |
pnpm --filter @internal/observability-map exec vitest run \
src/mutationCorpus.test.ts --disable-console-intercept
# The package's own tests are NOT run here. They gate through pr_checks.yml, which is the only
# workflow the all-checks aggregate can see, so a job in this file would report a result nobody
# is required to wait for. See unit-tests-observability-map.yml and the obsmap filter.
report:
needs: changes
runs-on: warp-ubuntu-latest-x64-4x
# Only this job comments, so only this job gets the write.
permissions:
contents: read
pull-requests: write
# Fork PRs get a read-only token, so the comment cannot post. Skipping the job beats a red x.
# The event test is what keeps this job off the nightly, which has no pull request to comment on
# and only exists for the corpus job above.
#
# The two output tests are what the workflow-level paths filter used to do, plus the thing it
# could not do. The report has to run when the watched paths moved, and ALSO when they did not
# but a marker comment is already on the pull request, because that comment is the one showing
# findings that have left the diff. Reconciling it needs no scan, so the steps below are gated
# again on the same output.
#
# `needs` carries an implicit success() and that is wanted here: a `changes` job that failed
# knows neither which paths moved nor whether a comment exists, and a report job that ran anyway
# could only guess. Same reason the lookup test is positive rather than a check for a failure
# sentinel: retries exhausted, or the lookup step dying anywhere unforeseen, both leave the
# output unset and both mean the same thing, so neither can be read as "no comment exists" by
# one step and "a comment exists" by another. That disagreement is what the sentinel pair this
# replaces got wrong once already.
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
needs.changes.outputs.lookup == 'ok' &&
(needs.changes.outputs.report == 'true' || needs.changes.outputs.comment != '')
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6
with:
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
# Guarded rather than allowed to fail: this job must never block a pull request. The failure
# is not swallowed either, the render step below turns a missing head report into a comment
# saying so, because a swallowed failure with no comment is the outcome nobody wants.
#
# `--out` rather than a stdout redirect, so nothing a tool decides to print can end up inside
# the document `prCommentCli` parses. `pnpm --filter` takes its recursive path and some
# versions announce `Scope: N of M workspace projects` on the way; that line landing in
# head.json would fail the parse and degrade every run to the stale-report comment, which is
# a permanent quiet failure rather than a loud one. It does not reproduce on the 10.33.2
# pinned above, so this closes the class rather than a reproduction: the file is written by
# the process that owns it and stdout is left to be log output. Held by
# `it("let the scanner write its own report rather than capturing stdout")` in
# `internal-packages/observability-map/src/integration.test.ts`.
#
# `-s` keeps the partial dance honest now the redirect no longer creates the file: a scanner
# that exits 0 without writing takes the else branch and the stale-report comment, instead of
# failing the `mv` and turning the job red.
#
# Gated: this is the expensive half, and the reconcile run has nothing to compare. The steps
# above it are not gated because the renderer is TypeScript in this repo, so reconciling still
# needs the checkout and the install. That is the cost of the reconcile run and it is paid only
# by a pull request that has a comment and no longer matches the paths.
- name: 🔎 Scan head
if: needs.changes.outputs.report == 'true'
run: |
if pnpm --filter @internal/observability-map exec tsx src/cli.ts \
--out=/tmp/head.json.partial && [ -s /tmp/head.json.partial ]; then
mv /tmp/head.json.partial /tmp/head.json
else
rm -f /tmp/head.json /tmp/head.json.partial
echo "head scan failed; the comment will say the report is stale for this run" >&2
fi
# base.sha, not a merge base, and two reviewers have now read that as a bug. The checkout
# above is the default for a pull_request event, so the working tree is GitHub's test merge
# commit, whose parents are base.sha and the PR head. The head tree therefore already contains
# the base branch up to base.sha, and diffing it against base.sha is what isolates this pull
# request's own work. A merge base would leave the intervening base-branch commits in the head
# tree and out of the base tree, and blame the pull request for all of them.
- name: 🔎 Scan base with the head's scanner
if: needs.changes.outputs.report == 'true'
run: |
if git worktree add /tmp/base-tree ${{ github.event.pull_request.base.sha }} \
&& pnpm --filter @internal/observability-map exec tsx src/cli.ts \
--routes=/tmp/base-tree/apps/webapp/app/routes --out=/tmp/base.json \
&& [ -s /tmp/base.json ]; then
:
else
echo "-" > /tmp/base.json || true
echo "base scan failed or the worktree could not be added; falling back to no base" >&2
fi
# continue-on-error for the same reason as the scan: a rendering bug must not turn the job
# red. An empty /tmp/comment.md means there is nothing to post, which is a decision
# prCommentCli makes, not this shell.
#
# Both shas are forwarded so every comment this job posts says which commit it was rendered
# for, which a sticky comment edited in place across pushes otherwise never tells you. They go
# through the CLI as data: the renderer builds no URL and reads no environment.
- name: 📝 Render comment
continue-on-error: true
env:
SCANNED: ${{ needs.changes.outputs.report }}
EXISTING_COMMENT: ${{ needs.changes.outputs.comment }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
COMPARE_URL: ${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
run: |
rm -f /tmp/comment.md
# `--out` rather than a stdout redirect, for the reason the scan steps above give, and with a
# worse failure mode than theirs: the marker has to be the comment's first line for the
# lookup to find it, so a line printed ahead of the document makes every push post a new
# comment instead of updating the one already there. Held by
# `it("let the renderer write its own comment rather than capturing stdout")`.
render() {
pnpm --filter @internal/observability-map exec tsx src/report/prCommentCli.ts \
--commit-sha="$HEAD_SHA" --commit-url="$COMPARE_URL" --out=/tmp/comment.md.partial "$@"
}
# Every write goes through this, so a renderer that exits non-zero never leaves a 0-byte
# comment.md for the upsert to skip in silence.
emit() {
rm -f /tmp/comment.md.partial
if render "$@"; then
mv /tmp/comment.md.partial /tmp/comment.md
return 0
fi
rm -f /tmp/comment.md.partial
return 1
}
# Nothing this workflow watches moved, so nothing was scanned and there is no delta to
# compute. The job's gate only lets that case through when a comment from an earlier push
# is on the pull request, so there is exactly one thing left to say: what it shows is not
# in this diff any more.
if [ "$SCANNED" != "true" ]; then
emit --resolved || echo "could not render the resolved comment" >&2
exit 0
fi
if [ ! -s /tmp/head.json ]; then
emit --scan-failed || echo "could not render the stale-report comment either" >&2
exit 0
fi
base=/tmp/base.json
if [ ! -s /tmp/base.json ] || [ "$(cat /tmp/base.json)" = "-" ]; then
base="-"
fi
flags=()
if [ -n "$EXISTING_COMMENT" ]; then
flags=(--existing-comment)
fi
if ! emit /tmp/head.json "$base" "${flags[@]}"; then
echo "render failed; falling back to the stale-report comment" >&2
emit --scan-failed || echo "could not render the stale-report comment either" >&2
fi
# continue-on-error for the same reason: a transient gh api failure (rate limit, network)
# must not fail the job either. Worst case, the PR gets no comment this run.
#
# The id comes from the same job output the render step read, so the two cannot disagree about
# whether a comment exists. A lookup that did not finish cleanly never reaches either of them:
# the job's gate stops it.
- name: 💬 Upsert PR comment
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
EXISTING_COMMENT: ${{ needs.changes.outputs.comment }}
run: |
if [ ! -s /tmp/comment.md ]; then
echo "nothing to post: this pull request does not move the report"
exit 0
fi
if [ -n "$EXISTING_COMMENT" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT}" -F body=@/tmp/comment.md
else
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -F body=@/tmp/comment.md
fi
+46
View File
@@ -22,6 +22,7 @@ jobs:
webapp: ${{ steps.filter.outputs.webapp }}
packages: ${{ steps.filter.outputs.packages }}
internal: ${{ steps.filter.outputs.internal }}
obsmap: ${{ steps.filter.outputs.obsmap }}
cli: ${{ steps.filter.outputs.cli }}
sdk: ${{ steps.filter.outputs.sdk }}
steps:
@@ -57,6 +58,7 @@ jobs:
- '.github/workflows/unit-tests-webapp.yml'
- '.github/workflows/e2e-webapp.yml'
- '.github/workflows/runops-guard.yml'
- '.github/workflows/fk-cascade-guard.yml'
- '.configs/**'
- 'package.json'
- 'pnpm-lock.yaml'
@@ -81,6 +83,38 @@ jobs:
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
# The whole webapp app tree, not just its routes, and that is the whole reason this
# filter exists. Two tests in @internal/observability-map read it: integration.test.ts
# scans the live route tree, and webappSymbols.test.ts walks all of apps/webapp/app and
# fails when a guard, sensitive or audit symbol stops resolving. Routes-only was this
# filter's own bug: renaming e.g. requireUserId in app/services/session.server.ts
# matched `webapp` and nothing else, so no job ran the suite and the break landed on
# main, or on the next unrelated internal-packages PR.
#
# The cost of the wider set, measured over the last 400 commits on main: 31% touch
# routes, 52% touch apps/webapp/app, so the job goes from firing on roughly a third of
# PRs to roughly a half. It is the cheap one -- a single 4x runner, no containers, no
# database, no prisma generate -- which is what makes that affordable.
#
# observability-map.yml is here because integration.test.ts asserts on its text and no
# other filter watches it, so editing the report workflow alone ran nothing at all.
#
# Deliberately NOT here: this package's own paths, and packages/plugins/src and
# internal-packages/rbac/src, the other two trees webappSymbols.test.ts reads.
# `internal` above already matches `internal-packages/**` and `packages/**`, and
# `unit-tests-internal.yml` runs `turbo run test --filter "@internal/*"`, which picks up
# @internal/observability-map and runs the same vitest suite. Listing them here as well
# ran the suite twice on every PR touching them, which was this filter's own doing.
#
# Also deliberately NOT here: pr_checks.yml, package.json, pnpm-lock.yaml,
# pnpm-workspace.yaml. `internal` already lists all four, so a PR touching only one of
# them ran this suite twice for the same reason as above. Editing pr_checks.yml no
# longer runs this job live as a result; integration.test.ts still asserts on its text
# via the `internal` job.
obsmap:
- 'apps/webapp/app/**'
- '.github/workflows/unit-tests-observability-map.yml'
- '.github/workflows/observability-map.yml'
cli:
- 'packages/cli-v3/**'
- 'packages/build/**'
@@ -117,6 +151,11 @@ jobs:
if: needs.changes.outputs.webapp == 'true'
uses: ./.github/workflows/runops-guard.yml
fk-cascade-guard:
needs: changes
if: needs.changes.outputs.webapp == 'true'
uses: ./.github/workflows/fk-cascade-guard.yml
webapp:
needs: changes
if: needs.changes.outputs.webapp == 'true'
@@ -149,6 +188,11 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
obsmap:
needs: changes
if: needs.changes.outputs.obsmap == 'true'
uses: ./.github/workflows/unit-tests-observability-map.yml
e2e:
needs: changes
if: needs.changes.outputs.cli == 'true'
@@ -168,10 +212,12 @@ jobs:
- code-quality
- typecheck
- runops-guard
- fk-cascade-guard
- webapp
- e2e-webapp
- packages
- internal
- obsmap
- e2e
- sdk-compat
if: always()
+9 -1
View File
@@ -23,11 +23,19 @@ jobs:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6
with:
node-version: 24.18.0
cache: "pnpm"
- name: Restore Turbo cache
uses: WarpBuilds/cache@40f3443ae7b70e568d6e2070ea897f3df94d7553 # v1
with:
path: node_modules/.cache/turbo
key: turbo-typecheck-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
turbo-typecheck-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
@@ -78,7 +78,6 @@ jobs:
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
# Retry each pull - DockerHub registry timeouts are a recurring transient CI flake.
pull() {
@@ -96,7 +95,6 @@ jobs:
pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251
pull redis:7.2
pull testcontainers/ryuk:0.14.0
pull electricsql/electric:1.2.4
echo "Image pre-pull complete"
- name: 📥 Download deps
@@ -0,0 +1,43 @@
name: "🧪 Unit Tests: Observability Map"
permissions:
contents: read
# Its own workflow rather than a job inside observability-map.yml, because that workflow is not
# reachable from pr_checks.yml's all-checks aggregate and so gates nothing. Called from there
# instead, behind a paths filter, which is how every other test suite in this repo is gated.
on:
workflow_call:
jobs:
unitTests:
name: "🧪 Unit Tests: Observability Map"
# No containers and no database: the package is a static analyser over source text, so the
# suite is CPU bound on parsing the route tree and needs nothing the runner does not have.
runs-on: warp-ubuntu-latest-x64-4x
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6
with:
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
# This suite reads apps/webapp/app (the route tree for the scan, the whole app tree for the
# symbol check) and the report workflow's text, which is why the filter that gates this
# workflow watches all of those and not only the routes folder.
- name: 🧪 Run tests
run: pnpm --filter @internal/observability-map run test
+1 -2
View File
@@ -81,7 +81,6 @@ jobs:
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
# Retry each pull - DockerHub registry timeouts are a recurring transient CI flake.
pull() {
@@ -98,7 +97,7 @@ jobs:
pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251
pull redis:7.2
pull testcontainers/ryuk:0.14.0
pull electricsql/electric:1.2.4
pull otel/opentelemetry-collector-k8s:0.158.0@sha256:c09130a633196a5becee164411473a0932ecf223f94fda6dab5f22798ff9f376
echo "Image pre-pull complete"
- name: 📥 Download deps
+45 -21
View File
@@ -14,18 +14,18 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests: Webapp"
# 10 shards on 16x machines: webapp test throughput is limited per-machine (one
# docker daemon + disk absorbing all the per-file Postgres/ClickHouse container
# spin-up), so many machines beats few big ones - fewer/bigger (3x32) measured
# SLOWER than 10x8. The 16x (vs 8x) gives the fork pool the CPU headroom the 8x
# runners lacked. Setup overhead per machine is ~1 min on warm runners.
# Webapp test throughput is limited per-machine (one docker daemon + disk absorbing
# all the per-file Postgres/ClickHouse container spin-up), so many machines beats
# few big ones - fewer/bigger (3x32) measured slower than 10x8. The 16x (vs 8x)
# gives the fork pool the CPU headroom the 8x runners lacked.
runs-on: warp-ubuntu-latest-x64-16x
strategy:
# one flaky shard shouldn't cancel its siblings - lets us re-run only the failed shard
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
shardTotal: [12]
shardIndex:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
shardTotal: [24]
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
SHARD_INDEX: ${{ matrix.shardIndex }}
@@ -69,7 +69,7 @@ jobs:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6
with:
node-version: 24.18.0
cache: "pnpm"
@@ -85,10 +85,10 @@ jobs:
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
- name: 📥 Prepare deps and testcontainer images
run: |
# Retry each pull - DockerHub registry timeouts are a recurring transient CI flake.
# Pull images concurrently with dependency installation. Retry each pull because
# DockerHub registry timeouts are a recurring transient CI flake.
pull() {
for attempt in 1 2 3; do
docker pull "$1" && return 0
@@ -98,17 +98,41 @@ jobs:
echo "::error::docker pull $1 failed after 3 attempts"
return 1
}
echo "Pre-pulling Docker images with authenticated session..."
pull postgres:14
pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251
pull redis:7.2
pull testcontainers/ryuk:0.14.0
pull electricsql/electric:1.2.4
pull minio/minio:latest
echo "Image pre-pull complete"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
pull_images() {
local pids=()
local failed=0
for image in \
postgres:14 \
postgres:17 \
clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 \
redis:7.2 \
testcontainers/ryuk:0.14.0 \
electricsql/electric:1.2.4@sha256:20da3d0b0e74926c5623392db67fd56698b9e374c4aeb6cb5cadeb8fea171c36 \
minio/minio:latest
do
pull "$image" &
pids+=("$!")
done
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
failed=1
fi
done
return "$failed"
}
echo "Installing dependencies and pre-pulling Docker images..."
pull_images &
pull_pid=$!
install_status=0
pnpm install --frozen-lockfile || install_status=$?
pull_status=0
wait "$pull_pid" || pull_status=$?
if (( install_status != 0 || pull_status != 0 )); then
exit 1
fi
echo "Dependency install and image pre-pull complete"
- name: 📀 Generate Prisma Client
run: pnpm run generate
+11
View File
@@ -79,3 +79,14 @@ ailogger-output.log
# local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them
.worktrees/
# local planning/design docs, not committed
**/docs/superpowers/
# observability-map CLI output artifact, not committed
observability-map.json
.claude/worktrees/
# CPU benchmark artifacts (profiles + summaries)
.bench/
+1
View File
@@ -21,6 +21,7 @@
"**/storybook-static/",
"**/.changeset/",
"**/dist/",
"**/.worktrees/",
"internal-packages/tsql/src/grammar/",
"internal-packages/llm-model-catalog/src/defaultPrices.ts",
"internal-packages/llm-model-catalog/src/modelCatalog.ts",
+153 -7
View File
@@ -1,13 +1,18 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "react"],
"categories": {
"correctness": "error"
},
"plugins": ["typescript", "import", "react", "jsx-a11y"],
"jsPlugins": [
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
"./oxlint-plugins/runops-residency.mjs"
"./oxlint-plugins/runops-residency.mjs",
"./oxlint-plugins/prisma-in-filter.mjs"
],
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/.worktrees/**",
"**/*.d.ts",
"**/seed.js",
"**/seedCloud.ts",
@@ -29,29 +34,170 @@
],
"no-empty-pattern": "off",
"no-control-regex": "off",
"typescript/no-non-null-asserted-optional-chain": "off",
"no-unused-expressions": ["warn", { "allowShortCircuit": true, "allowTernary": true }],
"typescript/no-non-null-asserted-optional-chain": "error",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true
}
],
"typescript/consistent-type-imports": "error",
"import/no-duplicates": "error",
"import/namespace": "off",
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"trigger/no-thrown-unawaited-redirect": "error"
"react/exhaustive-deps": "error",
"react/rules-of-hooks": "off",
"guard-for-in": "error",
"symbol-description": "error",
"no-unneeded-ternary": "error",
"prefer-object-has-own": "error",
"no-redeclare": "error",
"no-multi-assign": "error",
"prefer-object-spread": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-fragments": "error",
"react/self-closing-comp": "error",
"react/jsx-no-constructed-context-values": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unsafe": "error",
"react/no-will-update-set-state": "error",
"react/require-render-return": "error",
"react/style-prop-object": "error",
"react/void-dom-elements-no-children": "error",
"react/error-boundaries": "off",
"react/globals": "off",
"react/immutability": "off",
"react/incompatible-library": "off",
"react/preserve-manual-memoization": "off",
"react/purity": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/set-state-in-render": "off",
"react/static-components": "off",
"react/unsupported-syntax": "off",
"react/use-memo": "off",
"react/void-use-memo": "off",
"react/checked-requires-onchange-or-readonly": "error",
"react/forward-ref-uses-ref": "error",
"react/iframe-missing-sandbox": "error",
"react/no-unknown-property": "error",
"jsx-a11y/alt-text": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/control-has-associated-label": [
"error",
{
"depth": 4,
"ignoreElements": ["audio", "canvas", "embed", "input", "textarea", "tr", "td", "video"]
}
],
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/no-noninteractive-element-interactions": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/anchor-ambiguous-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",
"jsx-a11y/iframe-has-title": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/media-has-caption": "error",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/no-aria-hidden-on-focusable": "error",
"jsx-a11y/no-distracting-elements": "error",
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "error",
"no-lone-blocks": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-for-of": "error",
"trigger/no-thrown-unawaited-redirect": "error",
"trigger-prisma/no-unbounded-list-filter": "error",
"trigger-prisma/no-unbounded-list-filter-in-args-helper": "error"
},
"overrides": [
{
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
"rules": {
"react/button-has-type": "error",
"react/jsx-no-useless-fragment": "error",
"react/no-unstable-nested-components": "error",
"react/error-boundaries": "error",
"react/globals": "error",
"react/hooks": "error",
"react/immutability": "error",
"react/incompatible-library": "error",
"react/memo-dependencies": "error",
"react/no-deriving-state-in-effects": "error",
"react/preserve-manual-memoization": "error",
"react/purity": "error",
"react/refs": "error",
"react/set-state-in-effect": "error",
"react/set-state-in-render": "error",
"react/static-components": "error",
"react/unsupported-syntax": "error",
"react/use-memo": "error",
"react/void-use-memo": "error",
"react/rules-of-hooks": "error",
"trigger-runops/no-control-plane-run-graph-access": "error",
"trigger-runops/no-control-plane-in-runops-slot": "error"
}
},
{
"files": ["packages/react-hooks/src/**/*.ts", "packages/react-hooks/src/**/*.tsx"],
"rules": {
"react/rules-of-hooks": "error"
}
},
{
"files": ["**/*.ts", "**/*.tsx"],
"rules": {
"no-redeclare": "off"
}
},
{
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
"rules": {
"trigger-runops/no-control-plane-run-graph-access": "off",
"trigger-runops/no-control-plane-in-runops-slot": "off"
}
},
{
"files": ["**/*.test.ts", "**/*.test.tsx", "**/test/**", "**/tests/**", "**/e2e/**"],
"rules": {
"trigger-prisma/no-unbounded-list-filter": "off",
"trigger-prisma/no-unbounded-list-filter-in-args-helper": "off"
}
},
{
"files": ["internal-packages/tsql/**"],
"rules": {
"prefer-object-has-own": "off"
}
},
{
"files": [
"apps/webapp/app/components/primitives/charts/Chart.tsx",
"apps/webapp/app/components/primitives/Timeline.tsx"
],
"rules": {
"react/jsx-no-constructed-context-values": "off"
}
}
]
}
+5 -3
View File
@@ -4,11 +4,13 @@ This directory tracks changes to server-only components (webapp, supervisor, etc
## When to add a file
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file.
These entries are **user-facing release notes**, not a catalog of every change. The test is "would a user or customer care about this change?", not "did I touch a server app?". Add one only when a server-only change is something a user would notice, act on, or want to hear about (a feature, a bug fix they could have hit, a behavior or performance change they would feel). Skip it for internal-only or admin-only changes, refactors, test-only changes, chores, and performance tuning with no user-visible effect. Anyone who wants the exact history reads the commits. When in doubt, ask a maintainer rather than adding a note by default.
**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it.
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, or other server components (and does NOT change anything in `packages/`) AND the change is user-facing, add a `.server-changes/` file.
**Package-only PRs**: Just add a changeset as usual.
**Mixed PRs** (both packages and server): the changeset covers it, so no `.server-changes/` file is needed. If the package change is internal and needs no changeset but the server change is user-facing, add a `.server-changes/` file for it.
**Package-only PRs**: Just add a changeset as usual, when the change is user-facing.
## File format
@@ -1,6 +0,0 @@
---
area: webapp
type: fix
---
Speed up the Batches list page for environments with a large number of batches, which could previously time out while loading.
@@ -1,6 +0,0 @@
---
area: webapp
type: improvement
---
List API endpoints now clamp the page size to a maximum of 100. Requests asking for a larger page size return up to 100 items and keep paginating, rather than pulling an unbounded page.
@@ -1,6 +0,0 @@
---
area: webapp
type: improvement
---
Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit.
@@ -1,6 +0,0 @@
---
area: webapp
type: fix
---
Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs.
@@ -1,6 +0,0 @@
---
area: webapp
type: improvement
---
When you create a Personal Access Token, the generated token now shows its first and last few characters instead of being fully hidden, so you can confirm you copied the right value.
@@ -1,6 +0,0 @@
---
area: webapp
type: improvement
---
Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch.
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Cut webapp CPU usage by about a quarter on the routes that workers call most, freeing headroom at the same request rate. Detailed event-loop blocking traces are no longer recorded by default, because producing them was itself a large part of that cost.
@@ -1,6 +0,0 @@
---
area: supervisor
type: improvement
---
Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor.
+29 -3
View File
@@ -81,6 +81,18 @@ pnpm run lint:fix # oxlint — auto-fixes lint violations
pnpm run lint # oxlint — check only (no fixes)
```
### Dead code
We use knip to control unused dependencies and code. It is enforced by CI `code-quality`.
Scan your code before pushing with:
```bash
pnpm run knip
```
If there are false positives, edit ./knip.json so that it passes.
### Imports
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:
@@ -92,7 +104,21 @@ Dynamic imports add unnecessary overhead in hot paths and make code harder to an
## Changesets and Server Changes
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
Changesets and `.server-changes/` files are **user-facing release notes**. They ship verbatim into the changelog that customers read to decide what to upgrade for or pay attention to. They are not a catalog of every change: anyone who wants the exact history reads the commits. So the question is not "did I touch a public package or a server app?" but **"would a user or customer care about this change?"**
**Add one** when the change is something a user would notice, act on, or want to hear about: a new feature, a bug fix they could have hit, a behavior or performance change they would feel, a breaking change.
**Skip it** (no changeset, no `.server-changes/` file) when the change is not worth communicating to users, even if it touches a public package or a server app. For example:
- internal-only or admin-only changes, refactors, test-only changes, chores
- performance or query tuning with no user-visible behavior change
- changes to a public package that is not consumed independently (e.g. `@trigger.dev/redis-worker`), where a version bump means nothing to a user
When in doubt, ask a maintainer rather than adding a note by default. An unnecessary entry is noise in the changelog, not a safe default.
### How to add one
When a **public package** (`packages/*` or `integrations/*`) change is user-facing, add a changeset:
```bash
pnpm run changeset:add
@@ -102,9 +128,9 @@ pnpm run changeset:add
- Confirm with maintainers before selecting **minor** (new features)
- **Never** select major without explicit approval
When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
When a **server-only** change (`apps/webapp/`, `apps/supervisor/`, etc., with no package changes) is user-facing, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
**Write the description for users, not maintainers.** Both changesets and `.server-changes/` notes ship verbatim in user-visible release notes. Lead with what changed *for the user* - one plain sentence describing behavior, not implementation, and never naming internal tools or infra. The full writing guidance in `.server-changes/README.md` applies to changesets too.
**Write the description for users, not maintainers.** Both changesets and `.server-changes/` notes ship verbatim in user-visible release notes. Lead with what changed *for the user*: one plain sentence describing behavior, not implementation, and never naming internal tools or infra. The full writing guidance in `.server-changes/README.md` applies to changesets too.
## Dependency Pinning
+10 -6
View File
@@ -4,11 +4,13 @@ Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manag
## Adding a changeset (package changes)
To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
Changesets and `.server-changes/` files are user-facing release notes, not a catalog of every change. Add one only when the change is something a user would notice or act on. Skip it for internal-only changes, refactors, chores, and packages that are not consumed independently (e.g. `@trigger.dev/redis-worker`). Anyone who wants the exact history reads the commits.
To add a changeset, use `pnpm run changeset:add` and follow the [Changesets adding-a-changeset guide](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
## Adding a server change (server-only changes)
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset:
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.), does NOT change any published packages, AND the change is user-facing, add a `.server-changes/` file instead of a changeset:
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
@@ -24,17 +26,19 @@ EOF
- `area`: `webapp` | `supervisor`
- `type`: `feature` | `fix` | `improvement` | `breaking`
For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed.
For **mixed PRs** (both packages and server): the changeset covers it, so no `.server-changes/` file is needed. If the package change is internal and needs no changeset but the server change is user-facing, add a `.server-changes/` file for it.
See `.server-changes/README.md` for full documentation.
## When to add which
Only for user-facing changes. Skip the note entirely for internal-only or admin-only changes, refactors, and chores.
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
| Only packages (`packages/` or `integrations/`) | Changeset (`pnpm run changeset:add`), if the package change is user-facing |
| Only server (`apps/`) | `.server-changes/` file, if the server change is user-facing |
| Both packages and server | The changeset covers it; if the package change needs no changeset but the server change is user-facing, add a `.server-changes/` file |
## Release instructions (CI)
+23 -9
View File
@@ -214,6 +214,19 @@ pnpm exec trigger dev --log-level debug
5. Commit the generated migration files as well as the changes to `schema.prisma`.
6. If you're using VSCode you may need to restart the TypeScript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
## Git hooks (lefthook)
We use [lefthook](https://lefthook.dev) for local git hooks, configured in `lefthook.yml` (the source of truth for what runs and when). Today that's a pre-push hook mirroring the CI `code-quality` checks; the set may grow, so check `lefthook.yml` rather than this guide.
Hooks install automatically on `pnpm install`. A failing hook prints exactly what to run to fix it.
**Opting out**
- GitButler skips hooks on `but push` unless you enable **Run hooks** in the project settings (off by default).
- Plain git: `LEFTHOOK=0 git push` / `--no-verify` to skip once; `pnpm exec lefthook uninstall` to remove.
This never affects correctness — CI enforces the same checks on every PR; the hooks just give you faster feedback.
## Making a pull request
**If you get errors, be sure to fix them before committing.**
@@ -225,10 +238,11 @@ pnpm exec trigger dev --log-level debug
1. **Always open your PR in draft status first.** Do not mark it as "Ready for Review" until the steps below are complete.
2. **Run format and lint locally before pushing:**
```bash
pnpm run format # auto-fixes formatting (oxfmt)
pnpm run lint:fix # auto-fixes lint violations (oxlint)
pnpm run format
pnpm run lint
pnpm run knip
```
Both are enforced by CI — the `code-quality` check will fail if either produces a diff or errors.
These are enforced by CI — the `code-quality` check will fail if either produces a diff or errors.
3. **Address all CodeRabbit code review comments.** Our CI runs an automated code review via CodeRabbit. Go through each comment and either fix the issue or resolve it with a comment explaining why no change is needed.
4. **Wait for all CI checks to pass.** Do not mark the PR as "Ready for Review" until every check is green.
5. **Then mark the PR as "Ready for Review"** so a maintainer can take a look.
@@ -247,7 +261,7 @@ If your change touches core infrastructure, modifies widely-used code paths, or
We use [changesets](https://github.com/changesets/changesets) to manage our package versions and changelogs. If you've never used changesets before, first read [their guide here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
If you are contributing a change to any packages in this monorepo (anything in either the `/packages` or `/integrations` directories), then you will need to add a changeset to your Pull Requests before they can be merged.
Changesets are user-facing release notes, not a catalog of every change. If you are contributing a **user-facing** change to a package in this monorepo (anything in `/packages` or `/integrations` that a user would notice or act on), add a changeset to your Pull Request before it can be merged. Skip the changeset for internal-only changes, refactors, chores, and packages that are not consumed independently (e.g. `@trigger.dev/redis-worker`), where a version bump means nothing to a user.
To add a changeset, run the following command in the root of the repo
@@ -265,7 +279,7 @@ Most of the time the changes you'll make are likely to be categorized as patch r
## Adding server changes
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes.
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes AND the change is user-facing, add a `.server-changes/` file so the change appears in release notes. Skip it for internal-only or admin-only changes, refactors, and chores.
Create a markdown file with a descriptive name:
@@ -286,13 +300,13 @@ EOF
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
**When to add which:**
**When to add which** (only for user-facing changes; skip the note entirely for internal-only or admin-only changes, refactors, and chores):
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
| Only packages (`packages/` or `integrations/`) | Changeset (if the package change is user-facing) |
| Only server (`apps/`) | `.server-changes/` file (if the server change is user-facing) |
| Both packages and server | The changeset covers it; if the package change needs no changeset but the server change is user-facing, add a `.server-changes/` file |
See `.server-changes/README.md` for more details.
+221
View File
@@ -0,0 +1,221 @@
# Onboarding: taking over the hosted webhooks PR (#4344) for design
You are picking up **PR #4344 "hosted webhooks, agent channels, and human-in-the-loop"** to own the UX and front-end. This doc gets you from a clean machine to a running dashboard with realistic webhook data you can screenshot, restyle, and iterate on.
The feature is built and green (all backend plumbing, all four dashboard surfaces, the in-app test console). Your job is the visual and interaction design of the dashboard surfaces, not the backend. Everything below is oriented around that.
---
## 1. What you are designing
Hosted webhooks let a Trigger.dev user receive and verify a provider's webhooks (Stripe, GitHub, and so on) as a task, with no ingress or verification code of their own. A `webhook()` handler in their project gets a hosted URL; deliveries to that URL are verified, recorded, and routed to their `onEvent` handler.
The dashboard has **four surfaces you own**, all under the "Webhooks" nav section (teal icon):
| Surface | Route (under `/orgs/:org/projects/:project/env/:env`) | What it shows |
| --- | --- | --- |
| **Deliveries list** | `/webhooks` | Every delivery across all endpoints in the environment. Runs-style filter bar (Status, Webhook, Created, plus a More-filters menu for Delivery ID / Run ID), applied-filter pills, a Webhook column linking to the handler. This is the main screen. |
| **Delivery detail** | `/webhooks/deliveries/:deliveryParam` | One delivery. Main panel is a tabbed view (Event payload / Request headers) rendered as JSON. Sidebar property table (status badge, webhook + run links, external delivery id, idempotency key, timestamps, computed duration, error). Also has a friendly "not available / retained for N days" empty state for expired or bogus links. |
| **Handler detail + Console** | `/webhooks/:webhookParam` | The handler (the `webhook()` in the user's code). Tabs: Deliveries, Runs, Endpoints. This page also hosts the **Webhook Console / Composer** (see section 5), the tool you will lean on for data. |
| **Endpoint detail** | `/webhooks/endpoints/:endpointParam` | One endpoint. Left: scoped deliveries. Right: a **Connect** card (webhook URL, signing secret set/rotate/generate, provider setup rendered from the verifier config), Routing, Scope, Metadata. |
The status vocabulary, badges, and colors live in `components/webhookDeliveries/v1/DeliveryStatus.tsx` and `components/webhookEndpoints/v1/EndpointStatus.tsx`. The nav accent color is a Tailwind token `--color-webhooks` (teal), used via `text-webhooks`.
---
## 2. Get the code
You need the PR branch, `feat/hosted-webhook-ingress`.
```bash
git clone https://github.com/triggerdotdev/trigger.dev.git
cd trigger.dev
gh pr checkout 4344 # lands you on feat/hosted-webhook-ingress
```
If you plan to push design changes back to this branch, coordinate with Eric first: the branch is rebased and force-pushed periodically, so agree on timing or work on a child branch and open a follow-up.
Toolchain: pnpm 10.33.2 via corepack, Node 22+. Use `corepack pnpm` (a bare `pnpm` can be an old global that wipes `node_modules`).
```bash
corepack enable
corepack pnpm install
```
---
## 3. Bring the stack up
Four services and the webapp. Run from the repo root.
```bash
# 1. Core dev services: Postgres, Redis, Electric, MinIO, ClickHouse, s2-lite
corepack pnpm run docker
# 2. Config
cp .env.example .env
```
Now edit `.env` and add the two webhook-delivery replication lines (they are NOT in `.env.example`, and without them the Deliveries list looks empty even after you send webhooks, see section 5):
```bash
# webhook deliveries replication (required for the Deliveries list/detail to populate)
WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
WEBHOOK_DELIVERIES_REPLICATION_ENABLED=1
```
Then migrate, seed, build, and run:
```bash
corepack pnpm run db:migrate
corepack pnpm run db:seed # creates the References org + hello-world project
# Build the pieces you will run (do these sequentially, not with db:seed running)
corepack pnpm run build --filter webapp --filter trigger.dev --filter "@trigger.dev/sdk"
# Run the webapp (http://localhost:3030)
corepack pnpm run dev --filter webapp
curl -s http://localhost:3030/healthcheck # verify
```
**Log in (dev):** open http://localhost:3030, submit the email `local@trigger.dev`. Dev auto-verifies the magic link (watch the webapp log for `/magic?token=`). That seeded user is an org admin, which matters for the next step.
---
## 4. Turn the feature on
The dashboard is gated by a feature flag, `hasWebhooksAccess` (default off).
- The seeded dev user `local@trigger.dev` is an **admin**, and admins bypass the flag, so on a fresh seed the Webhooks nav section is already visible to you. Nothing to do.
- If you use a non-admin user, set `featureFlags.hasWebhooksAccess = true` on the `Organization` row to reveal the nav section. (A global `FeatureFlag` row with key `hasWebhooksAccess` makes the pages reachable by URL, but the left nav reads only org-level flags, so the section stays hidden for non-admins.)
If the "Webhooks" section is missing from the left nav, this flag is why.
---
## 5. Get nice data (the part that matters)
Delivery rows are what make these screens interesting: a spread of providers, statuses, payloads, timestamps. Here is how the data flows and how to produce it.
### The pipeline (why an empty list is usually a setup issue, not a bug)
`ingest -> engine (verify, filter, route) -> Postgres WebhookDelivery rows -> replication -> ClickHouse`. The Deliveries **list orders and paginates from ClickHouse**, then hydrates every visible field from Postgres. So if replication is off (section 3), you can create deliveries and still see an empty list. Enable the two replication env vars and restart the webapp.
One caveat baked into the design: replication starts streaming from the moment it is enabled, so deliveries written **before** you turned it on will not appear. Turn replication on first, then generate data.
### Fastest path: the seed script
There is a seed script that inserts a full, stable dataset directly into both stores (Postgres and ClickHouse), so you get realistic screens on a fresh DB with no workers, no `trigger dev`, and no signing secrets to set:
```bash
corepack pnpm --filter webapp run db:seed:webhooks
# optional: deliveries per endpoint (default 45)
corepack pnpm --filter webapp run db:seed:webhooks -- 60
```
It creates six endpoints across different providers and verifier schemes (Stripe, GitHub, Slack, Svix, Discord, and a custom shared-secret one, with a mix of active/inactive and secret-set/not-set), then a spread of deliveries over the last two weeks covering **every** delivery status (SUCCEEDED, FAILED, FILTERED, PENDING, PROCESSING), realistic per-provider payloads and headers, and a mix of test and live. It attaches to the first DEVELOPMENT environment your local user can see (set `WEBHOOK_SEED_PROJECT="<project name>"` to target a specific one), and prints the exact Deliveries URL when it finishes. Re-running clears and reseeds that environment, so you always get the same clean dataset. The script is `apps/webapp/seed-webhook-deliveries.ts`; edit the `ENDPOINTS` array or the status weights to shape the data to whatever you are designing.
Because it writes the ClickHouse rows directly, seeded data shows up **without** the replication setup in section 3. That replication env is only needed for the live and Composer paths below. (The seed uses `WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL` if set, otherwise `CLICKHOUSE_URL`, which is already in `.env.example`.)
This is the recommended way to get data. The interactive paths below are for exercising the live pipeline (real verification, real routed runs) or the in-app test console.
### Interactive: create an endpoint (one-time)
The Composer sends to an endpoint, and endpoints only exist once a Trigger project that declares a `webhook()` has been dev-run or deployed. Quickest path: a tiny demo project.
```ts
// demo/src/trigger/demo-webhook.ts
import { webhook, webhooks } from "@trigger.dev/sdk";
export const demoWebhook = webhook({
id: "demo-webhook",
source: webhooks.custom<{ message: string }>({ /* generic HMAC */ }),
onEvent: async ({ event, headers, ctx }) => {
// event is the parsed body, headers is a Web Headers object
},
});
// A real provider, for realistic payloads:
export const stripeWebhook = webhook({
id: "stripe-webhook",
source: webhooks.stripe(),
onEvent: async ({ event }) => {},
});
```
Link that demo project to your local build and run `trigger dev` (see `AGENTS.md` "Testing with the hello-world Reference Project" for linking; the `triggerdotdev/references` repo has ready-made projects). Running `trigger dev` registers the `webhook()` handlers, which creates their endpoints. Set each endpoint's signing secret from the **endpoint detail Connect card** (Generate or paste).
### Interactive: fire deliveries with the Webhook Console
Open the handler detail page (`/webhooks/:webhookParam`). It hosts the **Composer** (`components/webhookConsole/WebhookComposer.tsx`). It has four source tabs and four signature modes, and it injects the delivery straight through the engine in-process, so it is fast and does not consume any real rate budget:
- **Sample tab**: pick a real provider event from the built-in catalog (`@internal/webhook-sources`, six first-class providers plus a large sample manifest). This is the fastest way to get realistic Stripe / GitHub / Svix / Square / Discord payloads with correct-looking headers.
- **Body tab**: hand-write any JSON.
- **Replay tab**: re-send a prior delivery.
- **AI tab**: generate a payload with a prompt.
- **Signature modes** `signed | unsigned | tampered | simulate`: this is how you produce a **spread of delivery statuses**. `signed` (with a secret set) verifies and routes to a SUCCEEDED delivery; `unsigned` and `tampered` produce failed/rejected deliveries. Send a mix to populate every status badge you need to design.
To get SUCCEEDED deliveries whose **runs** also complete (nicest end-to-end data), keep the demo project's `trigger dev` running so the routed task actually executes.
### Interactive: a real provider (most realistic)
For genuine payloads and headers, point the Stripe CLI at an endpoint: `stripe listen --forward-to http://localhost:3030/webhooks/v1/ingest/<opaqueId>`, set that endpoint's `whsec` via the Connect card, then `stripe trigger payment_intent.succeeded`.
---
## 6. Where the front-end code lives
| Area | Path |
| --- | --- |
| Routes (pages) | `apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks*` |
| Deliveries list / detail components | `apps/webapp/app/components/webhookDeliveries/v1/` (`DeliveriesTable`, `DeliveryStatus`, `WebhookDeliveryFilters`, `DeliveryTimeline`, `useDeliveriesLiveReload`) |
| Endpoint components | `apps/webapp/app/components/webhookEndpoints/v1/` (`EndpointsTable`, `EndpointStatus`) |
| Console / Composer | `apps/webapp/app/components/webhookConsole/` (`WebhookComposer`, `SampleSourcePicker`, `ReplaySourcePicker`) |
| Data (presenters, read-only from your side) | `apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts`, `WebhookDeliveryDetailPresenter.server.ts`, `WebhookDetailPresenter.server.ts`, `webhookComposerEndpoints.server.ts` |
| Nav entry | `apps/webapp/app/components/navigation/SideMenu.tsx` (the `staticSections` "webhooks" push) |
| Path builders | `apps/webapp/app/utils/pathBuilder.ts` (`v3WebhooksPath`, `v3WebhookDeliveryPath`, `v3WebhookEndpointPath`, `v3WebhookTaskPath`) |
| Accent color token | `apps/webapp/app/tailwind.css` (`--color-webhooks`, used as `text-webhooks`) |
| Data seed script | `apps/webapp/seed-webhook-deliveries.ts` (run via `db:seed:webhooks`) |
**Styling:** the webapp is on Tailwind v4 (CSS-first `@theme` in `apps/webapp/app/tailwind.css`, there is no `tailwind.config.js`). Add or change design tokens there.
**Design language to match:** these screens deliberately reuse the Runs page primitives (the filter bar is built from `RunFilters` / `SharedFilters`, the tables mirror the Runs table cells). Match the Runs and Sessions pages, not a new visual system.
---
## 7. Iterating
- **HMR vs restart:** editing a component (`.tsx`) hot-reloads. Editing a `.server.ts` file makes the Remix dev server restart the app (a brief connection refused, then it comes back). Editing Tailwind tokens hot-reloads.
- **Screenshots:** capture from the running dashboard at http://localhost:3030. Save shots outside the repo or to a scratch folder so they do not get committed.
- **Typecheck after non-trivial changes:** `corepack pnpm run typecheck --filter webapp` (about 1 to 2 minutes). For small style tweaks, trust it and let CI catch anything.
- **One boundary gotcha that the dev server will NOT catch:** route files must not leak server-only imports into the client bundle. The dev server tolerates it, but the production build fails. If you touch a route file and import anything server-only, run `corepack pnpm --filter webapp run build:remix` before pushing. Pure component and style edits are unaffected.
---
## 8. Shipping your changes
Follow the repo PR workflow:
- Format and lint before committing: `corepack pnpm run format` (oxfmt) and `corepack pnpm run lint:fix` (oxlint). CI enforces both.
- Commit style is Conventional Commits, for example `feat(webapp): redesign webhook deliveries table`. No emoji, no attribution footer.
- The PR is a **draft** awaiting an AI review pass, then a human review, before it flips to ready. Do not flip it to ready yourself; push your commits and let Eric coordinate the review and any rebase onto `main`.
- CI to expect: `code-quality` (oxfmt + oxlint), `typecheck`, webapp unit shards, and the Playwright `e2e-webapp` job. Style-only changes usually only risk `code-quality`.
---
## 9. Quick reference
- **Webapp:** http://localhost:3030 (port comes from `REMIX_APP_PORT`, falling back to `PORT`/3030).
- **Default docker services:** Postgres 5432, Redis 6379, ClickHouse HTTP 8123 (`default:password`), MinIO, Electric, s2-lite.
- **Feature flag:** `hasWebhooksAccess` (admins bypass).
- **Seed data:** `corepack pnpm --filter webapp run db:seed:webhooks` (append `-- <n>` for deliveries per endpoint).
- **Must-set env for data to show:** `WEBHOOK_DELIVERIES_REPLICATION_ENABLED=1` and `WEBHOOK_DELIVERIES_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123`.
- **Login:** `local@trigger.dev`, magic link auto-verifies in dev.
- **Feature docs:** `docs/webhooks/` (overview, sources, connect, deliveries, channels, human-in-the-loop). Read `overview.mdx` and `deliveries.mdx` first for the mental model behind the screens.
- **PR:** https://github.com/triggerdotdev/trigger.dev/pull/4344
---
## 10. Mental model in one paragraph
A user writes a `webhook()` in their project. On deploy (or `trigger dev`) that handler gets one or more hosted endpoints, each with a signing secret. A provider POSTs to the endpoint's URL; the engine verifies the signature, optionally filters, records a `WebhookDelivery`, and triggers the routed task run. The dashboard reads those deliveries: the list orders them out of ClickHouse and hydrates the rest from Postgres, the detail page reads Postgres directly (it holds the only copy of the event payload and headers). Everything you design sits on top of that delivery record and the endpoint that produced it.
+1 -1
View File
@@ -21,7 +21,7 @@
"ioredis": "~5.6.0",
"p-limit": "^6.2.0",
"prom-client": "^15.1.0",
"socket.io": "4.7.4",
"socket.io": "4.8.3",
"std-env": "^3.8.0",
"zod": "3.25.76"
},
@@ -8,6 +8,8 @@ export class BackpressureMetrics {
readonly dryRun: Gauge<string>;
/** Dequeue attempts the gate skipped - or would have, in dry-run (labelled). */
readonly skipsTotal: Counter<string>;
/** Verdict source reads that failed (threw). */
readonly readFailuresTotal: Counter<string>;
constructor(opts: { register: Registry; prefix?: string }) {
const prefix = opts.prefix ?? "supervisor_backpressure";
@@ -30,5 +32,11 @@ export class BackpressureMetrics {
labelNames: ["dry_run"],
registers: [opts.register],
});
this.readFailuresTotal = new Counter({
name: `${prefix}_read_failures_total`,
help: "Verdict source reads that threw",
registers: [opts.register],
});
}
}
@@ -89,6 +89,60 @@ describe("BackpressureMonitor", () => {
monitor.stop();
});
it("holds an engaged verdict while reads fail, then releases past the max age", async () => {
let call = 0;
const source: BackpressureSignalSource = {
read: async () => {
call++;
if (call === 1) {
return { engaged: true, ts: Date.now() };
}
throw new Error("signal source unreachable");
},
};
const monitor = new BackpressureMonitor({
enabled: true,
source,
refreshIntervalMs: 1000,
maxVerdictAgeMs: 15_000,
});
monitor.start();
await vi.advanceTimersByTimeAsync(0);
expect(monitor.shouldSkipDequeue()).toBe(true);
await vi.advanceTimersByTimeAsync(5000);
expect(monitor.shouldSkipDequeue()).toBe(true); // read failing, verdict held
await vi.advanceTimersByTimeAsync(11_000);
expect(monitor.shouldSkipDequeue()).toBe(false); // past max age, released
monitor.stop();
});
it("releases immediately on an explicit null even when a grace window is configured", async () => {
let engaged: boolean | null = true;
const source: BackpressureSignalSource = {
read: async () => (engaged === null ? null : { engaged, ts: Date.now() }),
};
const monitor = new BackpressureMonitor({
enabled: true,
source,
refreshIntervalMs: 1000,
maxVerdictAgeMs: 15_000,
});
monitor.start();
await vi.advanceTimersByTimeAsync(0);
expect(monitor.shouldSkipDequeue()).toBe(true);
engaged = null;
await vi.advanceTimersByTimeAsync(1000);
expect(monitor.shouldSkipDequeue()).toBe(false); // null is an answer, not a failure
monitor.stop();
});
it("fails open when the source reports unknown (null)", async () => {
const { source } = countingSource(null);
const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 });
@@ -292,6 +346,7 @@ describe("BackpressureMonitor", () => {
const logs: Array<{ message: string; meta?: Record<string, unknown> }> = [];
const logger = {
info: (message: string, meta?: Record<string, unknown>) => logs.push({ message, meta }),
error: (message: string, meta?: Record<string, unknown>) => logs.push({ message, meta }),
};
const monitor = new BackpressureMonitor({
enabled: true,
@@ -1,7 +1,8 @@
import type { BackpressureMetrics } from "./backpressureMetrics.js";
export interface BackpressureLogger {
interface BackpressureLogger {
info(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
}
export type BackpressureVerdict = {
@@ -11,9 +12,10 @@ export type BackpressureVerdict = {
};
/**
* Source of the current backpressure verdict. `read()` returns `null` when the
* verdict is unknown (missing/unreadable) - the monitor treats unknown as
* "not engaged" (fail-open).
* Source of the current backpressure verdict. `read()` returns `null` when the source
* answered but there is no verdict - the monitor treats that as "not engaged"
* (fail-open). A thrown error is different: the read itself failed, so the monitor
* keeps the previous verdict until it ages past `maxVerdictAgeMs`.
*/
export interface BackpressureSignalSource {
read(): Promise<BackpressureVerdict | null>;
@@ -24,8 +26,9 @@ export type BackpressureMonitorOptions = {
source: BackpressureSignalSource;
refreshIntervalMs?: number;
/**
* If set, a cached verdict older than this is treated as unknown (fail-open).
* Guards against the source silently going stale (e.g. hanging reads).
* If set, an engaged verdict older than this is released (fail-open), bounding how
* long a dead source can hold the brake. Reads that fail keep the last verdict, so
* this doubles as the grace window for riding out a transient source outage.
*/
maxVerdictAgeMs?: number;
/**
@@ -54,6 +57,7 @@ export class BackpressureMonitor {
private refreshInFlight = false;
private wasEngaged = false;
private releasedAt?: number;
private readFailing = false;
constructor(private readonly opts: BackpressureMonitorOptions) {
this.opts.metrics?.dryRun.set(this.opts.dryRun ? 1 : 0);
@@ -152,12 +156,31 @@ export class BackpressureMonitor {
}
private async refresh(): Promise<void> {
let next: BackpressureVerdict | null = null;
let readError: unknown;
try {
this.verdict = await this.opts.source.read();
} catch {
// Fail-open: a dead/unreachable source must never pin the brake. Treat as
// unknown (no verdict) so dequeue resumes as if backpressure were off.
this.verdict = null;
next = await this.opts.source.read();
} catch (error) {
readError = error;
}
if (readError === undefined) {
this.verdict = next; // an explicit null means "no pressure", so honour it
this.readFailing = false;
} else {
const held = this.opts.maxVerdictAgeMs !== undefined;
if (!held) {
this.verdict = null; // unbounded hold could pin the brake forever
}
this.opts.metrics?.readFailuresTotal.inc();
if (!this.readFailing) {
this.readFailing = true; // log once per outage, not once per tick
this.opts.logger?.error("backpressure read failed", {
reason: String(readError),
heldPreviousVerdict: held,
engaged: this.computeEngaged(),
});
}
}
// Track the engaged→released transition to anchor the resume ramp. Use the
@@ -1,52 +1,49 @@
import { describe, it, expect } from "vitest";
import { parsePodCount, K8sPodCountSignalSource } from "./k8sPodCountSignalSource.js";
import { K8sPodCountSignalSource } from "./k8sPodCountSignalSource.js";
import { podCountFromList, withTimeout } from "../clients/kubernetes.js";
describe("parsePodCount", () => {
it("reads the pods object count", () => {
const text = [
"# HELP apiserver_storage_objects Number of stored objects",
"# TYPE apiserver_storage_objects gauge",
'apiserver_storage_objects{resource="pods"} 8421',
'apiserver_storage_objects{resource="configmaps"} 17',
].join("\n");
expect(parsePodCount(text)).toBe(8421);
describe("podCountFromList", () => {
it("returns items.length when the list is not truncated", () => {
expect(podCountFromList({ items: [{}], metadata: {} })).toBe(1);
});
it("is tolerant of extra labels in any order", () => {
const text = 'apiserver_storage_objects{group="",resource="pods",extra="x"} 12';
expect(parsePodCount(text)).toBe(12);
it("returns zero for an empty namespace", () => {
expect(podCountFromList({ items: [], metadata: {} })).toBe(0);
});
it("parses scientific notation", () => {
const text = 'apiserver_storage_objects{resource="pods"} 1.2e+04';
expect(parsePodCount(text)).toBe(12000);
it("adds remainingItemCount when the list is truncated", () => {
const list = { items: [{}], metadata: { _continue: "tok", remainingItemCount: 24492 } };
expect(podCountFromList(list)).toBe(24493);
});
it("throws when the pods metric is absent", () => {
const text = 'apiserver_storage_objects{resource="configmaps"} 17';
expect(() => parsePodCount(text)).toThrow(/not found/);
it("throws when truncated but remainingItemCount is absent", () => {
const list = { items: [{}], metadata: { _continue: "tok" } };
expect(() => podCountFromList(list)).toThrow(/remainingItemCount/);
});
it("throws on a non-finite value (e.g. 1e999)", () => {
const text = 'apiserver_storage_objects{resource="pods"} 1e999';
expect(() => parsePodCount(text)).toThrow();
});
it("throws on a negative value", () => {
const text = 'apiserver_storage_objects{resource="pods"} -5';
expect(() => parsePodCount(text)).toThrow();
it("throws when truncated but remainingItemCount is negative", () => {
const list = { items: [{}], metadata: { _continue: "tok", remainingItemCount: -1 } };
expect(() => podCountFromList(list)).toThrow(/remainingItemCount/);
});
});
function metrics(count: number): string {
return `apiserver_storage_objects{resource="pods"} ${count}`;
}
describe("withTimeout", () => {
it("rejects once the deadline passes", async () => {
await expect(withTimeout(new Promise(() => {}), 10, "pod count list")).rejects.toThrow(
/timed out/
);
});
it("passes a value through when it settles first", async () => {
await expect(withTimeout(Promise.resolve(7), 1000, "pod count list")).resolves.toBe(7);
});
});
describe("K8sPodCountSignalSource", () => {
it("engages at the engage threshold and reports the count", async () => {
const counts: number[] = [];
const source = new K8sPodCountSignalSource({
fetchMetrics: async () => metrics(10000),
fetchPodCount: async () => 10000,
engageThreshold: 10000,
releaseThreshold: 5000,
reportPodCount: (c) => counts.push(c),
@@ -59,7 +56,7 @@ describe("K8sPodCountSignalSource", () => {
it("does not engage below the engage threshold", async () => {
const source = new K8sPodCountSignalSource({
fetchMetrics: async () => metrics(9999),
fetchPodCount: async () => 9999,
engageThreshold: 10000,
releaseThreshold: 5000,
});
@@ -69,7 +66,7 @@ describe("K8sPodCountSignalSource", () => {
it("stays engaged in the hysteresis band, releases only below release threshold", async () => {
let count = 10000;
const source = new K8sPodCountSignalSource({
fetchMetrics: async () => metrics(count),
fetchPodCount: async () => count,
engageThreshold: 10000,
releaseThreshold: 5000,
});
@@ -82,9 +79,9 @@ describe("K8sPodCountSignalSource", () => {
expect((await source.read()).engaged).toBe(false); // band again -> stays off
});
it("propagates scrape failures (monitor fails open on throw)", async () => {
it("propagates fetch failures (monitor fails open on throw)", async () => {
const source = new K8sPodCountSignalSource({
fetchMetrics: async () => {
fetchPodCount: async () => {
throw new Error("connection refused");
},
engageThreshold: 10000,
@@ -1,22 +1,7 @@
import type { BackpressureSignalSource, BackpressureVerdict } from "./backpressureMonitor.js";
// Reads the apiserver's stored-pod-object count from a Prometheus /metrics scrape.
const POD_COUNT_RE = /^apiserver_storage_objects\{[^}]*resource="pods"[^}]*\}\s+([0-9.eE+]+)/m;
export function parsePodCount(metricsText: string): number {
const match = metricsText.match(POD_COUNT_RE);
if (!match) {
throw new Error('apiserver_storage_objects{resource="pods"} not found in metrics');
}
const value = Number(match[1]);
if (!Number.isFinite(value)) {
throw new Error(`unparseable pod count: ${match[1]}`);
}
return value;
}
export type K8sPodCountSignalSourceOptions = {
fetchMetrics: () => Promise<string>;
fetchPodCount: () => Promise<number>;
engageThreshold: number;
releaseThreshold: number;
reportPodCount?: (count: number) => void;
@@ -29,8 +14,7 @@ export class K8sPodCountSignalSource implements BackpressureSignalSource {
constructor(private readonly opts: K8sPodCountSignalSourceOptions) {}
async read(): Promise<BackpressureVerdict> {
const text = await this.opts.fetchMetrics();
const count = parsePodCount(text);
const count = await this.opts.fetchPodCount();
this.opts.reportPodCount?.(count);
if (this.engaged) {
+80 -46
View File
@@ -2,9 +2,8 @@ import * as k8s from "@kubernetes/client-node";
import type { Informer, KubernetesObject, ListPromise } from "@kubernetes/client-node";
import { assertExhaustive } from "@trigger.dev/core/utils";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import * as https from "node:https";
export const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const logger = new SimpleStructuredLogger("kubernetes-client");
@@ -54,55 +53,90 @@ function getKubeConfig() {
export { k8s };
/**
* Builds a function that scrapes the apiserver's Prometheus /metrics endpoint.
* One lightweight aggregate read - not a pod listing. Requires the service
* account to be granted GET on the /metrics non-resource URL.
* createPodCountFetcher sizes a namespace's pod collection with a single `limit=1`
* list: one pod transferred, no informer, no watch cache.
*
* This is an ESTIMATE, not an exact count. Kubernetes documents `remainingItemCount`
* as intended for estimating collection size and reserves the right not to set it or
* make it exact. Counting exactly would mean paginating the whole collection, which is
* what this deliberately avoids. Treat the value as a tight estimate from a quorum read
* at request time, and set thresholds with that in mind.
*
* Two request-shape constraints, both load-bearing. A label or field selector makes
* the apiserver omit `remainingItemCount` entirely, and setting `resourceVersion`
* serves a cached count instead of a quorum read - so neither is passed.
*/
export function createApiserverMetricsFetcher(timeoutMs: number): () => Promise<string> {
const kubeConfig = getKubeConfig();
export function createPodCountFetcher(
api: K8sApi,
namespace: string,
timeoutMs: number
): () => Promise<number> {
const serverTimeoutSeconds = Math.max(1, Math.floor(timeoutMs / 1000));
let pending: Promise<unknown> | undefined;
return async () => {
const cluster = kubeConfig.getCurrentCluster();
if (!cluster) {
throw new Error("no current cluster in kubeconfig");
if (pending) {
throw new Error("pod count list still in flight from a previous tick");
}
const url = new URL(`${cluster.server}/metrics`);
const opts: https.RequestOptions = {
method: "GET",
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: url.pathname,
};
// applyToHTTPSOptions sets the cluster CA, client cert/key, and auth headers
// (incl. exec plugins) on the request - so TLS verifies against the cluster
// CA, not the system store. The fetch-options path attaches the CA as an
// https.Agent, which global fetch (undici) ignores.
await kubeConfig.applyToHTTPSOptions(opts);
return new Promise<string>((resolve, reject) => {
const req = https.request(opts, (res) => {
const status = res.statusCode ?? 0;
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
if (status >= 200 && status < 300) {
resolve(body);
} else {
reject(new Error(`apiserver /metrics scrape failed: ${status}`));
}
});
});
// Without this a hung connect/TLS/read never settles, and the monitor's
// refreshInFlight guard would freeze the source (silent fail-open).
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`apiserver /metrics scrape timed out after ${timeoutMs}ms`));
});
req.on("error", reject);
req.end();
const request = api.core.listNamespacedPod({
namespace,
limit: 1,
timeoutSeconds: serverTimeoutSeconds,
});
pending = request
.catch(() => {})
.finally(() => {
pending = undefined;
});
return podCountFromList(await withTimeout(request, timeoutMs, "pod count list"));
};
}
/**
* podCountFromList turns a `limit=1` pod list into a population estimate.
*
* `remainingItemCount` is only set when the list is truncated, so `_continue` is the
* truncation signal: absent means the returned page is the whole collection and its
* length is exact. When truncated the total leans on `remainingItemCount`, which is
* documented as an estimate - so the result is an estimate too. Truncated without a
* usable count is unknowable, so it throws rather than returning a low number the
* caller would act on.
*/
export function podCountFromList(list: {
items: unknown[];
metadata?: { _continue?: string; remainingItemCount?: number };
}): number {
if (!list.metadata?._continue) {
return list.items.length;
}
const remaining = list.metadata.remainingItemCount;
if (typeof remaining !== "number" || !Number.isFinite(remaining) || remaining < 0) {
throw new Error("pod list truncated but remainingItemCount absent or invalid");
}
return list.items.length + remaining;
}
/**
* withTimeout rejects if `promise` outlives `timeoutMs`, so a hung request cannot
* freeze the caller. It cannot cancel: the k8s client threads no AbortSignal through to
* fetch, so an abandoned request keeps running. Callers must therefore also bound the
* request server-side (`timeoutSeconds`) and refuse to start a second one while the
* first is pending, or a blackholed connection accumulates one socket per attempt.
*/
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, what: string): Promise<T> {
let timer: NodeJS.Timeout;
const deadline = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`${what} timed out after ${timeoutMs}ms`)),
timeoutMs
);
timer.unref();
});
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
}
+53 -61
View File
@@ -1,7 +1,13 @@
import { randomUUID } from "crypto";
import { env as stdEnv } from "std-env";
import { z } from "zod";
import { AdditionalEnvVars, BoolEnv } from "./envUtil.js";
import {
AdditionalEnvVars,
BoolEnv,
NodeLabelValue,
OrgPlacementOverrides,
Tolerations,
} from "./envUtil.js";
export const Env = z
.object({
@@ -22,6 +28,7 @@ export const Env = z
// also reject invalid tokens.
WORKLOAD_TOKEN_SECRET: z.string().optional(),
WORKLOAD_TOKEN_ENFORCEMENT: z.enum(["disabled", "log", "enforce"]).default("disabled"),
DELETE_CHECKPOINTS_ON_COMPLETION: BoolEnv.default(false), // irreversible; enable per cluster
// Absolute expiry for minted deployment tokens. Deterministic (no wall-clock issued-at) so every
// pod of a deployment carries an identical token; bump before this date. Must outlive any run.
WORKLOAD_TOKEN_EXP: z.string().datetime().default("2032-01-01T00:00:00.000Z"),
@@ -79,7 +86,7 @@ export const Env = z
.number()
.int()
.positive()
.default(15_000), // Stale verdict → fail-open (treat as not engaged)
.default(120_000), // Grace window: held verdict older than this → fail-open
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST: z.string().optional(),
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PORT: z.coerce.number().int().optional(),
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_USERNAME: z.string().optional(),
@@ -173,11 +180,14 @@ export const Env = z
// Kubernetes settings
KUBERNETES_FORCE_ENABLED: BoolEnv.default(false),
KUBERNETES_NAMESPACE: z.string().default("default"),
KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"),
KUBERNETES_WORKER_NODETYPE_LABEL: NodeLabelValue.default("v4-worker"),
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv
KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),
KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false),
KUBERNETES_IMAGE_REGISTRY_REWRITE_FROM: z.string().optional(),
KUBERNETES_IMAGE_REGISTRY_REWRITE_TO: z.string().optional(),
KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME: z.string().optional(),
KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0),
KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
@@ -203,6 +213,16 @@ export const Env = z
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
KUBERNETES_RUNNER_SECCOMP_PROFILE_PATH: z
.string()
.trim()
.min(1)
.default("profiles/block-io-uring.json"),
KUBERNETES_RUNNER_SECCOMP_PROFILE_RUNTIMES: z
.enum(["none", "node-24-plus", "all"])
.default("node-24-plus"),
KUBERNETES_RUNNER_SECURITY_CONTEXT: z.enum(["off", "baseline", "restricted"]).default("off"),
KUBERNETES_RUNNER_RUN_AS_USER: z.coerce.number().int().min(1).default(1000),
// Pod DNS config — override the cluster default ndots to `KUBERNETES_POD_DNS_NDOTS`.
// Default k8s ndots is 5: any name with fewer than 5 dots (e.g. `api.example.com`, 2 dots) is first walked
@@ -256,65 +276,13 @@ export const Env = z
.max(100)
.default(20),
// Schedule toleration settings - scheduled runs tolerate taints on the dedicated pool
// Comma-separated list of tolerations in the format: key=value:effect
// For Exists operator (no value): key:effect
KUBERNETES_SCHEDULED_RUN_TOLERATIONS: z
.string()
.transform((val, ctx) => {
const tolerations = val
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => {
const colonIdx = entry.lastIndexOf(":");
if (colonIdx === -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration format (missing effect): "${entry}"`,
});
return z.NEVER;
}
KUBERNETES_RUNNER_TOLERATIONS: Tolerations.optional(), // every run pod
KUBERNETES_SCHEDULED_RUN_TOLERATIONS: Tolerations.optional(), // schedule-tree runs only
const effect = entry.slice(colonIdx + 1);
const validEffects = ["NoSchedule", "NoExecute", "PreferNoSchedule"];
if (!validEffects.includes(effect)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration effect "${effect}" in "${entry}". Must be one of: ${validEffects.join(
", "
)}`,
});
return z.NEVER;
}
const keyValue = entry.slice(0, colonIdx);
const eqIdx = keyValue.indexOf("=");
const key = eqIdx === -1 ? keyValue : keyValue.slice(0, eqIdx);
if (!key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration format (empty key): "${entry}"`,
});
return z.NEVER;
}
if (eqIdx === -1) {
return { key, operator: "Exists" as const, effect };
}
return {
key,
operator: "Equal" as const,
value: keyValue.slice(eqIdx + 1),
effect,
};
});
return tolerations;
})
.optional(),
// Per-org placement overrides, JSON keyed by the internal org id
// (the `org` label on run pods):
// {"<orgId>": {"nodeSelector": {"<key>": "<value>"}, "tolerations": "<csv or array>"}}
KUBERNETES_ORG_PLACEMENT_OVERRIDES: OrgPlacementOverrides,
// Placement tags settings
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
@@ -361,6 +329,22 @@ export const Env = z
path: ["TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE"],
});
}
if (data.KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED && data.KUBERNETES_ORG_PLACEMENT_OVERRIDES) {
// Non-large presets carry a hard NotIn on the large-machine pool, so an org
// pinned to that pool could never schedule its non-large runs.
for (const [orgId, override] of Object.entries(data.KUBERNETES_ORG_PLACEMENT_OVERRIDES)) {
const pinnedPool =
override.nodeSelector?.[data.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY];
if (pinnedPool === data.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Org "${orgId}" pins run pods to the large-machine pool, but non-large presets are required to stay off it, so those runs would never schedule. Use a different pool or disable KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED.`,
path: ["KUBERNETES_ORG_PLACEMENT_OVERRIDES"],
});
}
}
}
if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_METADATA_URL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -383,6 +367,14 @@ export const Env = z
path: ["WORKLOAD_TOKEN_SECRET"],
});
}
if (data.DELETE_CHECKPOINTS_ON_COMPLETION && data.WORKLOAD_TOKEN_ENFORCEMENT === "disabled") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"DELETE_CHECKPOINTS_ON_COMPLETION needs WORKLOAD_TOKEN_ENFORCEMENT set to log or enforce: the tenancy it deletes by comes from the deployment token, so with tokens disabled it would silently reclaim nothing",
path: ["DELETE_CHECKPOINTS_ON_COMPLETION"],
});
}
if (
data.TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED &&
!data.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST
+254 -1
View File
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { BoolEnv, AdditionalEnvVars } from "./envUtil.js";
import {
BoolEnv,
AdditionalEnvVars,
NodeLabelValue,
OrgPlacementOverrides,
Tolerations,
} from "./envUtil.js";
describe("BoolEnv", () => {
it("should parse string 'true' as true", () => {
@@ -78,3 +84,250 @@ describe("AdditionalEnvVars", () => {
});
});
});
describe("NodeLabelValue", () => {
it("should keep a clean value untouched", () => {
expect(NodeLabelValue.parse("v4-worker")).toBe("v4-worker");
});
it("should trim surrounding whitespace, which Kubernetes would reject", () => {
expect(NodeLabelValue.parse(" v4-worker ")).toBe("v4-worker");
expect(NodeLabelValue.parse("\tv4-worker\n")).toBe("v4-worker");
});
it("should treat a whitespace-only value as the empty off-switch", () => {
expect(NodeLabelValue.parse("")).toBe("");
expect(NodeLabelValue.parse(" ")).toBe("");
});
it("should still apply a default only when unset", () => {
const withDefault = NodeLabelValue.default("v4-worker");
expect(withDefault.parse(undefined)).toBe("v4-worker");
expect(withDefault.parse("")).toBe("");
});
it("should reject a value Kubernetes would reject, rather than 422 every pod create", () => {
for (const invalid of ["my worker", "-bad-", "bad.", "a".repeat(64)]) {
expect(NodeLabelValue.safeParse(invalid).success).toBe(false);
}
});
});
describe("Tolerations", () => {
it("should parse key=value entries as Equal", () => {
expect(Tolerations.parse("dedicated=runs:NoSchedule")).toEqual([
{ key: "dedicated", operator: "Equal", value: "runs", effect: "NoSchedule" },
]);
});
it("should parse entries without a value as Exists", () => {
expect(Tolerations.parse("scheduled-runs:NoExecute")).toEqual([
{ key: "scheduled-runs", operator: "Exists", effect: "NoExecute" },
]);
});
it("should keep an empty value as an exact match for a valueless taint", () => {
expect(Tolerations.parse("dedicated=:NoSchedule")).toEqual([
{ key: "dedicated", operator: "Equal", value: "", effect: "NoSchedule" },
]);
expect(Tolerations.parse("dedicated:NoSchedule")).toEqual([
{ key: "dedicated", operator: "Exists", effect: "NoSchedule" },
]);
});
it("should parse an empty string as no tolerations", () => {
expect(Tolerations.parse("")).toEqual([]);
expect(Tolerations.parse(" ")).toEqual([]);
});
it("should skip blank entries and trim whitespace", () => {
expect(Tolerations.parse(" a=b:NoSchedule , ,")).toEqual([
{ key: "a", operator: "Equal", value: "b", effect: "NoSchedule" },
]);
});
it("should reject a missing effect, an unknown effect, and an empty key", () => {
for (const invalid of ["dedicated=runs", "dedicated=runs:Nope", "=runs:NoSchedule"]) {
expect(Tolerations.safeParse(invalid).success).toBe(false);
}
});
it("should accept a hyphenated key, a digit-suffixed key, and every effect", () => {
expect(
Tolerations.parse("capacity-1=true:PreferNoSchedule,spot:NoExecute,gpu=a10:NoSchedule")
).toEqual([
{ key: "capacity-1", operator: "Equal", value: "true", effect: "PreferNoSchedule" },
{ key: "spot", operator: "Exists", effect: "NoExecute" },
{ key: "gpu", operator: "Equal", value: "a10", effect: "NoSchedule" },
]);
});
it("should accept a DNS-subdomain prefixed key", () => {
expect(
Tolerations.parse("node.cluster.x-k8s.io/machinepool=scheduled-runs:NoSchedule")
).toEqual([
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "Equal",
value: "scheduled-runs",
effect: "NoSchedule",
},
]);
});
it("should reject a key or value that Kubernetes would reject at pod create", () => {
for (const invalid of [
"dedicated=prod runs:NoSchedule",
"ded icated=runs:NoSchedule",
"dedicated=-runs:NoSchedule",
`dedicated=${"r".repeat(64)}:NoSchedule`,
`${"a".repeat(64)}=runs:NoSchedule`,
`example.com/${"a".repeat(64)}=runs:NoSchedule`,
"a/b/c=runs:NoSchedule",
"Example.com/pool=runs:NoSchedule",
]) {
expect(Tolerations.safeParse(invalid).success).toBe(false);
}
});
it("should bound the prefix and the name separately, as Kubernetes does", () => {
const longestPrefix = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
expect(longestPrefix.length).toBe(253);
expect(Tolerations.parse(`${longestPrefix}/${"n".repeat(63)}=runs:NoSchedule`)).toHaveLength(1);
expect(Tolerations.safeParse(`${longestPrefix}a/pool=runs:NoSchedule`).success).toBe(false);
});
it("should tolerate whitespace around the separators", () => {
expect(Tolerations.parse("dedicated = runs : NoSchedule")).toEqual([
{ key: "dedicated", operator: "Equal", value: "runs", effect: "NoSchedule" },
]);
});
it("should reject a stray extra effect instead of folding it into the value", () => {
expect(Tolerations.safeParse("dedicated=runs:NoSchedule:NoExecute").success).toBe(false);
});
});
describe("OrgPlacementOverrides", () => {
it("should parse a full override with nodeSelector and tolerations", () => {
expect(
OrgPlacementOverrides.parse(
JSON.stringify({
org_123: {
nodeSelector: { "node.cluster.x-k8s.io/machinepool": "dedicated-pool" },
tolerations: "dedicated=pool:NoSchedule",
},
})
)
).toEqual({
org_123: {
nodeSelector: { "node.cluster.x-k8s.io/machinepool": "dedicated-pool" },
tolerations: [{ key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" }],
},
});
});
it("should allow either half to be omitted", () => {
expect(
OrgPlacementOverrides.parse(JSON.stringify({ org_123: { nodeSelector: { pool: "a" } } }))
).toEqual({ org_123: { nodeSelector: { pool: "a" } } });
expect(
OrgPlacementOverrides.parse(JSON.stringify({ org_123: { tolerations: "spot:NoExecute" } }))
).toEqual({
org_123: { tolerations: [{ key: "spot", operator: "Exists", effect: "NoExecute" }] },
});
expect(OrgPlacementOverrides.parse(JSON.stringify({ org_123: {} }))).toEqual({ org_123: {} });
});
it("should reject invalid JSON at startup rather than silently skipping the override", () => {
for (const invalid of ["not json", "[]", '"org_123"', "{"]) {
expect(OrgPlacementOverrides.safeParse(invalid).success).toBe(false);
}
});
it("should treat a blank or missing value as no overrides, like the sibling settings", () => {
expect(OrgPlacementOverrides.parse(undefined)).toBeUndefined();
expect(OrgPlacementOverrides.parse("")).toBeUndefined();
expect(OrgPlacementOverrides.parse(" ")).toBeUndefined();
});
it("should accept tolerations as an array of entries, matching the Helm list shape", () => {
expect(
OrgPlacementOverrides.parse(
JSON.stringify({
org_123: { tolerations: ["dedicated=pool:NoSchedule", "spot:NoExecute"] },
})
)
).toEqual({
org_123: {
tolerations: [
{ key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" },
{ key: "spot", operator: "Exists", effect: "NoExecute" },
],
},
});
});
it("should coerce scalar node selector values to strings, as Kubernetes labels are", () => {
expect(
OrgPlacementOverrides.parse(
JSON.stringify({ org_123: { nodeSelector: { paid: true, replicas: 3 } } })
)
).toEqual({ org_123: { nodeSelector: { paid: "true", replicas: "3" } } });
});
it("should trim whitespace around node selector keys and values", () => {
expect(
OrgPlacementOverrides.parse(
JSON.stringify({ org_123: { nodeSelector: { " pool ": " a " } } })
)
).toEqual({ org_123: { nodeSelector: { pool: "a" } } });
});
it("should reject blank or padded org keys, since the lookup is exact", () => {
for (const key of [" ", " org_123", "org_123 "]) {
expect(OrgPlacementOverrides.safeParse(JSON.stringify({ [key]: {} })).success).toBe(false);
}
});
it("should reject an empty node selector value instead of pinning the org to nothing", () => {
for (const value of ["", " "]) {
expect(
OrgPlacementOverrides.safeParse(
JSON.stringify({ org_123: { nodeSelector: { pool: value } } })
).success
).toBe(false);
}
});
it("should reject an unknown field, so a typo cannot silently drop an override", () => {
expect(
OrgPlacementOverrides.safeParse(
JSON.stringify({ org_123: { toleration: "dedicated=pool:NoSchedule" } })
).success
).toBe(false);
});
it("should reject a node selector key or value Kubernetes would reject", () => {
for (const invalid of [
{ org_123: { nodeSelector: { "bad key": "a" } } },
{ org_123: { nodeSelector: { pool: "bad value" } } },
{ org_123: { nodeSelector: { "a/b/c": "a" } } },
{ org_123: { nodeSelector: { pool: "v".repeat(64) } } },
]) {
expect(OrgPlacementOverrides.safeParse(JSON.stringify(invalid)).success).toBe(false);
}
});
it("should reject an invalid toleration inside an override", () => {
expect(
OrgPlacementOverrides.safeParse(
JSON.stringify({ org_123: { tolerations: "dedicated=pool:Nope" } })
).success
).toBe(false);
});
});
+226
View File
@@ -16,6 +16,232 @@ export const BoolEnv = baseBoolEnv as Omit<typeof baseBoolEnv, "default"> & {
default: (value: boolean) => z.ZodDefault<typeof baseBoolEnv>;
};
const QUALIFIED_NAME = /^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$/;
const DNS_SUBDOMAIN = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
const LABEL_VALUE = /^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$/;
const QUALIFIED_NAME_MAX = 63;
const DNS_SUBDOMAIN_MAX = 253;
const LABEL_VALUE_MAX = 63;
/**
* isLabelValue mirrors the Kubernetes label value rules. Empty is valid upstream.
*/
function isLabelValue(value: string): boolean {
return value.length <= LABEL_VALUE_MAX && LABEL_VALUE.test(value);
}
/**
* isQualifiedName mirrors the Kubernetes qualified name rules used for taint and
* label keys: an optional DNS subdomain prefix before the slash, then the name.
* The two halves have different length limits and different case rules, so a
* single pattern with one overall bound gets both ends wrong.
*/
function isQualifiedName(key: string): boolean {
const slashIdx = key.indexOf("/");
if (slashIdx === -1) {
return key.length <= QUALIFIED_NAME_MAX && QUALIFIED_NAME.test(key);
}
const prefix = key.slice(0, slashIdx);
const name = key.slice(slashIdx + 1);
return (
prefix.length <= DNS_SUBDOMAIN_MAX &&
DNS_SUBDOMAIN.test(prefix) &&
name.length <= QUALIFIED_NAME_MAX &&
QUALIFIED_NAME.test(name)
);
}
/**
* A node label value. Trimmed because Kubernetes rejects surrounding whitespace
* outright, so a padded value fails every pod create. Deliberately no `min(1)`:
* empty is the off-switch, and the Helm chart ships empty by default.
*/
export const NodeLabelValue = z.string().trim().refine(isLabelValue, {
message:
"Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside, at most 63 characters",
});
/**
* Comma-separated pod tolerations in the format `key=value:effect`, or `key:effect`
* for the Exists operator. Keys and values are checked against the Kubernetes
* naming rules here so a typo fails at startup, rather than 422ing every single
* pod create with the cause buried in an API server message.
*/
export const Tolerations = z.string().transform((val, ctx) => {
return val
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => {
const colonIdx = entry.lastIndexOf(":");
if (colonIdx === -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration format (missing effect): "${entry}"`,
});
return z.NEVER;
}
const effect = entry.slice(colonIdx + 1).trim();
const validEffects = ["NoSchedule", "NoExecute", "PreferNoSchedule"];
if (!validEffects.includes(effect)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration effect "${effect}" in "${entry}". Must be one of: ${validEffects.join(
", "
)}`,
});
return z.NEVER;
}
const keyValue = entry.slice(0, colonIdx);
const eqIdx = keyValue.indexOf("=");
const key = (eqIdx === -1 ? keyValue : keyValue.slice(0, eqIdx)).trim();
if (!key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration format (empty key): "${entry}"`,
});
return z.NEVER;
}
if (!isQualifiedName(key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration key "${key}" in "${entry}". Must be a Kubernetes taint key, optionally prefixed with a DNS subdomain.`,
});
return z.NEVER;
}
if (eqIdx === -1) {
return { key, operator: "Exists" as const, effect };
}
const value = keyValue.slice(eqIdx + 1).trim();
if (!value) {
logger.warn(
'Toleration has an empty value, so it matches only a taint whose value is also empty. Drop the "=" to tolerate any value of this key.',
{ entry, key }
);
}
if (!isLabelValue(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid toleration value "${value}" in "${entry}". Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside.`,
});
return z.NEVER;
}
return {
key,
operator: "Equal" as const,
value,
effect,
};
});
});
/**
* Scalar values are coerced: YAML/JSON easily produce `true` or `3` where a label
* value is meant, and Kubernetes label values are always strings. An empty value
* is rejected rather than passed through - as a selector it matches only nodes
* carrying a literal empty-valued label, which pins the org to nothing.
*/
const NodeSelector = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.transform((selector, ctx) => {
const result: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(selector)) {
const key = rawKey.trim();
const value = String(rawValue).trim();
if (!isQualifiedName(key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid node selector key "${rawKey}". Must be a Kubernetes label key, optionally prefixed with a DNS subdomain.`,
});
continue;
}
if (!value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Empty node selector value for key "${key}". Remove the key instead of blanking the value.`,
});
continue;
}
if (!isLabelValue(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid node selector value "${value}" for key "${key}". Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside, at most 63 characters.`,
});
continue;
}
result[key] = value;
}
return result;
});
/**
* Per-organization placement overrides for run pods, as JSON keyed by the
* internal org id (the `org` label on run pods):
* `{"<orgId>": {"nodeSelector": {"<key>": "<value>"}, "tolerations": "<csv>"}}`.
* Tolerations use the same CSV format as `Tolerations`, or an array of such
* entries. Everything is validated at startup for the same reason as
* tolerations above: a typo would otherwise reject every pod create for that
* org, with the cause buried in API errors. A blank value means no overrides.
*/
export const OrgPlacementOverrides = z
.string()
.optional()
.transform((val, ctx) => {
if (val === undefined || val.trim() === "") {
return undefined;
}
try {
return JSON.parse(val) as unknown;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid org placement overrides: not valid JSON",
});
return z.NEVER;
}
})
.pipe(
z
.record(
z
.string()
.min(1)
.refine((key) => key === key.trim() && key.trim().length > 0, {
message:
"Org override keys must not be blank or padded with whitespace; the lookup is exact",
}),
z
.object({
nodeSelector: NodeSelector.optional(),
tolerations: z
.union([z.string(), z.array(z.string())])
.transform((val) => (Array.isArray(val) ? val.join(",") : val))
.pipe(Tolerations)
.optional(),
})
.strict()
)
.optional()
);
export const AdditionalEnvVars = z.preprocess((val) => {
if (typeof val !== "string") {
return val;
+5 -3
View File
@@ -21,7 +21,7 @@ import {
CheckpointClient,
isKubernetesEnvironment,
} from "@trigger.dev/core/v3/serverOnly";
import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js";
import { createK8sApi, createPodCountFetcher } from "./clients/kubernetes.js";
import { collectDefaultMetrics, Counter, Gauge, Histogram } from "prom-client";
import { register } from "./metrics.js";
import { PodCleaner } from "./services/podCleaner.js";
@@ -276,14 +276,16 @@ class ManagedSupervisor {
// RELEASE < ENGAGE is enforced in env.ts (superRefine), so it's valid here.
const podCountGauge = new Gauge({
name: "supervisor_cluster_pod_count",
help: "Total pod objects stored in the cluster, scraped for backpressure",
help: "Pod objects in the workload namespace, counted for backpressure",
registers: [register],
});
this.backpressureMonitors.push(
new BackpressureMonitor({
enabled: true,
source: new K8sPodCountSignalSource({
fetchMetrics: createApiserverMetricsFetcher(
fetchPodCount: createPodCountFetcher(
createK8sApi(),
env.KUBERNETES_NAMESPACE,
env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_SCRAPE_TIMEOUT_MS
),
engageThreshold: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE,
+3 -7
View File
@@ -7,18 +7,14 @@
* Off by default behind a kill switch - the dispatch hotpath runs at high
* QPS, so logging pressure must be cleanly removable.
*/
export { type Env, isValidRequestId, newState, type NewStateOptions } from "./new.js";
export { emit, EmitMessage } from "./emit.js";
export { parseTraceId } from "./traceparent.js";
export { fromContext, wideEventStorage } from "./context.js";
export { type PhaseOpt, recordPhase, recordPhaseSince, timePhase } from "./record.js";
export { fromContext } from "./context.js";
export { recordPhaseSince } from "./record.js";
export {
emitOneShot,
runWideEvent,
setExtra,
setMeta,
type WideEventLifecycleOptions,
type WideEventOptions,
} from "./middleware.js";
export type { ErrorInfo, PhaseRecord, State } from "./state.js";
export type { State } from "./state.js";
export { encodeBaggage } from "./baggage.js";
+1 -1
View File
@@ -76,7 +76,7 @@ export type PhaseRecord = {
};
/** Top-level error summary for a failed operation. */
export type ErrorInfo = {
type ErrorInfo = {
code: string;
message: string;
/** Coarse classification - "client" | "upstream" | "internal" | "timeout". */
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { rewriteImageRegistry } from "./imageRegistry.js";
const FROM = "123456789012.dkr.ecr.us-east-1.amazonaws.com";
const TO = "123456789012.dkr.ecr.eu-central-1.amazonaws.com";
describe("rewriteImageRegistry", () => {
it("rewrites the registry host and keeps the rest of the reference", () => {
expect(rewriteImageRegistry(`${FROM}/deployments/proj_abc:20260818.1`, FROM, TO)).toBe(
`${TO}/deployments/proj_abc:20260818.1`
);
});
it("preserves a digest", () => {
expect(rewriteImageRegistry(`${FROM}/deployments/proj_abc@sha256:abc123`, FROM, TO)).toBe(
`${TO}/deployments/proj_abc@sha256:abc123`
);
});
it("is a no-op unless both ends are configured", () => {
const ref = `${FROM}/deployments/proj_abc:tag`;
expect(rewriteImageRegistry(ref, undefined, TO)).toBe(ref);
expect(rewriteImageRegistry(ref, FROM, undefined)).toBe(ref);
expect(rewriteImageRegistry(ref, undefined, undefined)).toBe(ref);
});
it("leaves other registries alone", () => {
const ref = "ghcr.io/triggerdotdev/something:tag";
expect(rewriteImageRegistry(ref, FROM, TO)).toBe(ref);
});
it("only matches on a host boundary", () => {
const lookalike = `${FROM}.evil.example.com/deployments/proj_abc:tag`;
expect(rewriteImageRegistry(lookalike, FROM, TO)).toBe(lookalike);
});
it("does not rewrite a host that merely contains the source", () => {
const ref = `registry.example.com/${FROM}/proj_abc:tag`;
expect(rewriteImageRegistry(ref, FROM, TO)).toBe(ref);
});
});
@@ -0,0 +1,15 @@
export function rewriteImageRegistry(
imageRef: string,
from: string | undefined,
to: string | undefined
): string {
if (!from || !to) {
return imageRef;
}
if (!imageRef.startsWith(`${from}/`)) {
return imageRef;
}
return `${to}${imageRef.slice(from.length)}`;
}
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
BLOCK_IO_URING_SECCOMP_PROFILE,
withBlockIoUringSeccompProfile,
nodetypeNodeSelector,
runPodTolerations,
runnerSecurityContext,
withRunnerSeccompProfile,
withNodeSelector,
} from "./kubernetesPodSpec.js";
const basePodSpec = {
@@ -14,27 +17,172 @@ const basePodSpec = {
},
};
describe("withBlockIoUringSeccompProfile", () => {
it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => {
for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) {
const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime);
expect(podSpec).toMatchObject({
...basePodSpec,
securityContext: {
...basePodSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
});
describe("nodetypeNodeSelector", () => {
it("omits the nodeSelector entirely when the label is empty or unset", () => {
for (const label of ["", undefined]) {
expect(nodetypeNodeSelector(label)).toEqual({});
}
});
it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => {
it("pins to nodetype=<label> when set", () => {
expect(nodetypeNodeSelector("v4-worker")).toEqual({ nodeSelector: { nodetype: "v4-worker" } });
});
});
describe("runPodTolerations", () => {
const worker = [{ key: "dedicated", operator: "Equal", value: "runs", effect: "NoSchedule" }];
const scheduled = [{ key: "scheduled-runs", operator: "Exists", effect: "NoSchedule" }];
it("leaves tolerations unset when neither is configured", () => {
expect(runPodTolerations(undefined, undefined, false)).toBeUndefined();
expect(runPodTolerations(undefined, undefined, true)).toBeUndefined();
expect(runPodTolerations([], [], true)).toBeUndefined();
});
it("applies the worker tolerations to every run", () => {
expect(runPodTolerations(worker, undefined, false)).toEqual(worker);
expect(runPodTolerations(worker, undefined, true)).toEqual(worker);
});
it("applies the scheduled-run tolerations on their own, as before this option existed", () => {
expect(runPodTolerations(undefined, scheduled, true)).toEqual(scheduled);
expect(runPodTolerations(undefined, scheduled, false)).toBeUndefined();
expect(runPodTolerations([], scheduled, true)).toEqual(scheduled);
});
it("adds the scheduled-run tolerations only for scheduled runs", () => {
expect(runPodTolerations(worker, scheduled, false)).toEqual(worker);
expect(runPodTolerations(worker, [], true)).toEqual(worker);
expect(runPodTolerations(worker, scheduled, true)).toEqual([...worker, ...scheduled]);
});
it("appends the org tolerations regardless of run type", () => {
const org = [{ key: "dedicated", operator: "Equal", value: "org-pool", effect: "NoSchedule" }];
expect(runPodTolerations(undefined, undefined, false, org)).toEqual(org);
expect(runPodTolerations(worker, undefined, false, org)).toEqual([...worker, ...org]);
expect(runPodTolerations(worker, scheduled, true, org)).toEqual([
...worker,
...scheduled,
...org,
]);
expect(runPodTolerations(undefined, undefined, false, [])).toBeUndefined();
});
});
describe("withNodeSelector", () => {
const podSpec = { ...basePodSpec, nodeSelector: { nodetype: "v4-worker", paid: "true" } };
it("returns the pod spec untouched when there is nothing to merge", () => {
expect(withNodeSelector(podSpec, undefined)).toBe(podSpec);
expect(withNodeSelector(podSpec, {})).toBe(podSpec);
});
it("merges extra entries with existing ones", () => {
expect(withNodeSelector(podSpec, { machinepool: "dedicated-pool" })).toEqual({
...podSpec,
nodeSelector: { nodetype: "v4-worker", paid: "true", machinepool: "dedicated-pool" },
});
});
it("lets the extra entries win on key collision", () => {
expect(withNodeSelector(podSpec, { nodetype: "other" }).nodeSelector).toEqual({
nodetype: "other",
paid: "true",
});
});
it("adds a nodeSelector to a spec that had none", () => {
expect(withNodeSelector(basePodSpec, { machinepool: "dedicated-pool" })).toEqual({
...basePodSpec,
nodeSelector: { machinepool: "dedicated-pool" },
});
});
});
describe("withRunnerSeccompProfile", () => {
const base = {
profilePath: "profiles/example.json",
runtimes: "node-24-plus" as const,
runtime: "node-24",
checkpointsEnabled: true,
};
const withProfile = {
...basePodSpec,
securityContext: {
...basePodSpec.securityContext,
seccompProfile: { type: "Localhost", localhostProfile: "profiles/example.json" },
},
};
it("applies the profile to node-24 and above under the default scope", () => {
for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) {
expect(withRunnerSeccompProfile(basePodSpec, { ...base, runtime })).toMatchObject(
withProfile
);
}
});
it("skips older runtimes under the default scope", () => {
for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) {
expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec);
expect(withRunnerSeccompProfile(basePodSpec, { ...base, runtime })).toBe(basePodSpec);
}
});
it("applies the profile to every runtime under the all scope", () => {
for (const runtime of ["node", "node-22", "bun", "node-24", undefined]) {
expect(
withRunnerSeccompProfile(basePodSpec, { ...base, runtimes: "all", runtime })
).toMatchObject(withProfile);
}
});
it("applies nothing under the none scope, whatever the runtime", () => {
for (const runtime of ["node-24", "bun", "node-22"]) {
expect(withRunnerSeccompProfile(basePodSpec, { ...base, runtimes: "none", runtime })).toBe(
basePodSpec
);
}
});
it("applies nothing when checkpoints are disabled", () => {
for (const runtimes of ["none", "node-24-plus", "all"] as const) {
expect(
withRunnerSeccompProfile(basePodSpec, { ...base, runtimes, checkpointsEnabled: false })
).toBe(basePodSpec);
}
});
});
describe("runnerSecurityContext", () => {
it("sets nothing when off", () => {
expect(runnerSecurityContext("off", 1000, "node-24")).toBeUndefined();
});
it("drops all capabilities and blocks escalation at baseline", () => {
expect(runnerSecurityContext("baseline", 1000, "node-24")).toEqual({
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
});
});
it("pins the configured uid when restricted", () => {
expect(runnerSecurityContext("restricted", 1000, "node-24")).toEqual({
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
runAsNonRoot: true,
runAsUser: 1000,
});
});
it("pins bun's own uid, which differs from node's", () => {
expect(runnerSecurityContext("restricted", 1000, "bun")?.runAsUser).toBe(1001);
});
it("falls back to the configured uid when the runtime is unknown", () => {
for (const runtime of [undefined, null, "", "node", "node-22", "node-26"]) {
expect(runnerSecurityContext("restricted", 1000, runtime)?.runAsUser).toBe(1000);
}
});
});
@@ -14,7 +14,14 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
import { env } from "../env.js";
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
import { getRunnerId } from "../util.js";
import { withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js";
import {
nodetypeNodeSelector,
runPodTolerations,
runnerSecurityContext,
withRunnerSeccompProfile,
withNodeSelector,
} from "./kubernetesPodSpec.js";
import { rewriteImageRegistry } from "./imageRegistry.js";
type ResourceQuantities = {
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
@@ -65,6 +72,12 @@ export class KubernetesWorkloadManager implements WorkloadManager {
domain: opts.workloadApiDomain,
});
}
if (env.KUBERNETES_ORG_PLACEMENT_OVERRIDES) {
this.logger.info("[KubernetesWorkloadManager] Org placement overrides enabled", {
orgIds: Object.keys(env.KUBERNETES_ORG_PLACEMENT_OVERRIDES),
});
}
}
private addPlacementTags(
@@ -106,10 +119,30 @@ export class KubernetesWorkloadManager implements WorkloadManager {
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
try {
const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
const podSpec = this.opts.checkpointsEnabled
? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime)
: basePodSpec;
const orgOverride = env.KUBERNETES_ORG_PLACEMENT_OVERRIDES?.[opts.orgId];
const taggedPodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
const basePodSpec = withNodeSelector(taggedPodSpec, orgOverride?.nodeSelector);
if (orgOverride?.nodeSelector) {
const replacedKeys = Object.keys(orgOverride.nodeSelector).filter(
(key) =>
taggedPodSpec.nodeSelector?.[key] !== undefined &&
taggedPodSpec.nodeSelector[key] !== orgOverride.nodeSelector?.[key]
);
if (replacedKeys.length > 0) {
this.logger.warn(
"[KubernetesWorkloadManager] Org placement override replaces node selector keys",
{ orgId: opts.orgId, replacedKeys }
);
}
}
const podSpec = withRunnerSeccompProfile(basePodSpec, {
profilePath: env.KUBERNETES_RUNNER_SECCOMP_PROFILE_PATH,
runtimes: env.KUBERNETES_RUNNER_SECCOMP_PROFILE_RUNTIMES,
runtime: opts.runtime,
checkpointsEnabled: this.opts.checkpointsEnabled,
});
await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
@@ -127,18 +160,27 @@ export class KubernetesWorkloadManager implements WorkloadManager {
spec: {
...podSpec,
affinity: this.#getAffinity(opts),
tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)),
tolerations: this.#getTolerations(this.#isScheduledRun(opts), orgOverride?.tolerations),
terminationGracePeriodSeconds: 60 * 60,
containers: [
{
name: "run-controller",
image: this.stripImageDigest(opts.image),
image: rewriteImageRegistry(
this.stripImageDigest(opts.image),
env.KUBERNETES_IMAGE_REGISTRY_REWRITE_FROM,
env.KUBERNETES_IMAGE_REGISTRY_REWRITE_TO
),
ports: [
{
containerPort: 8000,
},
],
resources: this.#getResourcesForMachine(opts.machine),
securityContext: runnerSecurityContext(
env.KUBERNETES_RUNNER_SECURITY_CONTEXT,
env.KUBERNETES_RUNNER_RUN_AS_USER,
opts.runtime
),
env: [
{
name: "TRIGGER_DEQUEUED_AT_MS",
@@ -329,13 +371,12 @@ export class KubernetesWorkloadManager implements WorkloadManager {
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
}
: {}),
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
...(env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME
? {
nodeSelector: {
nodetype: env.KUBERNETES_WORKER_NODETYPE_LABEL,
},
priorityClassName: env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME,
}
: {}),
...nodetypeNodeSelector(env.KUBERNETES_WORKER_NODETYPE_LABEL),
...(env.KUBERNETES_POD_DNS_NDOTS_OVERRIDE_ENABLED
? {
dnsConfig: {
@@ -557,12 +598,16 @@ export class KubernetesWorkloadManager implements WorkloadManager {
};
}
#getScheduleTolerations(isScheduledRun: boolean): k8s.V1Toleration[] | undefined {
if (!isScheduledRun || !env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS?.length) {
return undefined;
}
return env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS;
#getTolerations(
isScheduledRun: boolean,
orgTolerations?: k8s.V1Toleration[]
): k8s.V1Toleration[] | undefined {
return runPodTolerations(
env.KUBERNETES_RUNNER_TOLERATIONS,
env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS,
isScheduledRun,
orgTolerations
);
}
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
@@ -1,33 +1,128 @@
import type { k8s } from "../clients/kubernetes.js";
/**
* Relative path (kubelet seccomp root) of the profile blocking only io_uring
* syscalls. Must match the profile deployed to worker nodes.
* An empty label is the documented off-switch, leaving the pod unpinned. The Helm
* chart ships an empty value, so don't collapse this into a fallback default -
* that would pin every chart install to a label its nodes don't carry.
*/
export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json";
export function nodetypeNodeSelector(
label: string | undefined
): Pick<k8s.V1PodSpec, "nodeSelector"> {
return label ? { nodeSelector: { nodetype: label } } : {};
}
/**
* Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking
* io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this,
* so the profile is only applied for node-24+. Tolerates an "experimental-" prefix.
* Tolerations for a run pod: the cluster-wide set, plus the scheduled-run set when the
* run came from a schedule tree, plus the org's own set when a placement override
* matches. Not reconciled - Kubernetes matches tolerations as an any-match set, so a
* broad entry in one set can subsume a narrower one in another.
* Returns undefined rather than an empty array to leave the field unset.
*/
export function withBlockIoUringSeccompProfile(
export function runPodTolerations(
runnerTolerations: k8s.V1Toleration[] | undefined,
scheduledRunTolerations: k8s.V1Toleration[] | undefined,
isScheduledRun: boolean,
orgTolerations?: k8s.V1Toleration[]
): k8s.V1Toleration[] | undefined {
const tolerations = [
...(runnerTolerations ?? []),
...(isScheduledRun ? (scheduledRunTolerations ?? []) : []),
...(orgTolerations ?? []),
];
return tolerations.length > 0 ? tolerations : undefined;
}
/**
* Merges extra node selector entries into a pod spec. Later entries win on key
* collision, so an override can retarget a key set by an earlier stage.
*/
export function withNodeSelector(
podSpec: Omit<k8s.V1PodSpec, "containers">,
runtime: string | null | undefined
nodeSelector: Record<string, string> | undefined
): Omit<k8s.V1PodSpec, "containers"> {
const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null;
if (!match || Number(match[1]) < 24) {
if (!nodeSelector || Object.keys(nodeSelector).length === 0) {
return podSpec;
}
return {
...podSpec,
nodeSelector: {
...podSpec.nodeSelector,
...nodeSelector,
},
};
}
export type RunnerSeccompProfileOptions = {
profilePath: string;
runtimes: "none" | "node-24-plus" | "all";
runtime: string | null | undefined;
checkpointsEnabled: boolean | undefined;
};
/**
* Applies the runner seccomp profile, which is a node-local file installed outside
* this repo - pointing a pod at a profile its node doesn't have fails pod creation,
* so every condition for skipping it lives here.
*
* "node-24-plus" matches the original rollout: node >= 24 always creates io_uring
* fds, which can't be checkpointed, and blocking io_uring_setup makes libuv fall
* back to epoll. Tolerates an "experimental-" prefix. "bun" matches only under "all".
*/
export function withRunnerSeccompProfile(
podSpec: Omit<k8s.V1PodSpec, "containers">,
options: RunnerSeccompProfileOptions
): Omit<k8s.V1PodSpec, "containers"> {
if (!options.checkpointsEnabled || options.runtimes === "none") {
return podSpec;
}
if (options.runtimes === "node-24-plus") {
const match = options.runtime ? /^(?:experimental-)?node-(\d+)$/.exec(options.runtime) : null;
if (!match || Number(match[1]) < 24) {
return podSpec;
}
}
return {
...podSpec,
securityContext: {
...podSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
localhostProfile: options.profilePath,
},
},
};
}
const BUN_RUN_AS_USER = 1001;
/**
* runnerSecurityContext maps a configured level onto the run container's security
* context. "baseline" drops the capability bounding set and blocks setuid
* escalation; "restricted" additionally pins the container to a non-root uid.
*
* The uid is set explicitly rather than read from the image: the kubelet cannot
* verify `runAsNonRoot` against an image that declares a named user, and fails
* the container instead. Bun images carry their user at a different uid to
* node's, so the runtime selects which uid is pinned.
*/
export function runnerSecurityContext(
level: "off" | "baseline" | "restricted",
runAsUser: number,
runtime: string | null | undefined
): k8s.V1SecurityContext | undefined {
if (level === "off") {
return undefined;
}
return {
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
...(level === "restricted"
? { runAsNonRoot: true, runAsUser: runtime === "bun" ? BUN_RUN_AS_USER : runAsUser }
: {}),
};
}
+120 -1
View File
@@ -23,6 +23,8 @@ import EventEmitter from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { type Namespace, Server, type Socket } from "socket.io";
import { z } from "zod";
import { tryCatch } from "@trigger.dev/core/utils";
import { Counter } from "prom-client";
import { env } from "../env.js";
import { register } from "../metrics.js";
import {
@@ -30,6 +32,7 @@ import {
workloadTokenEnforced,
workloadTokensEnabled,
} from "../workloadToken.js";
import type { WorkloadDeploymentTokenClaims } from "@trigger.dev/core/v3";
import {
ComputeSnapshotService,
type RunTraceContext,
@@ -50,6 +53,20 @@ interface DefaultEventsMap {
[event: string]: (...args: any[]) => void;
}
const checkpointDeleteRequests = new Counter({
name: "checkpoint_delete_requests_total",
help: "Checkpoint delete requests attempted at run completion, by outcome",
labelNames: ["result"],
registers: [register],
});
const checkpointCancelRequests = new Counter({
name: "checkpoint_cancel_requests_total",
help: "Checkpoint cancel requests attempted when a run continues, by outcome",
labelNames: ["result"],
registers: [register],
});
const WorkloadActionParams = z.object({
runFriendlyId: z.string(),
snapshotFriendlyId: z.string(),
@@ -181,10 +198,15 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
* environment_id to forward upstream. The env id is only forwarded in enforce mode: in log mode
* we still verify + record metrics but attach no header (so the platform never scopes). Only
* enforce fails a request, and only for a present-but-invalid token; absent and legacy ids pass.
*
* `claims` are returned on any valid token, for local use only - never to scope the platform,
* which is why environmentId stays gated on enforce.
*/
private async authorizeWorkloadRequest(
req: IncomingMessage
): Promise<{ ok: true; environmentId?: string } | { ok: false }> {
): Promise<
{ ok: true; environmentId?: string; claims?: WorkloadDeploymentTokenClaims } | { ok: false }
> {
if (!workloadTokensEnabled) {
return { ok: true };
}
@@ -201,9 +223,97 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
workloadTokenEnforced && result.outcome === "jwt_valid"
? result.claims.environment_id
: undefined,
claims: result.outcome === "jwt_valid" ? result.claims : undefined,
};
}
/**
* reclaimCheckpoints asks the checkpoint service to delete a finished run's checkpoint storage.
* Must be called after the reply is sent: it never delays the runner.
*/
private async reclaimCheckpoints(
req: IncomingMessage,
runFriendlyId: string,
attemptStatus: string,
claims: WorkloadDeploymentTokenClaims | undefined
): Promise<void> {
if (!env.DELETE_CHECKPOINTS_ON_COMPLETION) {
checkpointDeleteRequests.inc({ result: "disabled" });
return;
}
if (!this.checkpointClient) {
checkpointDeleteRequests.inc({ result: "no_client" });
return;
}
if (this.snapshotService) {
checkpointDeleteRequests.inc({ result: "not_applicable" });
return;
}
if (attemptStatus !== "RUN_FINISHED" && attemptStatus !== "RUN_PENDING_CANCEL") {
checkpointDeleteRequests.inc({ result: "not_terminal" });
return;
}
if (!claims) {
checkpointDeleteRequests.inc({ result: "no_claims" });
return;
}
const projectRef = this.projectRefFromRequest(req);
if (!projectRef) {
checkpointDeleteRequests.inc({ result: "no_project_ref" });
this.logger.error("Cannot reclaim checkpoints without a project ref", { runFriendlyId });
return;
}
const [error, accepted] = await tryCatch(
this.checkpointClient.deleteCheckpoints({
runFriendlyId,
body: {
orgId: claims.org_id,
envId: claims.environment_id,
deploymentVersion: claims.deployment_version,
projectRef,
},
})
);
if (error || !accepted) {
checkpointDeleteRequests.inc({ result: "http_error" });
this.logger.error("Failed to request checkpoint reclaim", { runFriendlyId, error });
return;
}
checkpointDeleteRequests.inc({ result: "sent" });
}
private async cancelCheckpointsAfterReply(runFriendlyId: string): Promise<void> {
if (!this.checkpointClient) {
checkpointCancelRequests.inc({ result: "no_client" });
return;
}
if (this.snapshotService) {
checkpointCancelRequests.inc({ result: "not_applicable" });
return;
}
const [error, accepted] = await tryCatch(
this.checkpointClient.cancelCheckpoints({ runFriendlyId })
);
if (error || !accepted) {
checkpointCancelRequests.inc({ result: "http_error" });
this.logger.error("Failed to request checkpoint cancel", { runFriendlyId, error });
return;
}
checkpointCancelRequests.inc({ result: "sent" });
}
/**
* Sets common route meta on the wide-event state from URL params.
*/
@@ -364,6 +474,13 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
}
reply.json(completeResponse.data satisfies WorkloadRunAttemptCompleteResponseBody);
await this.reclaimCheckpoints(
req,
params.runFriendlyId,
completeResponse.data.result.attemptStatus,
auth.claims
);
return;
}
),
@@ -557,6 +674,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
}
reply.json(continuationResult.data as WorkloadContinueRunExecutionResponseBody);
await this.cancelCheckpointsAfterReply(params.runFriendlyId);
}
),
}
-1
View File
@@ -28,7 +28,6 @@ const mintCounter = new Counter({
});
export type WorkloadAuthTransport = "http" | "ws";
export type WorkloadAuthOutcome = "jwt_valid" | "jwt_invalid" | "legacy_bare" | "token_absent";
const verifyCounter = new Counter({
name: "workload_auth_verify_total",
+4 -1
View File
@@ -7,6 +7,9 @@ node_modules
/cypress/screenshots
/cypress/videos
# Output of `pnpm run agent-ui:screenshots`
/screenshots
/app/styles/tailwind.css
# Ensure the .env symlink is not removed by accident
@@ -20,4 +23,4 @@ storybook-static
/prisma/seed.js
/prisma/populate.js
.memory-snapshots
.memory-snapshots
@@ -1,6 +1,6 @@
import { useAnimate } from "framer-motion";
import { HourglassIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
export function AnimatedHourglassIcon({
className,
@@ -10,18 +10,21 @@ export function AnimatedHourglassIcon({
delay?: number;
}) {
const [scope, animate] = useAnimate();
const initialDelay = useRef(delay);
useEffect(() => {
animate(
const controls = animate(
[
[scope.current, { rotate: 0 }, { duration: 0.7 }],
[scope.current, { rotate: 180 }, { duration: 0.3 }],
[scope.current, { rotate: 180 }, { duration: 0.7 }],
[scope.current, { rotate: 360 }, { duration: 0.3 }],
],
{ repeat: Infinity, delay }
{ repeat: Infinity, delay: initialDelay.current }
);
}, []);
return () => controls.stop();
}, [animate, scope]);
return <HourglassIcon ref={scope} className={className} />;
}
@@ -0,0 +1,14 @@
export function CrossIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
@@ -0,0 +1,35 @@
export function EyeClosedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.7424 5.08581C14.6841 4.54668 18.7922 6.54985 21.4978 11.0954C21.8296 11.6529 21.8298 12.3468 21.498 12.9043C21.124 13.5326 20.7233 14.1123 20.3 14.6434"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.70711 2.29289C3.31658 1.90237 2.68342 1.90237 2.29289 2.29289C1.90237 2.68342 1.90237 3.31658 2.29289 3.70711L3.70711 2.29289ZM20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L20.2929 21.7071ZM2.29289 3.70711L20.2929 21.7071L21.7071 20.2929L3.70711 2.29289L2.29289 3.70711Z"
fill="currentColor"
/>
<path
d="M10.3327 10.8948C10.6385 10.4349 10.5136 9.81416 10.0537 9.50837C9.59377 9.20259 8.97305 9.32753 8.66727 9.78743L10.3327 10.8948ZM14.2126 15.3328C14.6725 15.027 14.7974 14.4063 14.4916 13.9463C14.1858 13.4864 13.5651 13.3615 13.1052 13.6673L14.2126 15.3328ZM12 14C10.8954 14 10 13.1046 10 12H8C8 14.2092 9.79086 16 12 16V14ZM10 12C10 11.5897 10.1225 11.211 10.3327 10.8948L8.66727 9.78743C8.24565 10.4216 8 11.1836 8 12H10ZM13.1052 13.6673C12.789 13.8775 12.4103 14 12 14V16C12.8164 16 13.5785 15.7544 14.2126 15.3328L13.1052 13.6673Z"
fill="currentColor"
/>
<path
d="M6.12815 7C4.77316 7.99438 3.53535 9.35957 2.50209 11.0955C2.17024 11.6531 2.17115 12.3487 2.50305 12.9062C6.05251 18.8681 12.0149 20.4553 16.8492 17.6681"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,27 @@
export function EyeOpenIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21.4974 11.0946C16.66 2.9684 7.33998 2.96849 2.50257 11.0947C2.17069 11.6523 2.17069 12.3479 2.50257 12.9054C7.33998 21.0316 16.66 21.0315 21.4974 12.9053C21.8293 12.3477 21.8293 11.6521 21.4974 11.0946Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+19 -14
View File
@@ -48,20 +48,25 @@ export function MachineDefaultIcon({ className }: { className?: string }) {
function MachineIconNoMachine({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
className={cn("text-[#D7D9DD] light:text-charcoal-500", className)}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.1875 13C16.6362 13 17 13.3638 17 13.8125V16.1875C17 16.6362 16.6362 17 16.1875 17H6.18945C6.25882 16.9453 6.32663 16.8872 6.39062 16.8232L10.2139 13H16.1875Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<path
d="M4.84961 12H3.8125C3.36377 12 3 11.6362 3 11.1875V8.8125C3 8.36377 3.36377 8 3.8125 8H8.84961L4.84961 12ZM16.1875 8C16.6362 8 17 8.36377 17 8.8125V11.1875C17 11.6362 16.6362 12 16.1875 12H11.2139L15.2139 8H16.1875Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<path
d="M9.85059 7H3.8125C3.36377 7 3 6.63623 3 6.1875V3.8125C3 3.36377 3.36377 3 3.8125 3H13.8506L9.85059 7Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<line
@@ -69,7 +74,7 @@ function MachineIconNoMachine({ className }: { className?: string }) {
y1="16.9998"
x2="17.0018"
y2="2.99805"
stroke="#D7D9DD"
stroke="currentColor"
strokeOpacity="0.4"
strokeWidth="2"
strokeLinecap="round"
@@ -83,17 +88,17 @@ function MachineIconMicro({ className }: { className?: string }) {
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.1875 13C16.6362 13 17 13.3638 17 13.8125V16.1875C17 16.6362 16.6362 17 16.1875 17H3.8125C3.36377 17 3 16.6362 3 16.1875V13.8125C3 13.3638 3.36377 13 3.8125 13H16.1875ZM5 14C4.44772 14 4 14.4477 4 15C4 15.5523 4.44772 16 5 16C5.55228 16 6 15.5523 6 15C6 14.4477 5.55228 14 5 14Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<path
d="M16.1875 8C16.6362 8 17 8.36377 17 8.8125V11.1875C17 11.6362 16.6362 12 16.1875 12H3.8125C3.36377 12 3 11.6362 3 11.1875V8.8125C3 8.36377 3.36377 8 3.8125 8H16.1875ZM5 9C4.44772 9 4 9.44772 4 10C4 10.5523 4.44772 11 5 11C5.55228 11 6 10.5523 6 10C6 9.44772 5.55228 9 5 9Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<path
d="M16.1875 3C16.6362 3 17 3.36377 17 3.8125V6.1875C17 6.63623 16.6362 7 16.1875 7H3.8125C3.36377 7 3 6.63623 3 6.1875V3.8125C3 3.36377 3.36377 3 3.8125 3H16.1875ZM5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
</svg>
@@ -109,12 +114,12 @@ function MachineIconSmall1x({ className }: { className?: string }) {
/>
<path
d="M16.1875 8C16.6362 8 17 8.36377 17 8.8125V11.1875C17 11.6362 16.6362 12 16.1875 12H3.8125C3.36377 12 3 11.6362 3 11.1875V8.8125C3 8.36377 3.36377 8 3.8125 8H16.1875ZM5 9C4.44772 9 4 9.44772 4 10C4 10.5523 4.44772 11 5 11C5.55228 11 6 10.5523 6 10C6 9.44772 5.55228 9 5 9Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<path
d="M16.1875 3C16.6362 3 17 3.36377 17 3.8125V6.1875C17 6.63623 16.6362 7 16.1875 7H3.8125C3.36377 7 3 6.63623 3 6.1875V3.8125C3 3.36377 3.36377 3 3.8125 3H16.1875ZM5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
</svg>
@@ -130,12 +135,12 @@ function MachineIconSmall2x({ className }: { className?: string }) {
/>
<path
d="M16.1875 8C16.6362 8 17 8.36377 17 8.8125V11.1875C17 11.6362 16.6362 12 16.1875 12H3.8125C3.36377 12 3 11.6362 3 11.1875V8.8125C3 8.36377 3.36377 8 3.8125 8H16.1875ZM5 9C4.44772 9 4 9.44772 4 10C4 10.5523 4.44772 11 5 11C5.55228 11 6 10.5523 6 10C6 9.44772 5.55228 9 5 9Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
<path
d="M16.1875 3C16.6362 3 17 3.36377 17 3.8125V6.1875C17 6.63623 16.6362 7 16.1875 7H3.8125C3.36377 7 3 6.63623 3 6.1875V3.8125C3 3.36377 3.36377 3 3.8125 3H16.1875ZM5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
</svg>
@@ -155,7 +160,7 @@ function MachineIconMedium1x({ className }: { className?: string }) {
/>
<path
d="M16.1875 3C16.6362 3 17 3.36377 17 3.8125V6.1875C17 6.63623 16.6362 7 16.1875 7H3.8125C3.36377 7 3 6.63623 3 6.1875V3.8125C3 3.36377 3.36377 3 3.8125 3H16.1875ZM5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
</svg>
@@ -175,7 +180,7 @@ function MachineIconMedium2x({ className }: { className?: string }) {
/>
<path
d="M16.1875 3C16.6362 3 17 3.36377 17 3.8125V6.1875C17 6.63623 16.6362 7 16.1875 7H3.8125C3.36377 7 3 6.63623 3 6.1875V3.8125C3 3.36377 3.36377 3 3.8125 3H16.1875ZM5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4Z"
fill="#D7D9DD"
fill="currentColor"
fillOpacity="0.4"
/>
</svg>
@@ -0,0 +1,34 @@
export function PadlockRoundedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12C5 10.8954 5.89543 10 7 10H17C18.1046 10 19 10.8954 19 12V19C19 20.1046 18.1046 21 17 21H7C5.89543 21 5 20.1046 5 19V12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 9.5V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V9.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 14V17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,27 @@
export function RenameIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 4H7.2C6.0799 4 5.51984 4 5.09202 4.21799C4.71569 4.40973 4.40973 4.71569 4.21799 5.09202C4 5.51984 4 6.0799 4 7.2V16.8C4 17.9201 4 18.4802 4.21799 18.908C4.40973 19.2843 4.71569 19.5903 5.09202 19.782C5.51984 20 6.0799 20 7.2 20H16.8C17.9201 20 18.4802 20 18.908 19.782C19.2843 19.5903 19.5903 19.2843 19.782 18.908C20 18.4802 20 17.9201 20 16.8V13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 14.9999V12.4142C9 12.1489 9.10536 11.8946 9.29289 11.707L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L12.2929 14.707C12.1054 14.8946 11.851 14.9999 11.5858 14.9999H9Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,26 @@
export function SidebarCustomizeIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 5H5C3.89543 5 3 5.89543 3 7V17C3 18.1046 3.89543 19 5 19H11M11 5H19C20.1046 5 21 5.89543 21 7V17C21 18.1046 20.1046 19 19 19H11M11 5V19"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="round"
/>
<path
d="M6.125 8.75C6.125 9.23325 6.51675 9.625 7 9.625C7.48325 9.625 7.875 9.23325 7.875 8.75C7.875 8.26675 7.48325 7.875 7 7.875C6.51675 7.875 6.125 8.26675 6.125 8.75ZM6.125 12C6.125 12.4832 6.51675 12.875 7 12.875C7.48325 12.875 7.875 12.4832 7.875 12C7.875 11.5168 7.48325 11.125 7 11.125C6.51675 11.125 6.125 11.5168 6.125 12ZM6.125 15.25C6.125 15.7332 6.51675 16.125 7 16.125C7.48325 16.125 7.875 15.7332 7.875 15.25C7.875 14.7668 7.48325 14.375 7 14.375C6.51675 14.375 6.125 14.7668 6.125 15.25Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.75"
/>
</svg>
);
}
+9 -5
View File
@@ -1,11 +1,15 @@
export function WebhookIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 190 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="1.75" fill="currentColor" />
<circle cx="7" cy="16" r="1.75" fill="currentColor" />
<circle cx="17" cy="16" r="1.75" fill="currentColor" />
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M97.1775 62.8673C96.7903 62.892 96.4025 62.9043 96.0145 62.9042C86.0767 62.9042 78.008 54.8167 78.008 44.8556C78.008 34.8946 86.0767 26.807 96.0145 26.807C105.952 26.807 114.021 34.8946 114.021 44.8556C114.027 48.9969 112.605 53.0129 109.996 56.2245L129.9 92.8984C134.805 91.1113 139.984 90.1972 145.203 90.1972C169.928 90.1972 190 110.317 190 135.099C190 159.881 169.928 180 145.203 180C136.454 179.997 127.896 177.435 120.577 172.628C119.792 172.117 119.128 171.439 118.632 170.642C118.136 169.846 117.82 168.95 117.706 168.019C117.593 167.087 117.684 166.141 117.974 165.249C118.263 164.356 118.745 163.538 119.384 162.851C119.397 162.837 119.411 162.822 119.437 162.819C120.511 161.665 121.955 160.925 123.518 160.727C125.08 160.53 126.662 160.888 127.989 161.738C133.116 165.067 139.095 166.838 145.203 166.838C162.68 166.838 176.868 152.616 176.868 135.099C176.868 117.581 162.68 103.36 145.203 103.36C139.151 103.36 130.562 106.013 125.398 109.298C125.132 109.498 124.826 109.639 124.502 109.71C124.177 109.781 123.841 109.781 123.516 109.711C123.191 109.64 122.885 109.5 122.619 109.301C122.352 109.101 122.132 108.846 121.973 108.554L97.1775 62.8673ZM88.9103 140.885C88.0018 146.036 86.2022 150.988 83.5926 155.517C71.2305 176.98 43.8113 184.345 22.3993 171.954C0.987294 159.563 -6.35938 132.079 6.0019 110.616C10.3798 103.023 16.8727 96.8744 24.6848 92.9248C25.5195 92.4994 26.4369 92.2623 27.3727 92.2302C28.3085 92.1981 29.24 92.3718 30.1017 92.739C30.9639 93.1059 31.7354 93.6575 32.3621 94.3552C32.9888 95.0529 33.4555 95.8797 33.7294 96.7775C33.7346 96.7969 33.7399 96.8153 33.7294 96.8391C34.1899 98.3489 34.1078 99.9727 33.4973 101.428C32.8868 102.883 31.7866 104.078 30.3881 104.804C24.9482 107.59 20.4286 111.894 17.3741 117.197C8.63613 132.367 13.829 151.795 28.9642 160.554C44.0994 169.313 63.4824 164.107 72.2204 148.936C75.2464 143.683 77.2482 134.9 76.9926 128.775C76.9519 128.444 76.9829 128.108 77.0835 127.79C77.1841 127.473 77.3519 127.18 77.5754 126.934C77.799 126.687 78.073 126.491 78.3788 126.36C78.6846 126.229 79.015 126.166 79.3475 126.175L131.219 127.494C131.39 127.148 131.573 126.805 131.769 126.466C136.737 117.84 147.759 114.879 156.366 119.859C164.974 124.841 167.926 135.888 162.957 144.515C157.988 153.141 146.966 156.101 138.359 151.12C134.778 149.055 132.019 145.813 130.548 141.943L88.9103 140.885ZM67.1689 79.2862C63.1721 75.9215 59.7926 71.8828 57.1827 67.3521C44.8205 45.8901 52.1672 18.4061 73.5792 6.01594C94.9912 -6.3742 122.411 0.98963 134.773 22.4507C139.144 30.0472 141.21 38.757 140.717 47.5127C140.667 48.4501 140.414 49.3652 139.973 50.1937C139.533 51.0222 138.917 51.7442 138.169 52.3088C137.421 52.8734 136.559 53.2671 135.643 53.4622C134.727 53.6572 133.779 53.649 132.866 53.438C132.847 53.4336 132.828 53.4283 132.812 53.4072C131.278 53.0521 129.916 52.169 128.964 50.9115C128.012 49.654 127.53 48.1018 127.602 46.5249C127.915 40.4094 126.456 34.3338 123.4 29.031C114.662 13.8614 95.2802 8.65544 80.145 17.4139C65.0107 26.1731 59.8161 45.6005 68.5549 60.771C71.5809 66.0244 78.1687 72.1539 83.5882 74.9942C83.8943 75.1245 84.1687 75.3194 84.3928 75.5657C84.617 75.8119 84.7855 76.1036 84.8871 76.421C84.9886 76.7384 85.0207 77.074 84.9812 77.405C84.9417 77.7359 84.8315 78.0544 84.6581 78.3389L57.5815 122.706C57.7958 123.027 58.0005 123.357 58.1963 123.696C63.1644 132.324 60.2113 143.371 51.6042 148.352C42.9979 153.332 31.9762 150.372 27.0072 141.745C22.0383 133.119 24.9914 122.071 33.5976 117.091C37.173 115.015 41.3538 114.241 45.4328 114.9L67.1689 79.2862Z"
d="M16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 8.14562 8.48161 9.17875 9.25341 9.90798C9.65459 10.287 9.83991 10.8882 9.57187 11.3706L8.94292 12.5027L7 16M12 7L13.9429 10.4973L14.571 11.6278C14.8394 12.1109 15.4487 12.2704 15.9833 12.1304C16.3079 12.0453 16.6487 12 17 12C19.2091 12 21 13.7909 21 16C21 18.2091 19.2091 20 17 20C16.2949 20 15.6323 19.8175 15.0571 19.4973M17 16H12C11.4477 16 11.0128 16.4547 10.8766 16.9899C10.4361 18.7202 8.86748 20 7 20C4.79086 20 3 18.2091 3 16C3 14.496 3.83007 13.1859 5.05708 12.5027"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

@@ -1,21 +0,0 @@
export function ATAndTLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width="66"
height="33"
viewBox="0 0 66 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M57.0405 21.156C57.0064 21.1581 56.9723 21.1529 56.9403 21.1408C56.9084 21.1287 56.8794 21.1099 56.8552 21.0858C56.8311 21.0616 56.8123 21.0326 56.8002 21.0007C56.7881 20.9687 56.7829 20.9346 56.785 20.9005V13.4102H54.2407C54.2068 13.4114 54.173 13.4056 54.1415 13.3932C54.1099 13.3808 54.0813 13.362 54.0573 13.338C54.0333 13.3141 54.0145 13.2854 54.0021 13.2538C53.9897 13.2223 53.984 13.1885 53.9852 13.1546V12.1226C53.9839 12.0887 53.9896 12.0549 54.002 12.0233C54.0143 11.9916 54.0331 11.9629 54.0571 11.9389C54.0811 11.9148 54.1098 11.896 54.1414 11.8836C54.173 11.8711 54.2068 11.8653 54.2407 11.8666H61.0127C61.0468 11.8646 61.0809 11.8698 61.1129 11.882C61.1448 11.8942 61.1738 11.913 61.1979 11.9372C61.222 11.9614 61.2407 11.9904 61.2527 12.0224C61.2647 12.0544 61.2698 12.0886 61.2677 12.1226V13.1557C61.2689 13.1896 61.2632 13.2233 61.2508 13.2548C61.2384 13.2863 61.2197 13.315 61.1958 13.3389C61.1718 13.3629 61.1432 13.3817 61.1118 13.3941C61.0803 13.4066 61.0465 13.4124 61.0127 13.4112H58.4684V20.9005C58.4696 20.9344 58.4639 20.9681 58.4515 20.9997C58.4391 21.0313 58.4203 21.0599 58.3963 21.0839C58.3723 21.1079 58.3437 21.1266 58.3121 21.1391C58.2805 21.1515 58.2468 21.1572 58.2129 21.156H57.0394M37.5459 17.4163L36.2102 13.5845L34.8629 17.4163H37.5459ZM40.5081 20.8528C40.5662 21.0041 40.4735 21.1555 40.3107 21.1555H39.1087C39.0327 21.1609 38.9572 21.1394 38.8955 21.0947C38.8338 21.0499 38.7899 20.9848 38.7715 20.9109L38.0861 18.9369H34.3343L33.6489 20.9109C33.6305 20.9847 33.5867 21.0498 33.5251 21.0945C33.4635 21.1392 33.3881 21.1608 33.3122 21.1555H32.1744C32.0231 21.1555 31.9183 21.0041 31.977 20.8528L35.1245 12.0985C35.1826 11.9357 35.2873 11.866 35.4612 11.866H37.0184C37.1928 11.866 37.3091 11.9351 37.3672 12.0985L40.5147 20.8528M49.5381 19.9014C50.2811 19.9014 50.7812 19.5422 51.1875 18.9265L49.3067 16.9058C48.5862 17.3127 48.1212 17.7185 48.1212 18.5316C48.1212 19.3322 48.7716 19.9025 49.5387 19.9025M50.0613 13.1031C49.4581 13.1031 49.1088 13.4869 49.1088 13.9969C49.1088 14.3917 49.3172 14.7399 49.7942 15.2509C50.6189 14.7739 50.9677 14.4843 50.9677 13.9733C50.9677 13.4962 50.6661 13.096 50.0618 13.096M55.2025 20.8303C55.3533 20.9931 55.2606 21.1555 55.0742 21.1555H53.5937C53.511 21.1627 53.4278 21.1483 53.3524 21.1136C53.277 21.0788 53.212 21.025 53.1638 20.9575L52.2864 19.9826C51.6942 20.7722 50.869 21.3644 49.4981 21.3644C47.8021 21.3644 46.4658 20.3428 46.4658 18.5898C46.4658 17.2425 47.1863 16.5225 48.2786 15.9194C47.744 15.3041 47.5 14.6538 47.5 14.0852C47.5 12.6447 48.5106 11.6582 50.0316 11.6582C51.5889 11.6582 52.5408 12.5761 52.5408 13.9338C52.5408 15.0952 51.7046 15.7444 50.8218 16.2325L52.123 17.6379L52.8545 16.3602C52.9477 16.2094 53.0519 16.1519 53.2383 16.1519H54.3646C54.5511 16.1519 54.6553 16.2802 54.5401 16.477L53.2449 18.706L55.208 20.8314M43.6161 21.1566C43.65 21.1579 43.6838 21.1521 43.7155 21.1398C43.7471 21.1274 43.7758 21.1086 43.7998 21.0846C43.8239 21.0607 43.8427 21.032 43.8552 21.0004C43.8676 20.9688 43.8734 20.935 43.8721 20.901V13.4107H46.4164C46.4503 13.4119 46.4841 13.4062 46.5157 13.3938C46.5472 13.3813 46.5759 13.3626 46.5999 13.3386C46.6238 13.3146 46.6426 13.2859 46.655 13.2544C46.6674 13.2228 46.6732 13.1891 46.672 13.1552V12.1226C46.6733 12.0887 46.6676 12.0549 46.6552 12.0233C46.6428 11.9916 46.624 11.9629 46.6001 11.9389C46.5761 11.9148 46.5474 11.896 46.5158 11.8836C46.4842 11.8711 46.4504 11.8653 46.4164 11.8666H39.6406C39.6067 11.8653 39.5728 11.8711 39.5412 11.8836C39.5097 11.896 39.481 11.9148 39.457 11.9389C39.433 11.9629 39.4142 11.9916 39.4019 12.0233C39.3895 12.0549 39.3838 12.0887 39.3851 12.1226V13.1557C39.3838 13.1896 39.3896 13.2234 39.402 13.2549C39.4144 13.2865 39.4332 13.3152 39.4572 13.3391C39.4812 13.3631 39.5098 13.3819 39.5414 13.3943C39.5729 13.4067 39.6067 13.4125 39.6406 13.4112H42.1849V20.9005C42.1837 20.9344 42.1894 20.9681 42.2018 20.9997C42.2143 21.0313 42.233 21.0599 42.257 21.0839C42.281 21.1079 42.3096 21.1266 42.3412 21.1391C42.3728 21.1515 42.4065 21.1572 42.4404 21.156L43.6161 21.1566Z"
fill="currentColor"
/>
<path
d="M9.2351 25.6737C11.2728 27.2567 13.7799 28.1157 16.3602 28.1149C19.296 28.1149 21.9725 27.0248 24.0151 25.2361C24.0397 25.2142 24.0277 25.1999 24.003 25.2142C23.0862 25.8261 20.4739 27.1624 16.3602 27.1624C12.7851 27.1624 10.5259 26.3646 9.2499 25.6529C9.22523 25.6408 9.217 25.6583 9.23455 25.6748M17.1487 26.2686C20.0083 26.2686 23.1503 25.4889 25.0295 23.9464C25.5438 23.5258 26.033 22.9665 26.4716 22.2148C26.74 21.7483 26.9742 21.263 27.1724 20.7628C27.1812 20.7381 27.1669 20.726 27.1477 20.754C25.4002 23.3306 20.339 24.9296 15.1188 24.9296C11.4252 24.9296 7.45135 23.7485 5.89571 21.4931C5.88035 21.4723 5.865 21.4811 5.87377 21.5052C7.3181 24.5863 11.7152 26.2686 17.1476 26.2686M14.0232 21.1581C8.07591 21.1581 5.2717 18.389 4.76338 16.4983C4.7579 16.4709 4.73926 16.4764 4.73926 16.5016C4.73926 17.1377 4.80287 17.9591 4.91253 18.5041C4.96463 18.7695 5.1867 19.1857 5.49706 19.5186C6.9392 21.0166 10.5286 23.1212 16.7468 23.1212C25.2187 23.1212 27.1554 20.2994 27.5508 19.3705C27.8337 18.7125 27.9801 17.5073 27.9801 16.5C27.9803 16.29 27.9751 16.0801 27.9648 15.8705C27.9648 15.8392 27.9467 15.8376 27.9406 15.8672C27.5173 18.1373 20.2792 21.157 14.0249 21.157M5.85458 11.5172C5.49236 12.2876 5.21385 13.0946 5.02385 13.9244C4.96901 14.1766 4.99533 14.2989 5.07868 14.4876C5.79152 15.9999 9.39686 18.4192 17.8068 18.4192C22.9376 18.4192 26.9235 17.158 27.5689 14.8582C27.6878 14.4349 27.6939 13.9875 27.5414 13.3854C27.3769 12.712 27.0512 11.9268 26.7803 11.3757C26.7716 11.3576 26.7557 11.3604 26.7584 11.3812C26.8588 14.3982 18.4456 16.3426 14.2003 16.3426C9.60194 16.3426 5.76685 14.5111 5.76685 12.1971C5.76685 11.9751 5.81291 11.7585 5.87652 11.521C5.882 11.4991 5.86445 11.4964 5.85458 11.5156M24.0348 7.81261C24.0859 7.89265 24.1114 7.9864 24.1077 8.0813C24.1077 9.37209 20.158 11.6548 13.8702 11.6548C9.25045 11.6548 8.38572 9.94127 8.38572 8.85117C8.38572 8.46733 8.53487 8.0632 8.86442 7.65798C8.88252 7.63385 8.86716 7.62508 8.84633 7.64262C8.24498 8.15186 7.6971 8.72105 7.21118 9.34138C6.97978 9.63365 6.83666 9.89246 6.83666 10.0476C6.83666 12.3068 12.4999 13.9441 17.7958 13.9441C23.4437 13.9441 25.9562 12.1017 25.9562 10.4896C25.9562 9.91111 25.7369 9.57388 25.1556 8.91861C24.7816 8.49255 24.428 8.15094 24.0589 7.80438C24.0408 7.78958 24.0282 7.80164 24.0408 7.81974M22.3108 6.52949C20.5693 5.48545 18.547 4.8916 16.3668 4.8916C14.1713 4.8916 12.0887 5.50574 10.3351 6.57775C9.81086 6.90017 9.51585 7.15899 9.51585 7.49128C9.51585 8.47117 11.8057 9.52453 15.8673 9.52453C19.8866 9.52453 23.005 8.37082 23.005 7.25988C23.005 6.99503 22.7731 6.80915 22.3042 6.52949"
fill="currentColor"
/>
</svg>
);
}
@@ -1,57 +0,0 @@
export function AstroLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 184 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_5336_22429)">
<mask id="mask0_5336_22429" maskUnits="userSpaceOnUse" x="0" y="0" width="184" height="49">
<path d="M184 0H0V48.4533H184V0Z" fill="white" />
</mask>
<g mask="url(#mask0_5336_22429)">
<path
d="M12.4424 40.9986C10.2625 39.0108 9.62608 34.8341 10.5343 31.8083C12.1092 33.716 14.2912 34.3203 16.5514 34.6615C20.0406 35.1879 23.4674 34.991 26.7087 33.4002C27.0795 33.218 27.4221 32.9759 27.8273 32.7306C28.1315 33.6107 28.2106 34.4993 28.1044 35.4037C27.8461 37.6063 26.7472 39.3077 24.9995 40.5974C24.3006 41.1133 23.5611 41.5744 22.8393 42.0609C20.6218 43.5559 20.0219 45.3089 20.8551 47.8589C20.8749 47.921 20.8926 47.9831 20.9374 48.1347C19.8053 47.6293 18.9783 46.8934 18.3481 45.9258C17.6825 44.9046 17.3659 43.7749 17.3492 42.5525C17.3409 41.9577 17.3409 41.3576 17.2607 40.7711C17.0649 39.3413 16.3921 38.7013 15.1245 38.6644C13.8236 38.6265 12.7945 39.4287 12.5216 40.6921C12.5008 40.789 12.4706 40.8849 12.4404 40.9975L12.4424 40.9986Z"
fill="url(#paint0_linear_5336_22429)"
/>
<path
d="M0 31.3041C0 31.3041 6.45527 28.1673 12.9286 28.1673L17.8093 13.1001C17.992 12.3714 18.5256 11.8762 19.1278 11.8762C19.7302 11.8762 20.2637 12.3714 20.4464 13.1001L25.3271 28.1673C32.9938 28.1673 38.2557 31.3041 38.2557 31.3041C38.2557 31.3041 27.2909 1.50808 27.2694 1.44829C26.9547 0.567361 26.4234 0 25.7072 0H12.5496C11.8333 0 11.3235 0.567361 10.9874 1.44829C10.9637 1.50695 0 31.3041 0 31.3041Z"
fill="white"
/>
<path
d="M68.0598 26.9653C68.0598 29.6056 64.7674 31.1825 60.2089 31.1825C57.2422 31.1825 56.1929 30.4491 56.1929 28.9089C56.1929 27.2954 57.4954 26.5253 60.4622 26.5253C63.1396 26.5253 65.4188 26.5619 68.0598 26.892V26.9653ZM68.096 23.7016C66.4682 23.3349 64.0081 23.1149 61.0773 23.1149C52.5388 23.1149 48.5228 25.1318 48.5228 29.8257C48.5228 34.7029 51.2725 36.5731 57.6402 36.5731C63.031 36.5731 66.6853 35.2163 68.0236 31.8792H68.2407C68.2045 32.6859 68.1683 33.4927 68.1683 34.1161C68.1683 35.8396 68.4578 35.9864 69.8691 35.9864H76.5262C76.1644 34.9596 75.9472 32.0626 75.9472 29.5689C75.9472 26.892 76.0558 24.8751 76.0558 22.1615C76.0558 16.6241 72.7272 13.1037 62.3073 13.1037C57.821 13.1037 52.8282 13.8738 49.0293 15.0106C49.3911 16.5141 49.8977 19.5578 50.1509 21.538C53.4433 19.9979 58.1105 19.3378 61.7283 19.3378C66.7215 19.3378 68.096 20.4745 68.096 22.7848V23.7016Z"
fill="white"
/>
<path
d="M86.3622 28.5055C85.4576 28.6155 84.2278 28.6155 82.9613 28.6155C81.6224 28.6155 80.3927 28.5788 79.5604 28.4688C79.5604 28.7622 79.5242 29.0922 79.5242 29.3856C79.5242 33.9695 82.5271 36.6464 93.0917 36.6464C103.041 36.6464 106.261 34.0062 106.261 29.3489C106.261 24.9484 104.127 22.7848 94.6833 22.3081C87.3393 21.9781 86.6879 21.1713 86.6879 20.2545C86.6879 19.1911 87.6288 18.641 92.5489 18.641C97.6506 18.641 99.0251 19.3378 99.0251 20.8046V21.1346C99.7489 21.098 101.052 21.0613 102.39 21.0613C103.656 21.0613 105.031 21.098 105.827 21.1713C105.827 20.8413 105.863 20.5479 105.863 20.2912C105.863 14.9006 101.377 13.1404 92.6937 13.1404C82.9251 13.1404 79.6327 15.524 79.6327 20.1812C79.6327 24.3617 82.2738 26.9653 91.6443 27.3687C98.5547 27.5887 99.3146 28.3588 99.3146 29.4223C99.3146 30.559 98.1928 31.0725 93.345 31.0725C87.7735 31.0725 86.3622 30.3024 86.3622 28.7255V28.5055Z"
fill="white"
/>
<path
d="M118.203 9.17995C115.562 11.6369 110.822 14.0939 108.181 14.7539C108.217 16.1107 108.217 18.6044 108.217 19.9612L110.641 19.9979C110.605 22.6015 110.569 25.7552 110.569 27.8454C110.569 32.7226 113.137 36.3897 121.133 36.3897C124.498 36.3897 126.741 36.023 129.527 35.4363C129.237 33.6394 128.912 30.8891 128.804 28.7988C127.139 29.3489 125.041 29.6423 122.725 29.6423C119.505 29.6423 118.203 28.7622 118.203 26.2319C118.203 24.0316 118.203 21.9781 118.239 20.0712C122.364 20.1078 126.488 20.1812 128.912 20.2545C128.876 18.3477 128.948 15.5973 129.057 13.7638C125.547 13.8371 121.604 13.8738 118.348 13.8738C118.384 12.2603 118.42 10.7201 118.456 9.17995H118.203Z"
fill="white"
/>
<path
d="M139.812 18.861C139.848 16.9541 139.884 15.3406 139.92 13.7638H132.648C132.757 16.9175 132.757 20.1445 132.757 24.8751C132.757 29.6056 132.72 32.8693 132.648 35.9864H140.969C140.824 33.7861 140.788 30.0823 140.788 26.9286C140.788 21.9414 142.815 20.5112 147.409 20.5112C149.544 20.5112 151.063 20.768 152.402 21.2446C152.438 19.3745 152.8 15.744 153.017 14.1305C151.642 13.7271 150.123 13.4705 148.278 13.4705C144.334 13.4338 141.44 15.0473 140.101 18.8977L139.812 18.861Z"
fill="white"
/>
<path
d="M176.038 24.7284C176.038 28.7255 173.143 30.5957 168.584 30.5957C164.062 30.5957 161.167 28.8355 161.167 24.7284C161.167 20.6213 164.098 19.0811 168.584 19.0811C173.107 19.0811 176.038 20.7313 176.038 24.7284ZM183.599 24.5451C183.599 16.5875 177.376 13.0304 168.584 13.0304C159.757 13.0304 153.75 16.5875 153.75 24.5451C153.75 32.4659 159.359 36.7198 168.548 36.7198C177.81 36.7198 183.599 32.4659 183.599 24.5451Z"
fill="white"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_5336_22429"
x1="10.1338"
y1="48.1347"
x2="31.1641"
y2="38.1736"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D83333" />
<stop offset="1" stopColor="#F041FF" />
</linearGradient>
<clipPath id="clip0_5336_22429">
<rect width="184" height="48.4533" fill="white" />
</clipPath>
</defs>
</svg>
);
}
File diff suppressed because one or more lines are too long
@@ -1,45 +0,0 @@
export function FastifyLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 184 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_5342_22458)">
<path
d="M181.319 7.73806L184 0.829078L183.889 0.386903L157.47 7.35116C160.288 3.26104 159.321 0 159.321 0C159.321 0 150.892 5.38901 144.508 5.25083C138.124 5.11265 136.079 3.39922 126.296 6.52208C116.513 9.64494 113.749 19.207 110.903 21.2797C108.084 23.3247 99.213 30.1508 99.213 30.1508L107.227 27.5807C107.227 27.5807 104.961 29.7363 100.291 36.0096V36.0372C101.037 37.0874 104.326 41.4263 107.587 40.4866C107.946 40.3761 108.36 40.1826 108.83 39.9615C110.295 40.7906 112.257 41.5921 114.385 41.8132C114.385 41.8132 112.948 40.155 111.732 38.2205L112.727 37.5849L112.561 37.6954L115.629 38.8008L115.297 35.9267H115.325L118.309 37.0321L117.95 34.4067L119.083 33.854L122.206 22.0258L135.14 13.1823L134.117 15.7801C131.492 22.2193 126.573 23.7393 126.573 23.7393L124.527 24.5407C122.98 26.337 122.344 26.7792 121.819 32.8591C123.063 32.5275 124.223 32.4722 125.301 32.7486C130.828 34.2409 132.735 40.9012 131.243 42.7528C130.884 43.195 129.999 43.9964 128.866 44.9084H126.628L126.6 46.7324L126.379 46.9258H124.085L124.058 48.6945L123.45 49.1367C121.322 49.1919 118.586 47.3127 118.586 47.3127C118.613 49.0261 120.023 51.6515 120.023 51.6515L120.272 51.5134L120.05 51.6792C120.05 51.6792 125.799 55.4929 129.419 54.0835C132.625 52.8123 140.943 46.2625 148.129 43.1673L169.85 37.419L172.725 30.0126L156.171 34.3791V27.6912L175.599 22.5786L178.473 15.1445L156.171 21.0309V14.3707L181.319 7.73806ZM141.883 21.86L147.051 20.5059L147.106 20.7546L145.503 24.9276L140.169 26.337L141.883 21.86ZM143.652 30.7864L138.318 32.1959L140.059 27.7188L145.199 26.3647L145.282 26.6134L143.652 30.7864ZM150.616 29.2941L145.282 30.7035L147.023 26.2265L152.163 24.8723L152.246 25.1211L150.616 29.2941Z"
fill="white"
/>
<path
d="M4.00721 16.7197L4.17302 15.863C4.64284 13.3758 5.58246 11.2478 7.32352 9.64495C8.59477 8.4566 10.474 7.73807 12.602 7.73807C13.9561 7.73807 14.9787 7.93152 15.6143 8.15261L14.426 12.2427C13.9285 12.0769 13.4587 11.994 12.7678 11.994C10.8609 11.994 9.75548 13.9838 9.39621 15.8078L9.2304 16.7197H13.2652L12.5467 20.4506H8.59477L5.3061 37.5296H0L3.26104 20.4506"
fill="white"
/>
<path
d="M21.4178 37.5296L21.5837 34.0475H21.4455C19.7873 36.8111 17.7975 37.8612 16.1394 37.8612C13.0994 37.8612 11.6071 35.2634 11.6071 31.6155C11.6071 25.3145 14.7576 16.3605 23.7116 16.3605C25.7843 16.3605 27.8846 16.7197 29.1559 17.2172L26.8621 28.7966C26.3647 31.2286 25.9778 35.2634 26.0054 37.5296H21.4178ZM23.3247 20.4506C22.9102 20.3677 22.5233 20.34 22.2193 20.34C18.7372 20.34 16.8579 27.2767 16.8026 30.3443C16.8026 32.1682 17.079 33.4947 18.3779 33.4947C19.8426 33.4947 21.1968 31.0904 21.9706 27.3043L23.3247 20.4506Z"
fill="white"
/>
<path
d="M29.7915 32.9697C30.6482 33.4395 31.6708 33.854 33.1631 33.8264C34.7107 33.7711 35.5398 32.8038 35.5398 31.4773C35.5398 30.3166 34.9871 29.5152 33.55 28.3821C31.7537 26.9174 30.897 25.0934 30.897 23.2971C30.897 19.4833 33.8816 16.3605 38.6074 16.3605C40.459 16.3605 41.7579 16.6921 42.5317 17.1343L41.371 21.0033C40.7906 20.6717 39.7957 20.3677 38.9114 20.3677C37.198 20.3677 36.1478 21.252 36.1478 22.6615C36.1478 23.7393 36.7558 24.3749 37.723 25.1763C40.0721 26.9727 40.8459 29.0177 40.8459 30.6206C40.8459 35.2358 37.6401 37.7783 32.8591 37.7783C30.9522 37.7783 29.2388 37.2256 28.5203 36.7282L29.7915 32.9697Z"
fill="white"
/>
<path
d="M53.3097 11.082L52.2319 16.7197H60.4121L59.7212 20.4506H51.5134L49.6894 30.1232C49.5512 30.8417 49.5236 31.4773 49.5236 31.8642C49.5236 33.1355 50.1592 33.6053 51.1265 33.6053C51.5134 33.6053 52.0661 33.6053 52.5635 33.5224L51.9279 37.5296C50.9606 37.7783 49.7723 37.8612 48.7498 37.8612C45.544 37.8612 43.9688 36.0925 43.9688 33.2736C43.9688 32.334 44.1346 31.1733 44.3557 30.1508L46.1796 20.4506H43.7753L44.4938 16.6921H46.9258L47.7549 12.4085L53.3097 11.082Z"
fill="white"
/>
<path
d="M54.581 37.5296L58.5882 16.7197H63.8943L59.9147 37.5296H54.581ZM59.5554 11.386C59.5554 9.83838 60.6609 8.09732 62.5401 8.09732C64.2812 8.09732 65.1103 9.42384 65.055 10.8609C64.9997 12.9889 63.5626 14.2049 61.9874 14.2049C60.274 14.2049 59.5278 12.9612 59.5554 11.386Z"
fill="white"
/>
<path
d="M67.2106 16.7197L67.3764 15.863C67.8462 13.3758 68.7858 11.2478 70.5269 9.64495C71.7981 8.4566 73.6774 7.73807 75.8054 7.73807C77.1595 7.73807 78.182 7.93152 78.8177 8.15261L77.6293 12.2427C77.1319 12.0769 76.6621 11.994 75.9712 11.994C74.0643 11.994 72.9589 13.9838 72.6272 15.8078L72.4338 16.7197H78.6242L77.9057 20.4506H71.7981L68.5371 37.5296H63.2034L66.492 20.4506"
fill="white"
/>
<path
d="M81.9405 16.7197L82.0787 26.7239C82.1063 28.6861 82.1616 30.1232 82.1063 31.6431H82.1892C82.6314 29.9574 83.046 28.5479 83.7645 26.337L86.915 16.6921H92.2764L85.4779 33.1078C83.4329 37.8612 81.0838 41.8684 78.5413 44.2175C77.2977 45.3506 75.8606 46.2625 74.9763 46.6218L72.8483 42.283C73.7603 41.8132 74.7552 41.2604 75.6119 40.5972C76.8002 39.6023 77.9333 38.4139 78.4584 37.2532C78.5413 36.9769 78.6242 36.7558 78.5689 36.3413L76.3581 16.6921H81.9405V16.7197Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_5342_22458">
<rect width="184" height="54.4151" fill="white" />
</clipPath>
</defs>
</svg>
);
}
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
export function NextjsLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 184 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_5291_19580)">
<path
d="M122.318 0.0154419H154.367V5.93096H141.653V37.0517H135.296V5.93096H122.318V0.0154419Z"
fill="white"
/>
<path
d="M69.608 0.0154419V5.93096H43.9181V15.4472H64.5779V21.3627H43.9181V31.1362H69.608V37.0517H37.5612L37.5586 0.0154419H69.608Z"
fill="white"
/>
<path
d="M85.6114 0.0308914H77.2921L107.09 37.0672H115.433L100.533 18.5618L115.41 0.0591477L107.09 0.0720044L96.3674 13.3889L85.6114 0.0308914Z"
fill="white"
/>
<path
d="M94.1482 26.4861L89.9821 21.3061L77.2683 37.0954H85.6114L94.1482 26.4861Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M37.784 37.0517L7.94614 0H0V37.0363H6.35692V7.91648L29.7981 37.0517H37.784Z"
fill="white"
/>
<path
d="M155.796 36.8255C155.331 36.8255 154.935 36.6642 154.603 36.3417C154.271 36.0191 154.107 35.6286 154.112 35.166C154.107 34.7161 154.271 34.3298 154.603 34.0073C154.935 33.6847 155.331 33.5234 155.796 33.5234C156.244 33.5234 156.636 33.6847 156.967 34.0073C157.304 34.3298 157.471 34.7161 157.476 35.166C157.471 35.4716 157.394 35.7517 157.239 36.0021C157.079 36.2568 156.877 36.4563 156.623 36.6006C156.373 36.7491 156.097 36.8255 155.796 36.8255Z"
fill="white"
/>
<path
d="M166.646 21.2232H169.463V32.0761C169.459 33.0735 169.243 33.9266 168.821 34.6439C168.395 35.3612 167.805 35.9087 167.047 36.2949C166.293 36.6769 165.41 36.8722 164.406 36.8722C163.489 36.8722 162.666 36.7066 161.934 36.3841C161.202 36.0615 160.62 35.5777 160.194 34.941C159.763 34.3043 159.552 33.5107 159.552 32.5599H162.373C162.378 32.9759 162.472 33.3366 162.653 33.638C162.834 33.9393 163.084 34.1685 163.403 34.3298C163.725 34.4911 164.096 34.5717 164.514 34.5717C164.966 34.5717 165.354 34.4783 165.668 34.2873C165.982 34.1006 166.224 33.8205 166.392 33.447C166.555 33.0777 166.642 32.6193 166.646 32.0761V21.2232Z"
fill="white"
/>
<path
d="M181.054 25.4676C180.985 24.8097 180.683 24.2961 180.158 23.9311C179.628 23.5619 178.943 23.3793 178.103 23.3793C177.513 23.3793 177.005 23.4685 176.583 23.6425C176.161 23.8208 175.833 24.0584 175.609 24.3598C175.386 24.6611 175.274 25.0049 175.265 25.3912C175.265 25.7138 175.342 25.9939 175.493 26.2273C175.644 26.465 175.846 26.6645 176.109 26.8258C176.367 26.9913 176.656 27.1271 176.971 27.2375C177.289 27.3478 177.608 27.4412 177.927 27.5176L179.395 27.8784C179.986 28.0142 180.559 28.1967 181.105 28.4301C181.652 28.6594 182.148 28.9522 182.583 29.3045C183.018 29.6568 183.363 30.0812 183.617 30.5778C183.871 31.0744 184 31.6558 184 32.3265C184 33.2305 183.767 34.0242 183.298 34.7118C182.828 35.3951 182.152 35.9299 181.265 36.3162C180.382 36.6982 179.314 36.8934 178.056 36.8934C176.841 36.8934 175.782 36.7067 174.89 36.3332C173.994 35.9639 173.296 35.4206 172.792 34.7075C172.288 33.9945 172.017 33.1244 171.978 32.1015H174.77C174.808 32.6363 174.981 33.082 175.274 33.4427C175.571 33.7993 175.958 34.0624 176.432 34.2407C176.91 34.4147 177.444 34.5038 178.034 34.5038C178.65 34.5038 179.193 34.4104 179.662 34.2279C180.128 34.0454 180.494 33.7908 180.757 33.4597C181.024 33.1329 181.157 32.7467 181.162 32.3053C181.157 31.902 181.036 31.5667 180.804 31.3035C180.567 31.0404 180.24 30.8197 179.822 30.6415C179.4 30.4632 178.909 30.3019 178.349 30.1618L176.566 29.7119C175.278 29.3851 174.257 28.8886 173.512 28.2222C172.762 27.5558 172.392 26.673 172.392 25.5652C172.392 24.6569 172.641 23.859 173.146 23.1756C173.645 22.4923 174.33 21.9618 175.196 21.584C176.066 21.202 177.048 21.0152 178.142 21.0152C179.253 21.0152 180.227 21.202 181.067 21.584C181.906 21.9618 182.566 22.4881 183.044 23.1586C183.522 23.8293 183.772 24.5975 183.785 25.4676H181.054Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_5291_19580">
<rect width="184" height="37.3604" fill="white" />
</clipPath>
</defs>
</svg>
);
}
-21
View File
@@ -1,21 +0,0 @@
export function NuxtLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 184 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_5336_22448)">
<path
d="M38.7097 46H64.3598C65.1746 46 65.9748 45.7875 66.6805 45.3838C67.3846 44.9814 67.9704 44.4006 68.379 43.7C68.7859 43.0017 69.0002 42.208 69 41.3998C68.9994 40.5916 68.7844 39.7981 68.3769 39.1002L51.1511 9.52867C50.7425 8.82816 50.1568 8.24752 49.4528 7.84507C48.7462 7.44109 47.9464 7.22869 47.1325 7.2289C46.3181 7.2289 45.5179 7.44165 44.8125 7.8453C44.1084 8.24768 43.5227 8.82834 43.1142 9.5289L38.7097 17.095L30.0978 2.29954C29.6891 1.59892 29.1031 1.01826 28.3988 0.61594C27.6921 0.212089 26.8922 -0.000228035 26.0783 1.8379e-07C25.2637 1.8379e-07 24.4633 0.21252 23.7576 0.61617C23.0534 1.01847 22.4674 1.59904 22.0586 2.29954L0.623071 39.1C0.215509 39.7979 0.000499188 40.5915 7.38785e-07 41.3998C-0.000459261 42.2073 0.213901 43.0006 0.621001 43.7C1.0296 44.4006 1.61541 44.9813 2.31955 45.3838C3.02626 45.7878 3.82623 46.0002 4.64025 46H20.7412C27.1207 46 31.8253 43.2228 35.0624 37.8044L42.9217 24.3144L47.1314 17.095L59.7653 38.7805H42.9217L38.7097 46ZM20.4787 38.7732L9.24232 38.7706L26.0857 9.85826L34.4901 24.3144L28.8629 33.9768C26.7131 37.4923 24.271 38.7732 20.4787 38.7732Z"
fill="#00DC82"
/>
<path
d="M86.71 46C86.954 46 87.188 45.9031 87.3605 45.7305C87.5331 45.558 87.63 45.324 87.63 45.08V23.69C87.63 23.69 88.8361 25.5958 91.08 29.44L99.9826 44.841C100.394 45.5584 101.152 46 101.972 46H108.1V11.5H101.89C101.646 11.5 101.412 11.5969 101.239 11.7695C101.067 11.942 100.97 12.176 100.97 12.42V34.04L97.06 27.14L88.6448 12.6546C88.2326 11.9398 87.4761 11.5 86.6578 11.5H80.5V46H86.71ZM155.527 33.0871L163.427 21.16H157.509C157.128 21.1607 156.754 21.2561 156.419 21.4377C156.084 21.6193 155.8 21.8813 155.592 22.2001L151.974 27.715L148.356 22.2001C148.148 21.8813 147.864 21.6193 147.529 21.4377C147.195 21.2561 146.82 21.1607 146.439 21.16H140.568L148.468 33.0379L139.961 46H145.703C146.08 45.9993 146.452 45.9054 146.785 45.7265C147.117 45.5477 147.401 45.2894 147.609 44.9747L152.021 38.3608L156.386 44.9664C156.595 45.2834 156.878 45.5438 157.212 45.7242C157.546 45.9046 157.919 45.9993 158.298 46H163.988L155.527 33.0871ZM166.658 21.16H171.192V13.8163H177.689V21.16H184V26.8771H177.689V36.685C177.689 39.1 178.905 40.2337 181.008 40.2337H184V46H180.12C174.698 46 171.192 42.7471 171.192 36.9808V26.8771H166.658V21.16ZM135.7 21.16H132.25C131.448 21.16 130.82 21.1934 130.295 21.7403C129.77 22.2569 129.72 22.5752 129.72 23.3655V35.4372C129.72 37.2906 129.634 38.4185 128.8 39.33C127.966 40.2111 126.818 40.48 125.12 40.48C123.453 40.48 122.274 40.2111 121.44 39.33C120.606 38.4185 120.52 37.2906 120.52 35.4372V23.3655C120.52 22.5754 120.47 22.2569 119.945 21.7403C119.42 21.1934 118.792 21.16 117.99 21.16H114.54V35.5171C114.54 38.738 115.435 41.2903 117.225 43.1742C119.047 45.0582 121.694 46 125.12 46C128.546 46 131.147 45.0582 132.968 43.1742C134.789 41.2903 135.7 38.738 135.7 35.5171V21.16Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_5336_22448">
<rect width="184" height="46" fill="white" />
</clipPath>
</defs>
</svg>
);
}
File diff suppressed because one or more lines are too long
-199
View File
@@ -1,199 +0,0 @@
export function RemixLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 204 66" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_5334_22407)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M47.4777 45.058C47.8835 50.3106 47.8835 52.7727 47.8835 55.4603H35.8237C35.8237 54.8748 35.8341 54.3393 35.8445 53.7962C35.8771 52.1081 35.9111 50.3479 35.6397 46.7928C35.2811 41.5885 33.0571 40.4319 28.9676 40.4319H25.3444H10V30.9627H29.5415C34.7071 30.9627 37.2898 29.379 37.2898 25.1865C37.2898 21.5001 34.7071 19.2659 29.5415 19.2659H10V10H31.6937C43.3882 10 49.1997 15.5659 49.1997 24.457C49.1997 31.1071 45.1102 35.4441 39.5857 36.167C44.2492 37.1068 46.9754 39.7813 47.4777 45.058Z"
fill="#E5F3FF"
/>
</g>
<path
d="M10 55.4604V48.4012H22.7516C24.8815 48.4012 25.3439 49.9932 25.3439 50.9423V55.4604H10Z"
fill="#E5F3FF"
/>
<g filter="url(#filter1_d_5334_22407)">
<path
d="M192.998 23.7133H181.045L175.606 31.3573L170.309 23.7133H157.498L169.021 39.5059L156.496 55.8753H168.448L174.819 47.1497L181.188 55.8753H194L181.403 39.0011L192.998 23.7133Z"
fill="#FFF1F1"
/>
</g>
<g filter="url(#filter2_d_5334_22407)">
<path
d="M117.711 29.0434C116.351 25.2935 113.416 22.6975 107.762 22.6975C102.966 22.6975 99.5309 24.861 97.8132 28.3943V23.5629H86.2185V55.7246H97.8132V39.9322C97.8132 35.1008 99.1731 31.9279 102.966 31.9279C106.474 31.9279 107.333 34.2354 107.333 38.6343V55.7246H118.927V39.9322C118.927 35.1008 120.215 31.9279 124.08 31.9279C127.588 31.9279 128.375 34.2354 128.375 38.6343V55.7246H139.97V35.5335C139.97 28.827 137.393 22.6975 128.59 22.6975C123.222 22.6975 119.428 25.4377 117.711 29.0434Z"
fill="#FFFAEA"
/>
</g>
<g filter="url(#filter3_d_5334_22407)">
<path
d="M74.1446 43.2432C73.071 45.7671 71.067 46.8487 67.9178 46.8487C64.4107 46.8487 61.5477 44.9738 61.2614 41.0076H83.6637V37.7628C83.6637 29.0372 78.0096 21.6817 67.345 21.6817C57.3964 21.6817 49.9529 28.9651 49.9529 39.1328C49.9529 49.3725 57.2534 55.5742 67.4883 55.5742C75.934 55.5742 81.803 51.464 83.4491 44.1084L74.1446 43.2432ZM61.4047 35.3829C61.8339 32.3542 63.4803 30.0466 67.202 30.0466C70.6375 30.0466 72.4983 32.4984 72.6415 35.3829H61.4047Z"
fill="#F1FFF0"
/>
</g>
<g filter="url(#filter4_d_5334_22407)">
<path
d="M143.762 23.7765V55.9383H155.357V23.7765H143.762ZM143.691 20.7478H155.429V10.5078H143.691V20.7478Z"
fill="white"
/>
</g>
<defs>
<filter
id="filter0_d_5334_22407"
x="0"
y="0"
width="59.1997"
height="65.4604"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.203922 0 0 0 0 0.45098 0 0 0 0 0.74902 0 0 0 1 0"
/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5334_22407" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5334_22407"
result="shape"
/>
</filter>
<filter
id="filter1_d_5334_22407"
x="146.496"
y="13.7133"
width="57.5044"
height="52.162"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.882353 0 0 0 0 0.180392 0 0 0 0 0.227451 0 0 0 1 0"
/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5334_22407" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5334_22407"
result="shape"
/>
</filter>
<filter
id="filter2_d_5334_22407"
x="78.2185"
y="14.6975"
width="69.7512"
height="49.0271"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="4" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.937255 0 0 0 0 0.72549 0 0 0 0 0.117647 0 0 0 1 0"
/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5334_22407" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5334_22407"
result="shape"
/>
</filter>
<filter
id="filter3_d_5334_22407"
x="41.9529"
y="13.6817"
width="49.7108"
height="49.8926"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="4" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.384314 0 0 0 0 0.788235 0 0 0 0 0.372549 0 0 0 1 0"
/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5334_22407" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5334_22407"
result="shape"
/>
</filter>
<filter
id="filter4_d_5334_22407"
x="133.691"
y="0.507791"
width="31.738"
height="65.4305"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.596078 0 0 0 0 0.192157 0 0 0 0 0.556863 0 0 0 1 0"
/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5334_22407" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5334_22407"
result="shape"
/>
</filter>
</defs>
</svg>
);
}
@@ -1,39 +0,0 @@
export function ShopifyLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width="85"
height="25"
viewBox="0 0 85 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_9632_116870)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.6613 1.00225C10.5632 0.945637 10.4859 0.900988 10.3639 0.900988C8.19769 0.900988 7.03127 3.73373 6.44806 5.40004L8.28101 4.90015C8.7809 2.5673 9.78069 1.4842 10.7805 1.06762C10.7358 1.0453 10.6972 1.02297 10.6613 1.00225ZM11.6136 3.73373C11.6136 2.81725 11.447 2.23404 11.2804 1.73415C10.5305 2.06741 9.69738 2.81725 9.19748 4.56688L11.6136 3.90036V3.73373ZM12.3635 3.56709L13.4466 3.23383C13.1966 2.40067 12.6967 1.65083 12.1135 1.4842C12.2802 1.98409 12.3635 2.5673 12.3635 3.31715V3.56709ZM18.6955 4.40025C18.7788 4.40025 18.8621 4.48357 18.8621 4.56688C18.8621 4.73352 21.3616 21.7299 21.3616 21.7299L14.1964 23.3129L0.949219 20.8135C0.949219 20.8135 2.61553 7.64957 2.69885 7.23299C2.70036 7.22241 2.70184 7.212 2.7033 7.20175C2.78239 6.64673 2.79425 6.56344 3.44869 6.39983C3.64023 6.33599 3.82633 6.27214 4.00838 6.20968C4.53904 6.02763 5.03532 5.85737 5.53159 5.7333C6.03148 3.81704 7.44785 0.0678302 10.4472 0.151146C10.8638 0.151146 11.3637 0.317778 11.697 0.817672H11.7803C13.03 0.817672 13.7799 1.90078 14.1964 3.0672C14.613 2.90057 14.863 2.81725 14.863 2.81725C14.8796 2.81725 14.9029 2.81392 14.9309 2.80992C15.0429 2.79392 15.2295 2.76726 15.3629 2.90057C15.5295 2.98388 16.7792 4.23362 16.7792 4.23362C16.7792 4.23362 18.5289 4.40025 18.6955 4.40025ZM10.8622 11.0656L11.7786 8.39946C11.7786 8.39946 11.1954 7.98288 9.77906 8.06619C6.69638 8.06619 5.11338 10.1491 5.11338 12.3153C5.11338 13.764 5.92592 14.4263 6.63951 15.008C7.19816 15.4633 7.69617 15.8693 7.69617 16.5644C7.69617 16.981 7.44622 17.4809 6.7797 17.4809C5.77991 17.4809 4.61348 16.4811 4.61348 16.4811L4.03027 18.4807C4.03027 18.4807 5.19669 19.897 7.44622 19.897C9.36249 19.897 10.7789 18.4807 10.7789 16.2311C10.7789 14.5056 9.60415 13.6457 8.68583 12.9735C8.09656 12.5421 7.61285 12.188 7.61285 11.7321C7.61285 11.4821 7.69617 10.649 9.11254 10.649C10.1123 10.649 10.8622 11.0656 10.8622 11.0656Z"
fill="currentColor"
/>
<path
d="M18.6111 4.40038C18.5277 4.40038 16.7781 4.23375 16.7781 4.23375C16.7781 4.23375 15.5284 2.98401 15.3617 2.9007C15.2784 2.81738 15.2784 2.81738 15.1951 2.81738L14.1953 23.3131L21.3605 21.7301C21.3605 21.7301 18.861 4.73365 18.861 4.56701C18.861 4.4837 18.6944 4.40038 18.6111 4.40038Z"
fill="currentColor"
/>
<path
d="M29.7751 13.3151C29.0253 12.8985 28.692 12.5653 28.692 12.1487C28.692 11.5655 29.1919 11.2322 30.0251 11.2322C30.9416 11.2322 31.7747 11.6488 31.7747 11.6488L32.4412 9.64922C32.4412 9.64922 31.858 9.14932 30.1084 9.14932C27.6089 9.14932 25.9426 10.5657 25.9426 12.5653C25.9426 13.7317 26.7758 14.5648 27.7756 15.1481C28.6087 15.648 28.942 15.9812 28.942 16.4811C28.942 16.981 28.5254 17.4809 27.6922 17.4809C26.5258 17.4809 25.3594 16.8977 25.3594 16.8977L24.6929 18.8973C24.6929 18.8973 25.6927 19.5638 27.4423 19.5638C29.9418 19.5638 31.7747 18.3141 31.7747 16.0645C31.7747 14.8148 30.8582 13.8983 29.7751 13.3151ZM39.8563 9.06601C38.6066 9.06601 37.6068 9.64922 36.857 10.5657L37.9401 4.90022H35.1073L32.3579 19.3138H35.1907L36.1071 14.3982C36.4404 12.5653 37.4402 11.3988 38.3567 11.3988C39.0232 11.3988 39.2731 11.8154 39.2731 12.482C39.2731 12.8985 39.2731 13.3151 39.1898 13.7317L38.1067 19.3972H40.9395L42.0226 13.5651C42.1059 12.9818 42.1892 12.232 42.1892 11.7321C42.1892 10.0658 41.356 9.06601 39.8563 9.06601ZM47.1048 17.3143C46.105 17.3143 45.7718 16.4811 45.7718 15.4813C45.7718 13.8983 46.6049 11.2322 48.1046 11.2322C49.1044 11.2322 49.4377 12.0654 49.4377 12.8985C49.4377 14.7315 48.6045 17.3143 47.1048 17.3143ZM48.5212 9.06601C45.1052 9.06601 42.8557 12.1487 42.8557 15.5646C42.8557 17.7308 44.1888 19.4805 46.7716 19.4805C50.1042 19.4805 52.3537 16.4811 52.3537 12.9818C52.3537 10.9823 51.1873 9.06601 48.5212 9.06601ZM56.8528 17.3976C56.1029 17.3976 55.6863 16.981 55.6863 16.981L56.1862 14.3149C56.5195 12.5653 57.436 11.3988 58.4358 11.3988C59.2689 11.3988 59.6022 12.232 59.6022 12.9818C59.5189 14.8148 58.4358 17.3976 56.8528 17.3976ZM59.6022 9.06601C57.6859 9.06601 56.6028 10.7323 56.6028 10.7323L56.7695 9.23264H54.27C54.1867 10.2324 53.9367 11.8154 53.6868 12.9818L51.6872 23.313H54.5199L55.2698 19.1472H55.3531C55.3531 19.1472 55.9363 19.4805 57.0194 19.4805C60.352 19.4805 62.5182 16.0645 62.5182 12.6486C62.5182 10.8156 61.6851 9.06601 59.6022 9.06601ZM66.5174 5.06685C65.6009 5.06685 64.9344 5.81669 64.9344 6.73316C64.9344 7.56632 65.4343 8.14953 66.2675 8.14953C67.1839 8.14953 67.9338 7.56632 67.9338 6.48322C67.9338 5.65006 67.3506 5.06685 66.5174 5.06685ZM62.6016 19.3138H65.4343L67.3506 9.31595H64.5178L62.6016 19.3138ZM74.5157 9.31595H72.5161L72.5995 8.81606C72.7661 7.81627 73.3493 6.98311 74.2658 6.98311C74.7657 6.98311 75.1822 7.14974 75.1822 7.14974L75.7655 4.90022C75.7655 4.90022 75.2656 4.65027 74.2658 4.65027C73.266 4.65027 72.2662 4.90022 71.5163 5.56674C70.5166 6.3999 70.1 7.56632 69.85 8.73274L69.7667 9.23264H68.4337L68.0171 11.3988H69.3501L67.8505 19.3138H70.6832L72.1829 11.3988H74.0991L74.5157 9.31595ZM81.2643 9.31595C81.2643 9.31595 79.5147 13.7317 78.6815 16.1478C78.5982 15.398 78.015 9.31595 78.015 9.31595H75.0156L76.6819 18.4807C76.6819 18.6473 76.6819 18.814 76.5986 18.9806C76.2654 19.6471 75.6821 20.2303 75.0989 20.6469C74.599 21.0635 73.9325 21.3134 73.5159 21.4801L74.2658 23.8962C74.849 23.8129 76.0154 23.313 77.0152 22.3965C78.2649 21.2301 79.5147 19.3138 80.6811 16.8144L84.097 9.39927L81.2643 9.31595Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_9632_116870">
<rect
width="83.3158"
height="23.8283"
fill="currentColor"
transform="translate(0.949219 0.585938)"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -1,29 +0,0 @@
export function SvelteKitLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 184 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_5341_22451)">
<path
d="M153.928 25.3304L149.002 16.1776L145.901 19.929V25.3304H143.3V7.82486H145.901V16.3027L152.653 7.82486H155.654L150.753 13.977L156.98 25.3304H153.928ZM162.606 25.3304V7.82486H165.206V25.3304H162.606ZM178.761 10.3257V25.3304H176.16V10.3257H171.208V7.82486H183.712V10.3257H178.761Z"
fill="#8D8D93"
/>
<path
d="M43.8554 25.6303C42.4293 25.6662 41.0258 25.2695 39.8294 24.4926C38.7117 23.7554 37.8816 22.6561 37.4785 21.3792L39.9292 20.4789C40.2691 21.2628 40.8241 21.9343 41.53 22.4157C42.2466 22.8959 43.0933 23.1447 43.9558 23.1285C44.7848 23.1719 45.603 22.9242 46.2688 22.4283C46.5585 22.1851 46.7873 21.8776 46.9371 21.5302C47.0869 21.1829 47.1535 20.8054 47.1316 20.4278C47.1349 20.076 47.0534 19.7285 46.8939 19.4148C46.7552 19.1342 46.5738 18.8768 46.3563 18.6518C46.0811 18.4012 45.7691 18.1946 45.431 18.0391C45.0139 17.8311 44.6679 17.6727 44.393 17.5639C44.1181 17.4552 43.7138 17.3093 43.1801 17.1264C42.5134 16.893 42.0133 16.7097 41.68 16.5764C41.2284 16.3842 40.7898 16.1628 40.367 15.9135C39.912 15.6721 39.4983 15.3598 39.1415 14.9882C38.8329 14.6257 38.5839 14.2162 38.4041 13.7754C38.0584 12.9348 37.9858 12.0069 38.1964 11.1229C38.4071 10.2388 38.8904 9.44335 39.5779 8.84903C40.5782 7.96552 41.9368 7.52366 43.6538 7.52347C45.0876 7.52347 46.2672 7.84021 47.1926 8.47369C48.0908 9.07097 48.7503 9.96521 49.0555 10.9998L46.655 11.7998C46.4138 11.258 46.0065 10.807 45.492 10.5121C44.8698 10.1667 44.1651 9.99797 43.4539 10.024C42.7565 9.98411 42.0657 10.1777 41.4907 10.5743C41.2559 10.7563 41.0685 10.9924 40.9445 11.2623C40.8204 11.5323 40.7634 11.8282 40.7782 12.1249C40.7802 12.3524 40.8303 12.5769 40.9251 12.7837C41.0198 12.9906 41.1572 13.1751 41.3281 13.3252C41.6604 13.6524 42.0504 13.9153 42.4785 14.1005C42.8791 14.2672 43.4878 14.4922 44.3044 14.7755C44.8041 14.9592 45.1749 15.0967 45.4169 15.1881C45.6589 15.2794 46.0132 15.4336 46.4798 15.6506C46.8539 15.816 47.2136 16.0123 47.555 16.2375C47.8608 16.4574 48.1532 16.6954 48.4304 16.9503C48.732 17.2117 48.9896 17.5199 49.1934 17.863C49.3877 18.2188 49.5387 18.5965 49.6433 18.9881C49.771 19.4482 49.8342 19.9239 49.8311 20.4014C49.8311 22.0354 49.2726 23.3149 48.1555 24.2399C47.0384 25.1649 45.605 25.6283 43.8554 25.6303ZM59.8851 25.3304L53.8832 7.82485H56.6843L60.6604 20.0287C60.8801 20.6851 61.0638 21.3531 61.2107 22.0295C61.357 21.3529 61.5407 20.685 61.7609 20.0287L65.6868 7.82485H68.4627L62.4858 25.3304H59.8851ZM73.764 25.3304V7.82485H84.6174V10.2758H76.365V15.0772H81.6916V17.5281H76.365V22.8794H85.1688V25.3304H73.764ZM91.7943 25.3304V7.82485H94.3955V22.8295H102.948V25.3304H91.7943ZM112.75 10.3257V25.3304H110.149V10.3257H105.198V7.82485H117.702V10.3257H112.75ZM123.078 25.3304V7.82485H133.931V10.2758H125.679V15.0772H131.006V17.5281H125.679V22.8794H134.483V25.3304H123.078Z"
fill="#4A4A55"
/>
<path
d="M25.615 4.49554C22.563 0.127376 16.5351 -1.16825 12.1769 1.60935L4.52282 6.48785C3.48875 7.13833 2.6018 7.99743 1.91867 9.01023C1.23553 10.023 0.771258 11.1672 0.555527 12.3697C0.190411 14.395 0.51127 16.4843 1.46731 18.3067C0.812002 19.3005 0.365144 20.417 0.153779 21.5885C-0.0643107 22.8151 -0.0352588 24.073 0.239231 25.2882C0.51372 26.5035 1.02812 27.6517 1.75226 28.6656C4.80425 33.0343 10.8325 34.3294 15.1901 31.5518L22.8445 26.6733C23.8786 26.0228 24.7655 25.1637 25.4487 24.1509C26.1318 23.1381 26.5961 21.9939 26.8118 20.7914C27.1767 18.766 26.856 16.6768 25.9003 14.8541C26.5553 13.8603 27.0019 12.744 27.2132 11.5726C27.4315 10.346 27.4025 9.08811 27.128 7.87282C26.8535 6.65752 26.3393 5.5093 25.615 4.49554Z"
fill="#FF3E00"
/>
<path
d="M11.4291 29.0767C10.2246 29.3898 8.95296 29.3257 7.78598 28.8932C6.619 28.4606 5.61287 27.6803 4.90345 26.6577C4.46805 26.0481 4.15874 25.3578 3.99365 24.6271C3.82857 23.8964 3.81103 23.1401 3.94207 22.4025C3.98642 22.1604 4.04751 21.9216 4.1249 21.6879L4.26898 21.2478L4.66134 21.5356C5.56664 22.2013 6.57896 22.7074 7.65464 23.0323L7.93901 23.1186L7.91289 23.4026C7.87805 23.8066 7.98742 24.2097 8.22161 24.5407C8.43522 24.849 8.73834 25.0842 9.08998 25.2146C9.44162 25.345 9.82483 25.3644 10.1878 25.2699C10.3539 25.2258 10.5122 25.1565 10.6573 25.0645L18.312 20.1845C18.4992 20.0666 18.6599 19.9109 18.7837 19.7275C18.9075 19.544 18.9917 19.3368 19.0309 19.119C19.0702 18.8966 19.0648 18.6687 19.015 18.4485C18.9652 18.2282 18.8721 18.0201 18.741 17.8363C18.5274 17.528 18.2243 17.2928 17.8727 17.1622C17.521 17.0317 17.1379 17.0122 16.7748 17.1064C16.6087 17.1505 16.4504 17.2198 16.3053 17.3119L13.3842 19.1739C12.9035 19.4793 12.379 19.7095 11.8288 19.8565C10.6243 20.1696 9.35266 20.1055 8.18568 19.6729C7.01869 19.2403 6.01256 18.4601 5.30314 17.4375C4.86762 16.8279 4.55821 16.1376 4.39307 15.4069C4.22793 14.6762 4.21039 13.9198 4.34147 13.1823C4.47158 12.4591 4.75119 11.7711 5.16242 11.1622C5.57365 10.5533 6.10744 10.0369 6.72965 9.64607L14.3834 4.76787C14.8639 4.46198 15.3885 4.23149 15.9388 4.0844C17.1433 3.77125 18.4149 3.8353 19.5819 4.26789C20.7489 4.70048 21.755 5.48077 22.4644 6.5034C22.8999 7.11296 23.2093 7.80332 23.3744 8.53401C23.5395 9.2647 23.5571 10.021 23.4261 10.7586C23.3812 11.0011 23.3197 11.2402 23.2421 11.4743L23.098 11.9145L22.706 11.6272C21.8007 10.9615 20.7884 10.4553 19.7127 10.1306L19.428 10.0443L19.4544 9.76023C19.4889 9.35635 19.3795 8.95328 19.1457 8.62219C18.9322 8.31419 18.6293 8.07913 18.278 7.94872C17.9266 7.81831 17.5437 7.79884 17.181 7.89294C17.0149 7.93707 16.8566 8.00635 16.7114 8.09836L9.05592 12.9766C8.86868 13.0944 8.70806 13.25 8.58432 13.4335C8.46058 13.6169 8.37643 13.8241 8.33723 14.0418C8.29778 14.2642 8.30305 14.4922 8.35274 14.7125C8.40242 14.9328 8.49551 15.1409 8.62659 15.3248C8.84026 15.6331 9.14336 15.8683 9.49496 15.9988C9.84656 16.1293 10.2297 16.1488 10.5928 16.0547C10.7588 16.0104 10.9171 15.9411 11.0623 15.8493L13.9828 13.9881C14.4633 13.6822 14.9878 13.4518 15.5382 13.3049C16.7424 12.9919 18.0136 13.0559 19.1803 13.4882C20.347 13.9205 21.3529 14.7004 22.0624 15.7225C22.4979 16.332 22.8073 17.0224 22.9725 17.7531C23.1376 18.4838 23.1552 19.2401 23.0241 19.9777C22.8943 20.7008 22.615 21.3889 22.2042 21.998C21.7934 22.607 21.26 23.1237 20.6382 23.515L12.9845 28.3932C12.504 28.6992 11.9795 28.9297 11.4291 29.0767Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_5341_22451">
<rect width="184" height="33.1611" fill="white" />
</clipPath>
</defs>
</svg>
);
}
@@ -1,33 +0,0 @@
export function VerizonLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width="82"
height="19"
viewBox="0 0 82 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_9632_116867)">
<path
d="M79.373 0.50708L74.9504 9.96817L73.2874 6.3833H71.4888L74.2236 12.2472H75.6773L81.1593 0.50708H79.373Z"
fill="currentColor"
/>
<path
d="M60.4008 18.0743H63.961V11.5575C63.961 10.0669 64.8234 9.01976 66.1046 9.01976C67.3365 9.01976 67.9771 9.8821 67.9771 11.1263V18.0743H71.5373V10.4488C71.5373 7.86176 69.9851 6.03853 67.472 6.03853C65.8705 6.03853 64.7495 6.7284 63.9118 7.98495H63.8379V6.38347H60.3885L60.4008 18.0743ZM53.4036 6.06317C49.6832 6.06317 47.1947 8.73642 47.1947 12.2597C47.1947 15.7583 49.6832 18.4562 53.4036 18.4562C57.1239 18.4562 59.6124 15.7583 59.6124 12.2597C59.6247 8.73642 57.1239 6.06317 53.4036 6.06317ZM53.3789 15.8199C51.6912 15.8199 50.7919 14.4032 50.7919 12.2597C50.7919 10.0915 51.6789 8.69946 53.3789 8.69946C55.0666 8.69946 56.0029 10.0915 56.0029 12.2597C56.0152 14.4032 55.079 15.8199 53.3789 15.8199ZM37.0438 18.0743H47.0469V15.2656H41.3801V15.1916L46.8005 9.11831V6.37115H37.0438V9.19223H42.5258V9.26614L37.0438 15.3887V18.0743ZM32.4734 18.0743H36.0582V6.38347H32.4734V18.0743ZM24.158 18.0743H27.7305V12.7032C27.7305 10.264 29.1965 9.17991 31.4262 9.48789H31.5002V6.40811C31.3154 6.33419 31.1059 6.32187 30.7733 6.32187C29.3813 6.32187 28.445 6.96247 27.6443 8.32989H27.5704V6.38347H24.158V18.0743ZM17.6042 15.8322C16.0273 15.8322 15.0664 14.8097 14.8817 13.2329H23.3079C23.3326 10.7198 22.6673 8.71178 21.2753 7.45523C20.2898 6.54362 18.9962 6.03853 17.3455 6.03853C13.8222 6.03853 11.383 8.71178 11.383 12.2104C11.383 15.7337 13.7113 18.4193 17.5795 18.4193C19.0455 18.4193 20.2035 18.025 21.1644 17.3721C22.1869 16.6699 22.9261 15.6351 23.1232 14.6126H19.6984C19.3658 15.3764 18.6513 15.8322 17.6042 15.8322ZM17.4194 8.58859C18.676 8.58859 19.5383 9.52484 19.6861 10.8923H14.9063C15.1896 9.51253 15.9411 8.58859 17.4194 8.58859ZM4.26258 18.0743H8.00759L12.0729 6.38347H8.48804L6.209 13.8981H6.15972L3.88068 6.38347H0.197266L4.26258 18.0743ZM32.4734 2.2196H36.0582V5.39794H32.4734V2.2196Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_9632_116867">
<rect
width="81.0845"
height="17.9859"
fill="white"
transform="translate(0.074707 0.50708)"
/>
</clipPath>
</defs>
</svg>
);
}
+43 -45
View File
@@ -1,3 +1,9 @@
/**
* Mostly superseded by the dashboard agent (`components/dashboard-agent`), which owns every
* entry point except two: ⌘I and the CLI's `?aiHelp=` link still open Ask AI. `AskAIRoot` is
* mounted by the `_app` layout for those; the `AskAI` button below is mounted nowhere.
*/
import {
ArrowPathIcon,
ArrowUpIcon,
@@ -11,13 +17,17 @@ import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader } from "~/root";
import {
ASK_AI_DEEP_LINK_PARAM,
ASK_AI_SHORTCUT,
askAiCanOpen,
} from "./dashboard-agent/ask-ai-channels";
import { useAskAiHost } from "./dashboard-agent/askAiOpenRequest";
import { Button } from "./primitives/Buttons";
import { Callout } from "./primitives/Callout";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog";
@@ -34,11 +44,6 @@ import {
} from "./primitives/Tooltip";
import { ClientOnly } from "remix-utils/client-only";
function useKapaWebsiteId() {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.kapa.websiteId;
}
/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */
function useAskAIState() {
const [isOpen, setIsOpen] = useState(false);
@@ -61,59 +66,46 @@ function useAskAIState() {
// Handle URL param functionality
useEffect(() => {
const aiHelp = searchParams.get("aiHelp");
const aiHelp = searchParams.get(ASK_AI_DEEP_LINK_PARAM);
if (aiHelp) {
// Delay to avoid hCaptcha bot detection
window.setTimeout(() => openAskAI(aiHelp), 1000);
// Clone instead of mutating in place
const next = new URLSearchParams(searchParams);
next.delete("aiHelp");
next.delete(ASK_AI_DEEP_LINK_PARAM);
setSearchParams(next);
}
}, [searchParams, openAskAI]);
}, [searchParams, setSearchParams, openAskAI]);
return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI };
}
/**
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap
* it around the popover, not inside, so the dialog and shortcut survive the popover closing.
* `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no
* Kapa website id, or SSR).
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog). It renders no page content and wraps
* nothing: entry points reach it through `requestAskAi`, so the Kapa provider mounting after
* hydration can never remount the app around it.
*/
export function AskAIRoot({
children,
}: {
children: (openAskAI: (() => void) | undefined) => ReactNode;
}) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
export function AskAIRoot() {
const availability = useAskAiAvailability();
if (!isManagedCloud || !websiteId) {
return <>{children(undefined)}</>;
if (!askAiCanOpen(availability)) {
return null;
}
return (
<ClientOnly fallback={<>{children(undefined)}</>}>
{() => <AskAIRootProvider websiteId={websiteId}>{children}</AskAIRootProvider>}
</ClientOnly>
);
const websiteId = availability.kapaWebsiteId!;
return <ClientOnly>{() => <AskAIRootProvider websiteId={websiteId} />}</ClientOnly>;
}
function AskAIRootProvider({
websiteId,
children,
}: {
websiteId: string;
children: (openAskAI: () => void) => ReactNode;
}) {
function AskAIRootProvider({ websiteId }: { websiteId: string }) {
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true },
shortcut: ASK_AI_SHORTCUT,
action: () => openAskAI(),
});
useAskAiHost(openAskAI);
return (
<KapaProvider
@@ -126,7 +118,6 @@ function AskAIRootProvider({
}}
botProtectionMechanism="hcaptcha"
>
{children(() => openAskAI())}
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
@@ -137,14 +128,16 @@ function AskAIRootProvider({
);
}
/** @deprecated Mounted nowhere: the sidebar's AI entry point is the dashboard agent. */
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
const availability = useAskAiAvailability();
if (!isManagedCloud || !websiteId) {
if (!askAiCanOpen(availability)) {
return null;
}
const websiteId = availability.kapaWebsiteId!;
return (
<ClientOnly
fallback={
@@ -280,6 +273,7 @@ function ChatMessages({
// Reset feedback state when conversation is reset
useEffect(() => {
if (conversation.length === 0) {
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
setFeedbackGivenForQAs(new Set());
}
}, [conversation.length]);
@@ -458,7 +452,7 @@ function ChatMessages({
<div className="flex flex-col">
<Callout variant="error" className="mb-4">
<Paragraph className="font-semibold text-error">Error generating answer:</Paragraph>
<Paragraph className="text-rose-300">
<Paragraph className="text-rose-500 dark:text-rose-300">
{error} If the problem persists after retrying, please contact support.
</Paragraph>
</Callout>
@@ -550,8 +544,12 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) {
/>
{isGeneratingAnswer ? (
<SimpleTooltip
asChild
tabbable
button={
<span
<button
type="button"
aria-label="Stop generating"
onClick={() => stopGeneration()}
className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center"
>
@@ -560,7 +558,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) {
className="absolute inset-0 animate-spin"
hoverEffect
/>
</span>
</button>
}
content="Stop generating"
/>
@@ -1,42 +1,74 @@
import { type ReactNode } from "react";
import blurredDashboardBackgroundMenuTop from "~/assets/images/blurred-dashboard-background-menu-top.jpg";
import blurredDashboardBackgroundMenuTopLight from "~/assets/images/blurred-dashboard-background-menu-top-light.jpg";
import blurredDashboardBackgroundMenuBottom from "~/assets/images/blurred-dashboard-background-menu-bottom.jpg";
import blurredDashboardBackgroundMenuBottomLight from "~/assets/images/blurred-dashboard-background-menu-bottom-light.jpg";
import blurredDashboardBackgroundTable from "~/assets/images/blurred-dashboard-background-table.jpg";
import blurredDashboardBackgroundTableLight from "~/assets/images/blurred-dashboard-background-table-light.jpg";
/* Blurred dashboard screenshots; the -light set is the same artwork with the
lightness inverted for the light theme. */
const BACKDROPS = [
{
images: {
menuTop: blurredDashboardBackgroundMenuTop,
menuBottom: blurredDashboardBackgroundMenuBottom,
table: blurredDashboardBackgroundTable,
},
tableFill: "#101214",
/* `light` is a zero-specificity variant, so `light:lg:hidden` ties with
`lg:block` - the `!` guarantees the dark backdrop loses on Light. */
className: "hidden lg:block light:lg:hidden!",
},
{
images: {
menuTop: blurredDashboardBackgroundMenuTopLight,
menuBottom: blurredDashboardBackgroundMenuBottomLight,
table: blurredDashboardBackgroundTableLight,
},
tableFill: "#f4f5f7",
className: "hidden light:lg:block",
},
];
export function BackgroundWrapper({ children }: { children: ReactNode }) {
return (
<div className="relative h-full w-full overflow-hidden bg-background-dimmed lg:bg-transparent">
<div
className="absolute left-0 top-0 hidden w-[260px] bg-contain bg-top-left bg-no-repeat lg:block"
style={{
backgroundImage: `url(${blurredDashboardBackgroundMenuTop})`,
aspectRatio: "auto",
height: "100vh",
backgroundSize: "260px auto",
}}
/>
{BACKDROPS.map(({ images, tableFill, className }) => (
<div key={tableFill} className={className}>
<div
className="absolute left-0 top-0 w-[260px] bg-contain bg-top-left bg-no-repeat"
style={{
backgroundImage: `url(${images.menuTop})`,
aspectRatio: "auto",
height: "100vh",
backgroundSize: "260px auto",
}}
/>
<div
className="absolute bottom-0 left-0 hidden w-[260px] bg-contain bg-bottom-left bg-no-repeat lg:block"
style={{
backgroundImage: `url(${blurredDashboardBackgroundMenuBottom})`,
aspectRatio: "auto",
height: "100vh",
backgroundSize: "260px auto",
}}
/>
<div
className="absolute bottom-0 left-0 w-[260px] bg-contain bg-bottom-left bg-no-repeat"
style={{
backgroundImage: `url(${images.menuBottom})`,
aspectRatio: "auto",
height: "100vh",
backgroundSize: "260px auto",
}}
/>
<div
className="absolute top-0 hidden bg-top-left bg-no-repeat lg:block"
style={{
left: "260px",
backgroundImage: `url(${blurredDashboardBackgroundTable})`,
width: "100%",
height: "100vh",
backgroundSize: "1200px auto",
backgroundColor: "#101214",
}}
/>
<div
className="absolute top-0 bg-top-left bg-no-repeat"
style={{
left: "260px",
backgroundImage: `url(${images.table})`,
width: "100%",
height: "100vh",
backgroundSize: "1200px auto",
backgroundColor: tableFill,
}}
/>
</div>
))}
<div className="relative z-10 h-full w-full">{children}</div>
</div>
+144 -183
View File
@@ -3,15 +3,13 @@ import {
BellAlertIcon,
BookOpenIcon,
ChatBubbleLeftRightIcon,
ClockIcon,
PlusIcon,
QuestionMarkCircleIcon,
RectangleGroupIcon,
RectangleStackIcon,
SparklesIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { useLocation } from "react-use";
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
import { AIPenIcon } from "~/assets/icons/AIPenIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png";
@@ -31,10 +29,10 @@ import {
v3CreateBulkActionPath,
v3EnvironmentPath,
v3NewProjectAlertPath,
v3NewSchedulePath,
} from "~/utils/pathBuilder";
import { AskAI } from "./AskAI";
import { AskAgentButton } from "./dashboard-agent/AskAgentButton";
import { CodeBlock } from "./code/CodeBlock";
import { useDevPresence } from "./DevPresence";
import { InlineCode } from "./code/InlineCode";
import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel";
import { Feedback } from "./Feedback";
@@ -53,20 +51,73 @@ import { StepNumber } from "./primitives/StepNumber";
import { TextLink } from "./primitives/TextLink";
import { SimpleTooltip } from "./primitives/Tooltip";
import {
InitAgentPromptV3,
InitCommandV3,
PackageManagerProvider,
TriggerDeployStep,
TriggerDevStepV3,
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
export function HasNoTasksDev() {
/**
* What the agent is asked when it's opened from a deployment setup panel. The panel is the docs
* answer; the agent is for the part the docs can't answer — this project, this environment.
*/
const ASK_AGENT_DEPLOY_PROMPT =
"I'm trying to deploy my tasks to this environment. Walk me through it and tell me if anything about this project or environment is going to get in the way.";
/** The docs links the deployment panels offer to anyone without the agent. */
function DeployDocsLinks() {
return (
<>
<SimpleTooltip
asChild
tabbable
button={
// Span wrapper: LinkButton drops the pointer-event props Radix injects via asChild, so
// the tooltip trigger has to be a plain element (same pattern as FavoritePageButton).
<span className="flex">
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
aria-label="Deploy docs"
/>
</span>
}
content="Deploy docs"
/>
<SimpleTooltip
asChild
tabbable
button={
<span className="flex">
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
aria-label="Troubleshooting docs"
/>
</span>
}
content="Troubleshooting docs"
/>
</>
);
}
export function HasNoTasksDev({ initializedAt }: { initializedAt: Date | string | null }) {
const { isConnected } = useDevPresence();
const initialized = !!initializedAt;
const devConnected = isConnected === true;
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<Header1 spacing>Get setup in 3 minutes</Header1>
<Header1 spacing>Get set up in 2 minutes</Header1>
<div className="flex items-center gap-2">
<Feedback
button={
@@ -78,22 +129,75 @@ export function HasNoTasksDev() {
/>
</div>
</div>
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
{!initialized && (
<>
<div className="flex flex-col gap-4 rounded-md border border-indigo-400/20 bg-indigo-800/10 p-4 sm:flex-row sm:items-center">
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-indigo-500/15 text-indigo-400">
<SparklesIcon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<Paragraph className="text-text-bright">Set it up with your AI agent</Paragraph>
<Paragraph variant="small" className="text-text-dimmed">
Copy a ready-to-paste prompt for Claude Code, Cursor, or any coding agent. It
includes your project reference.
</Paragraph>
</div>
<div className="shrink-0">
<InitAgentPromptV3 />
</div>
</div>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-grid-bright" />
<span className="text-xs uppercase tracking-wide text-text-dimmed">
or set it up yourself
</span>
<div className="h-px flex-1 bg-grid-bright" />
</div>
</>
)}
<StepNumber
stepNumber="1"
title={initialized ? "Project initialized" : "Initialize your project"}
complete={initialized}
/>
<StepContentContainer>
<InitCommandV3 />
<Paragraph spacing>
You'll notice a new folder in your project called{" "}
<InlineCode variant="small">trigger</InlineCode>. We've added a few simple example tasks
in there to help you get started.
</Paragraph>
{initialized ? (
<Paragraph>
Your project is initialized. Your tasks live in the{" "}
<InlineCode variant="small">trigger</InlineCode> directory.
</Paragraph>
) : (
<>
<InitCommandV3 />
<Paragraph spacing>
Run this in an existing project. You'll notice a new folder called{" "}
<InlineCode variant="small">trigger</InlineCode> with a few example tasks to help
you get started.
</Paragraph>
</>
)}
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
<StepNumber
stepNumber="2"
title={devConnected ? "Dev server connected" : "Start the dev server"}
complete={devConnected}
displaySpinner={!devConnected}
/>
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
{devConnected ? (
<Paragraph>
Your dev server is connected. Your tasks will appear here automatically as soon as
they register.
</Paragraph>
) : (
<>
<TriggerDevStepV3 />
<Paragraph spacing>
Keep this running while you develop. Once your tasks register, this page updates
automatically.
</Paragraph>
</>
)}
</StepContentContainer>
</div>
</PackageManagerProvider>
@@ -104,71 +208,6 @@ export function HasNoTasksDeployed({ environment }: { environment: MinimumEnviro
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
return (
<InfoPanel
title="Create your first scheduled task"
icon={ClockIcon}
iconClassName="text-schedules"
panelClassName="max-w-full"
accessory={
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
How to schedule tasks
</LinkButton>
}
>
<Paragraph spacing variant="small">
You have no scheduled tasks in your project. Before you can schedule a task you need to
create a <InlineCode>schedules.task</InlineCode>.
</Paragraph>
</InfoPanel>
);
}
export function SchedulesNoneAttached() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const location = useLocation();
return (
<InfoPanel
title="Attach your first schedule"
icon={ClockIcon}
iconClassName="text-schedules"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
this in the dashboard or using the SDK.
</Paragraph>
<div className="flex gap-2">
<LinkButton
to={`${v3NewSchedulePath(organization, project, environment)}${location.search}`}
variant="secondary/medium"
LeadingIcon={RectangleGroupIcon}
className="inline-flex"
leadingIconClassName="text-blue-500"
>
Use the dashboard
</LinkButton>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Use the SDK
</LinkButton>
</div>
</InfoPanel>
);
}
export function BatchesNone() {
return (
<InfoPanel
@@ -269,29 +308,10 @@ export function DeploymentsNoneDev() {
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
{/* One entry point instead of two: the docs links were a guess at which page you
needed, and the agent can look at this project and answer for it. Someone with no
agent still gets the links. */}
<AskAgentButton prompt={ASK_AGENT_DEPLOY_PROMPT} fallback={<DeployDocsLinks />} />
</div>
</div>
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
@@ -384,36 +404,6 @@ export function AlertsNoneDeployed() {
);
}
export function QueuesHasNoTasks() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
title="You don't have any queues"
icon={RectangleStackIcon}
iconClassName="text-queues"
panelClassName="max-w-md"
accessory={
<LinkButton
to={v3EnvironmentPath(organization, project, environment)}
variant="primary/small"
>
Create a task
</LinkButton>
}
>
<Paragraph spacing variant="small">
Queues will appear here when you have created a task in this environment. Follow the
instructions on the{" "}
<TextLink to={v3EnvironmentPath(organization, project, environment)}>Tasks page</TextLink>{" "}
to create a task, then return here to see its queue.
</Paragraph>
</InfoPanel>
);
}
export function NoWaitpointTokens() {
return (
<InfoPanel
@@ -476,14 +466,10 @@ export function BranchesNoBranchableEnvironment({ showSelfServe }: { showSelfSer
)
}
>
<Paragraph spacing variant="small">
<Paragraph variant="small">
Preview branches in Trigger.dev create isolated environments for testing new features before
production.
</Paragraph>
<Paragraph variant="small">
You must be on <V4Badge inline /> to access preview branches. Read our{" "}
<TextLink to={docsPath("upgrade-to-v4")}>upgrade to v4 guide</TextLink> to learn more.
</Paragraph>
</InfoPanel>
);
}
@@ -557,19 +543,15 @@ export function BranchesNoBranches({
/>
}
>
<Paragraph spacing variant="small">
<Paragraph variant="small">
Branches are a way to test new features in isolation before merging them into the main
environment.
</Paragraph>
<Paragraph spacing variant="small">
Branches are only available when using <V4Badge inline /> or above. Read our{" "}
<TextLink to={docsPath("upgrade-to-v4")}>v4 upgrade guide</TextLink> to learn more.
</Paragraph>
</InfoPanel>
);
}
export function SwitcherPanel({ title = "Switch to a deployed environment" }: { title?: string }) {
function SwitcherPanel({ title = "Switch to a deployed environment" }: { title?: string }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -657,29 +639,10 @@ function DeploymentOnboardingSteps() {
</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
{/* One entry point instead of two: the docs links were a guess at which page you
needed, and the agent can look at this project and answer for it. Someone with no
agent still gets the links. */}
<AskAgentButton prompt={ASK_AGENT_DEPLOY_PROMPT} fallback={<DeployDocsLinks />} />
</div>
</div>
<ClientTabs defaultValue="github">
@@ -701,14 +664,12 @@ function DeploymentOnboardingSteps() {
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
@@ -744,7 +705,7 @@ export function PromptsNone() {
return (
<InfoPanel
title="Define your first prompt"
icon={AIChatIcon}
icon={AIPenIcon}
iconClassName="text-aiPrompts"
panelClassName="max-w-lg"
accessory={
@@ -9,7 +9,7 @@ import { Paragraph } from "./primitives/Paragraph";
import simplur from "simplur";
import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v3/SharedFilters";
import { formatNumber } from "~/utils/numberFormatter";
import { SpinnerWhite } from "./primitives/Spinner";
import { Spinner } from "./primitives/Spinner";
import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline";
import assertNever from "assert-never";
@@ -306,5 +306,5 @@ export function EstimatedCount({ count }: { count?: number }) {
return <>~{formatNumber(count)}</>;
}
return <SpinnerWhite className="mx-0.5 -mt-0.5 inline size-3" />;
return <Spinner color="blue" className="mx-0.5 -mt-0.5 inline size-3" />;
}
+8 -2
View File
@@ -15,6 +15,7 @@ import disconnectedImage from "../assets/images/cli-disconnected.png";
import { InlineCode } from "./code/InlineCode";
import { Button } from "./primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
import { cn } from "~/utils/cn";
import { Paragraph } from "./primitives/Paragraph";
import { TextLink } from "./primitives/TextLink";
import { PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
@@ -54,6 +55,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro
useEffect(() => {
// If disabled or no events
if (!enabled || streamedEvents === null) {
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
setIsConnected(undefined);
return;
}
@@ -79,7 +81,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro
// Calculate isConnected and memoize the context value
const contextValue = useMemo(() => {
return { isConnected };
}, [isConnected, enabled]);
}, [isConnected]);
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
}
@@ -112,6 +114,7 @@ export function useCrossEngineIsConnected({
useEffect(() => {
if (project.engine === "V2") {
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
setCrossEngineIsConnected(isConnected);
return;
}
@@ -165,7 +168,10 @@ export function DevPresencePanel({ isConnected }: { isConnected: boolean | undef
width={282}
height={45}
/>
<Paragraph variant="small" className={isConnected ? "text-success" : "text-error"}>
<Paragraph
variant="small"
className={cn("system-mono-label", isConnected ? "text-success" : "text-error")}
>
{isConnected === undefined
? "Checking connection..."
: isConnected
+15 -19
View File
@@ -34,22 +34,16 @@ export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
);
}
return (
<>
{isRouteErrorResponse(error) ? (
<ErrorDisplay
title={friendlyErrorDisplay(error.status, error.statusText).title}
message={
error.data.message ?? friendlyErrorDisplay(error.status, error.statusText).message
}
{...options}
/>
) : error instanceof Error ? (
<ErrorDisplay title={error.name} message={error.message} {...options} />
) : (
<ErrorDisplay title="Oops" message={JSON.stringify(error)} {...options} />
)}
</>
return isRouteErrorResponse(error) ? (
<ErrorDisplay
title={friendlyErrorDisplay(error.status, error.statusText).title}
message={error.data.message ?? friendlyErrorDisplay(error.status, error.statusText).message}
{...options}
/>
) : error instanceof Error ? (
<ErrorDisplay title={error.name} message={error.message} {...options} />
) : (
<ErrorDisplay title="Oops" message={JSON.stringify(error)} {...options} />
);
}
@@ -58,12 +52,14 @@ type DisplayOptionsProps = {
message?: ReactNode;
} & ErrorDisplayOptions;
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
return (
// The backdrop stays dark in every theme (the rotating-logo animation is
// dark artwork), so the text pins to the dark-theme colors on light too.
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-[#16181C]">
<div className="z-10 mt-[30vh] flex shrink-0 flex-col items-center gap-8">
<Header1>{title}</Header1>
{message && <Paragraph>{message}</Paragraph>}
<Header1 className="light:text-charcoal-200">{title}</Header1>
{message && <Paragraph className="light:text-charcoal-400">{message}</Paragraph>}
<LinkButton
to={button ? button.to : "/"}
shortcut={{ key: "enter" }}
+21 -21
View File
@@ -12,7 +12,14 @@ export function AlphaBadge({
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
<Badge
variant="extra-small"
className={cn(
"system:border-transparent system:bg-blue-500 system:text-white",
inline ? "inline-grid" : "",
className
)}
>
Alpha
</Badge>
}
@@ -22,20 +29,18 @@ export function AlphaBadge({
);
}
export function AlphaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<AlphaBadge />
</>
);
}
export function BetaBadge({ inline = false, className }: { inline?: boolean; className?: string }) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
<Badge
variant="extra-small"
className={cn(
"system:border-transparent system:bg-blue-500 system:text-white",
inline ? "inline-grid" : "",
className
)}
>
Beta
</Badge>
}
@@ -45,20 +50,15 @@ export function BetaBadge({ inline = false, className }: { inline?: boolean; cla
);
}
export function BetaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<BetaBadge />
</>
);
}
export function NewBadge({ inline = false, className }: { inline?: boolean; className?: string }) {
return (
<Badge
variant="extra-small"
className={cn("text-success", inline ? "inline-grid" : "", className)}
className={cn(
"text-success system:border-transparent system:bg-success system:text-white",
inline ? "inline-grid" : "",
className
)}
>
New
</Badge>
+34 -8
View File
@@ -9,7 +9,7 @@ import { parseWithZod } from "@conform-to/zod";
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
import { EnvelopeIcon, ShieldCheckIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type FeedbackType, feedbackTypes, schema } from "~/routes/resources.feedback";
import { Button } from "./primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
@@ -27,13 +27,26 @@ import { TextLink } from "./primitives/TextLink";
import { DialogClose } from "@radix-ui/react-dialog";
type FeedbackProps = {
button: ReactNode;
button?: ReactNode;
defaultValue?: FeedbackType;
onOpenChange?: (open: boolean) => void;
};
} &
// Controlled mode is all-or-none: pass both open + setOpen to host the dialog outside a popover
// (so the popover closing can't unmount the form mid-submit and cancel the feedback POST), or
// neither for the self-managed, button-triggered dialog. Passing only one is a broken half-state.
({ open?: never; setOpen?: never } | { open: boolean; setOpen: (open: boolean) => void });
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
const [open, setOpen] = useState(false);
export function Feedback({
button,
defaultValue = "bug",
onOpenChange,
open: openProp,
setOpen: setOpenProp,
}: FeedbackProps) {
const [openState, setOpenState] = useState(false);
// Controlled when the caller passes open/setOpen (hosted outside a popover); otherwise self-managed.
const open = openProp ?? openState;
const setOpen = setOpenProp ?? setOpenState;
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const lastSubmission = useActionData();
@@ -57,12 +70,13 @@ export function Feedback({ button, defaultValue = "bug", onOpenChange }: Feedbac
) {
setOpen(false);
}
}, [navigation.formAction, navigation.state, form.allErrors]);
}, [navigation.formAction, navigation.state, form.allErrors, setOpen]);
// Handle URL param functionality
useEffect(() => {
const open = searchParams.get("feedbackPanel");
if (open) {
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
setType(open as FeedbackType);
setOpen(true);
// Clone instead of mutating in place
@@ -70,7 +84,19 @@ export function Feedback({ button, defaultValue = "bug", onOpenChange }: Feedbac
next.delete("feedbackPanel");
setSearchParams(next);
}
}, [searchParams]);
}, [searchParams, setOpen, setSearchParams]);
// Reset the topic to the default once the dialog closes, so reopening always starts fresh. The
// dialog is now persistently mounted (hosted outside the popover), so without this it would keep
// the previously chosen topic selected and risk filing feedback under the wrong category. Keyed
// on the close transition (not just `!open`) so the ?feedbackPanel= open path isn't clobbered.
const wasOpen = useRef(open);
useEffect(() => {
if (wasOpen.current && !open) {
setType(defaultValue);
}
wasOpen.current = open;
}, [open, defaultValue]);
const handleOpenChange = (value: boolean) => {
setOpen(value);
@@ -79,7 +105,7 @@ export function Feedback({ button, defaultValue = "bug", onOpenChange }: Feedbac
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{button}</DialogTrigger>
{button ? <DialogTrigger asChild>{button}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>Contact us</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
@@ -1,30 +1,3 @@
import { cn } from "~/utils/cn";
type GitHubLoginButtonProps = {
label?: string;
className?: string;
onClick?: () => void;
};
export function GitHubLoginButton({
label = "Continue with GitHub",
className,
onClick,
}: GitHubLoginButtonProps) {
return (
<button
className={cn(
"flex items-center justify-center gap-2 rounded-md bg-indigo-800 py-3 pl-5 pr-6 text-lg font-medium text-white transition hover:bg-indigo-700",
className
)}
onClick={onClick}
>
<OctoKitty className="h-5 w-5" />
{label}
</button>
);
}
export function OctoKitty({ className }: { className?: string }) {
return (
<svg
+3 -7
View File
@@ -14,11 +14,7 @@ export function GitMetadata({ git }: { git?: GitMetaLinks | null }) {
);
}
export function GitMetadataBranch({
git,
}: {
git: Pick<GitMetaLinks, "branchUrl" | "branchName">;
}) {
function GitMetadataBranch({ git }: { git: Pick<GitMetaLinks, "branchUrl" | "branchName"> }) {
return (
<SimpleTooltip
button={
@@ -38,7 +34,7 @@ export function GitMetadataBranch({
);
}
export function GitMetadataCommit({
function GitMetadataCommit({
git,
}: {
git: Pick<GitMetaLinks, "commitUrl" | "shortSha" | "commitMessage">;
@@ -62,7 +58,7 @@ export function GitMetadataCommit({
);
}
export function GitMetadataPullRequest({
function GitMetadataPullRequest({
git,
}: {
git: Pick<GitMetaLinks, "pullRequestUrl" | "pullRequestNumber" | "pullRequestTitle">;
@@ -1,30 +0,0 @@
import { Form } from "@remix-run/react";
import { UserCrossIcon } from "~/assets/icons/UserCrossIcon";
import { Button } from "./primitives/Buttons";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip";
export function ImpersonationBanner() {
return (
<div>
<Form action="/resources/impersonation" method="delete" reloadDocument>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
variant="small-menu-item"
LeadingIcon={UserCrossIcon}
fullWidth
textAlignLeft
className="text-amber-400"
/>
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Stop impersonating
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Form>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More