Compare commits

...

83 Commits

Author SHA1 Message Date
James Ritchie ca95a03b24 refactor(webapp): extract shared TaskRunsList for both task pages 2026-07-26 10:14:41 +01:00
James Ritchie 4861ad5ee1 fix(webapp): keep the task-page new-runs count consistent with the list 2026-07-26 09:54:14 +01:00
James Ritchie f32b0a2952 chore(webapp): format task pages with oxfmt 2026-07-26 09:45:22 +01:00
James Ritchie dc74266fcb feat(webapp): live-update the runs list on task pages 2026-07-26 09:40:03 +01:00
Matt Aitken be45cf9e61 fix(sdk): preserve partial assistant message on chat stream failure (#4348)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 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
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
## Summary

When a `chat.agent` (or `chat.createSession`) turn's model stream fails
mid-response (e.g. a transport timeout like `UND_ERR_BODY_TIMEOUT`), the
assistant output that already streamed was dropped: `onTurnComplete`
fired with `responseMessage: undefined`, and the manual loop's
`turn.complete()` rethrew without keeping the partial. Apps that
register `hydrateMessages` are hit hardest, since boot-time tail-replay
recovery is off by design.

This preserves the streamed-so-far assistant output while still
reporting the turn as errored, so persistence and recovery keep it.

## Scope of behavior change

Only the **error path** changes. Successful turns are unaffected: the
same chunks stream to the client in the same order, and
backpressure/cancel behave as before. Everything here is a correctness
improvement on a turn that hit a source-stream failure.

## What it does

Follow-up to #4304 (`chat.pipeAndCapture`), extending the same
partial-recovery to the two loops that lacked it:

- **`chat.agent`**: taps the response stream (via a `TransformStream`,
so pass-through backpressure and cancel are preserved) to buffer chunks,
and on a source-stream failure reconstructs the partial (preferring the
`onFinish` message). It's surfaced on the error-path `onTurnComplete`
(`responseMessage`, `rawResponseMessage`, `uiMessages`, `newUIMessages`,
`newMessages`) and committed to the accumulator so the next turn and the
reboot snapshot keep it.
- **`chat.createSession` / `turn.complete()`**: the reconstructed
partial is accumulated (so `turn.uiMessages` reflects it and the caller
can persist after catching) before `turn.complete()` rethrows.

`onBeforeTurnComplete` stays skipped on the error path (it hands out a
writer for a stream that has already broken).

## Correctness properties (each covered by a regression test)

Each test below was confirmed to fail without its fix:

- The recovered partial reaches `onTurnComplete` and the next turn's
accumulated messages.
- An already-committed (possibly enriched) response is not overwritten
if a post-response hook then throws.
- Incomplete tool parts are cleaned from the recovered partial (text
kept), so the UI and model views agree and the next turn isn't poisoned.
- A prior turn's model-only compaction survives an errored turn (append
only the new tail, don't reconvert the full history).
- A reconstructed fragment that reuses an existing message id does not
clobber the complete message.
- Queued `chat.response` data parts are folded into the recovered
partial, matching the success path.
- `newMessages` (model delta) stays symmetric with `newUIMessages`.

## Tests

New `chat-agent-source-stream-error.test.ts` covers the cases above. The
full `@trigger.dev/sdk` unit suite passes and the package build is green
across all supported runtimes (Node 20 to 26, Bun, Deno, Cloudflare
Workers).
2026-07-24 14:20:20 +01:00
claude[bot] 109e245d56 feat(webapp): show the first and last characters of a new PAT (#4363) 2026-07-24 12:55:31 +01:00
nicktrn bf41c5d5fc feat(supervisor): configurable warm-start dispatch url (#4362)
Adds an optional `TRIGGER_WARM_START_DISPATCH_URL`. The warm-start
dispatch request uses it when set, otherwise falls back to
`TRIGGER_WARM_START_URL`, so the dispatch target can differ from the
default warm-start URL. No behavior change when unset.
2026-07-24 12:11:55 +01:00
Eric Allam 7188eecd83 perf(webapp): clamp list-endpoint page size to 100 (#4360)
## Summary

Several list endpoints accepted an unbounded page size (`perPage` /
`per_page` / `pageSize`). An unbounded page lets one request pull an
arbitrarily large result set and do a proportional amount of work, which
is a poor default for a shared API.

This clamps the page size to 100 on every list endpoint that was
uncapped, matching the existing cap on `api.v1.runs` and
`api.v1.sessions`. Clamping rather than rejecting keeps existing clients
working: a request for a larger page returns up to 100 items and offset
pagination continues from there.

## Endpoints capped

- `api.v1.schedules` (`perPage`)
- `api.v1.queues` (`perPage`)
- `resources.…versions` (`per_page`)
- `resources.…queues` (`per_page`)
- `admin.api.v1.…engine.report` (`per_page`)
- `admin.api.v1.llm-models` (`pageSize`)

Already capped, left as-is: `api.v1.runs`, `api.v1.sessions`,
`api.v1.deployments`.
2026-07-24 11:56:58 +01:00
Eric Allam 9c85e0ecdc perf(database): index BatchTaskRun on (runtimeEnvironmentId, createdAt, id) for the batches list (#4361)
## Summary

The batches list page orders by `createdAt DESC, id DESC` filtered by
environment and a created-at window, but the only supporting index on
`BatchTaskRun` was `(runtimeEnvironmentId, id)`. That index can't
satisfy the `createdAt` ordering, so on environments with a large number
of batches the query fell back to a full table scan and in-memory sort,
which could run long enough to hit the statement timeout.

## Fix

Adds `(runtimeEnvironmentId, createdAt DESC, id DESC)` on
`BatchTaskRun`. The query now reads straight from the index in order
with no sort step, returning a page with only a handful of heap fetches
instead of scanning the whole environment slice.

The migration uses `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, so it
takes no table lock and is a no-op if the index already exists.
2026-07-24 11:52:34 +01:00
nicktrn 722e240e4d feat(supervisor): add prometheus metric for outbound http requests (#4350)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 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
🚀 Publish Trigger.dev Docker / units (push) Failing after 6s
🦋 Changesets PR / Create Release PR (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
Adds Prometheus metrics so the supervisor's outbound HTTP calls are
observable - including client-side failures that previously only
surfaced as a log line.

- `supervisor_outbound_request_total{name, method, status, outcome}` -
counts every outbound request. `outcome` separates a transport failure
(`network_error`), an HTTP error response (`http_error`), a response
that failed schema validation (`invalid_response`), and success (`ok`).
- `supervisor_outbound_request_duration_seconds{name, outcome}` -
latency histogram. Leaner labels than the counter (no `status`) to avoid
bucket×label cardinality; buckets match the existing dequeue-latency
histogram since these calls share the same retrying HTTP client and
long-poll envelope.

Coverage:
- The warm-start request (a one-off `fetch`) - instrumented inline; the
response status code is now also included in the failure log (it was
previously dropped).
- All worker API client calls (`SupervisorHttpClient`: dequeue, run
attempt start/complete, heartbeats, snapshots, continue, suspend,
debug-log, connect) - routed through a single instrumented `request()`
helper that reports via an optional `onHttpRequestComplete` callback on
the client, which the supervisor wires into the counter + histogram.

Low cardinality by design: `name` is a **static per-endpoint label**
(e.g. `dequeue`, `start_run_attempt`), never the interpolated URL - so
no run/snapshot IDs land in labels, mirroring the templated `route`
labels on the inbound HTTP server.

Registered on the existing metrics registry, exposed on `/metrics` with
no new wiring. Internal-only change (no package release needed), so the
changelog note is a single `.server-changes` entry.
2026-07-23 18:18:58 +01:00
Eric Allam 88ca0091a9 fix(docker): stop the container entrypoint printing database connection strings in logs (#4346)
🚀 Publish Trigger.dev Docker / units (push) Failing after 11m53s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 11m54s
🚀 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

The container entrypoint runs under `set -x`, which echoes every command
to the logs with its variables expanded. Several startup guards
reference full database connection strings, so the DSN (including the
password) was printed to the container logs on every boot. This turns
tracing off around those lines so connection strings are never traced,
while leaving migration behavior and ordinary startup logging unchanged.

## Fix

The leaking lines are the `[ -n "$RUN_OPS_DATABASE_URL" ]` and `[ -n
"$RUN_OPS_LEGACY_DIRECT_URL" ]` guards, and the ClickHouse block (its `[
-n "$CLICKHOUSE_URL" ]` guard plus the lines that build `GOOSE_DBSTRING`
from `CLICKHOUSE_URL`). `set -x` prints each of these with the
credential expanded. Tracing is now disabled around each region and
restored afterward, so non-secret tracing is preserved everywhere else.
The existing legacy-migration subshell already protected its own command
body; this adds the missing protection for the guards and the ClickHouse
block.

```sh
{ set +x; } 2>/dev/null
if [ -n "$RUN_OPS_DATABASE_URL" ]; then
  set -x
  ...
```

## Verification

Built the webapp image and ran it with dummy sentinel connection strings
whose password token is `S3NTINEL_PW_DoNotLog`, then grepped the boot
logs.

Before (unmodified), the token appears in the traced guards:

```
+ [ -n postgresql://user:S3NTINEL_PW_DoNotLog@fake-host:6432/run-ops ]
+ [ -n postgresql://user:S3NTINEL_PW_DoNotLog@fake-host:5432/legacy ]
+ [ -n https://default:S3NTINEL_PW_DoNotLog@fake-host:8443 ]
```

After, `grep S3NTINEL_PW_DoNotLog` on the same run returns nothing, and
the normal "skipping ... migrations" lines still log.
2026-07-23 11:51:34 +01:00
claude[bot] 3c82248940 chore(deps): bump tar to 7.5.19 (#4345)
Pins `tar` to `7.5.19` via a root `pnpm.overrides` entry, replacing a
stale range override (`tar@>=7 <7.5.11`) that no longer matched any
installed copy.

The single override collapses all resolved `tar` copies onto one
version:

- `packages/cli-v3` — direct dependency (was 7.5.13)
- `@kubernetes/client-node` (apps/supervisor) — transitive (was 7.5.13)
- `cacache` — transitive (was 6.2.1)
- `giget` — transitive (was 6.2.1)

No source changes; cli-v3's published `^7.5.13` spec already permits
`7.5.19`, so no changeset is needed.
2026-07-23 10:59:16 +01:00
Eric Allam e9ac98b7a1 perf(run-store): route id-set reads to the owning store, not both DBs (#4342)
📚 Publish docs / publish (push) Has been cancelled
## Summary

The split run-store's id-set read path (`#findRunsByIdSet`, used by the
runs-list hydrate, the realtime hydrator, and engine sweeps) queried the
new store for the entire id set and then probed the legacy store for the
misses. A run's residency is a total function of its id (run-ops ids
live in the new store, every other id in legacy), so each id belongs to
exactly one store. Route each id to its owner and query each store only
for its own ids, in parallel. Same result set, and while a split is
active with most runs still on legacy it removes a wasted new-store
query from every id-set read.

## Change

`#findRunsByIdSet` now partitions the ids by `classifyResidency` and
runs one bounded query per store (skipping an empty side), in parallel,
mirroring `expireRunsBatch` and the single-run `#route`. `finalizeRows`
still applies orderBy/take/skip globally over the merged set.

This drops the id-set path's cross-store fallback, which existed to
prefer the new-store copy when the same id was present in both stores.
That collision cannot arise when each id maps to exactly one store
(nothing writes a legacy-shaped id into the new store), so the fallback
is dead code. The two id-set tests that asserted "new copy wins on
collision" now assert the routing invariant: a legacy-shaped id resolves
to the legacy store and the path never consults the new store.

The open-predicate path (`#findRunsOpen`) is unchanged: an open `where`
has no id to route on, so it still unions both stores and dedupes.
2026-07-22 23:03:24 +01:00
Katia Bulatova 23d5771d56 feat(webapp): unconfigured billing limit UX and default billing alerts (#4328)
## Default billing alerts + billing limit page UX

- New orgs get default billing alerts: $5, $100, $500, $1000, $2500.
Existing orgs are backfilled by a billing-side data migration (companion
[PR](https://github.com/triggerdotdev/cloud/pull/1657)).
- The billing limit form starts with nothing selected for orgs that
never set a limit — the save button appears once an option is picked.
- The yellow banner now also shows on the billing limits page itself,
asking to configure a limit. Hidden everywhere for members who can't
manage billing.
- Also fixes billing limit alert preview.

Tests
- `apps/webapp/test/billingLimitsRoute.test.ts` — dirty logic for
empty/selected mode
- `apps/webapp/test/billingAlertsDefaults.test.ts` — default values
- `apps/webapp/test/billingAlertsFormat.test.ts` — preview after a limit
change
2026-07-22 21:23:52 +02:00
Eric Allam a2d382b2be feat(webapp): add emission fan-out metrics to the native realtime feed (#4341)
## Summary

Adds two counters to the native realtime backend so we can see how much
duplicate row serialization the change router does per batch. When a run
changes it can match several held feeds at once (a run subscription plus
one or more tag/list feeds), and today each matching feed serializes
that run's wire value independently. These counters quantify that
fan-out so we can decide whether a shared serialization step is worth
it.

## What they measure

- `realtime_native.emission_run_serializations`: total wire-value
serializations performed across feeds per batch (what the current path
does).
- `realtime_native.emission_distinct_serializations`: distinct (columns,
run) rows those serializations cover (what a serialize-once-per-batch
step would do).

Average feeds-per-run is `run_serializations / distinct_serializations`,
and `1 - distinct / run_serializations` is the serialization work a
shared step could save. Wired through a new optional `onEmissionFanout`
callback on the router. No behavior change.
2026-07-22 17:48:44 +01:00
github-actions[bot] aafc333523 chore: release v4.5.7 (#4319)
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 15s
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-supervisor (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
## Summary
5 improvements, 5 bug fixes.

## Improvements
- Add `node-24` and `node-26` as supported `runtime` options in
`trigger.config.ts`. The `experimental-node-24` and
`experimental-node-26` names are now deprecated aliases and emit a
deprecation warning; switch to `node-24` / `node-26` instead.
([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337))
  
  ```ts
  import { defineConfig } from "@trigger.dev/sdk";
  
  export default defineConfig({
  runtime: "node-24",
  project: "<your-project-ref>",
  });
  ```
- Avoid logging task run environment variable values at debug level
([#4336](https://github.com/triggerdotdev/trigger.dev/pull/4336))
- Custom chat agent loops get two ergonomic wins for owning the turn
loop. ([#4304](https://github.com/triggerdotdev/trigger.dev/pull/4304))
  
`chat.writeTurnComplete()` now returns the turn boundary's resume
cursors (`lastEventId` for the output stream and `sessionInEventId` for
the input stream), so you can persist them straight from the task
instead of round-tripping them back from the client.
  
  ```ts
const { lastEventId, sessionInEventId } = await
chat.writeTurnComplete();
  await db.chats.update(chatId, { lastEventId, sessionInEventId });
  ```
  
`chat.pipeAndCapture()` no longer throws when a stream is stopped or
fails. It now returns a `PipeAndCaptureResult` whose `message` holds any
partial output captured before the stop or failure, alongside a typed
`status` (`"complete" | "aborted" | "error"`) and, on failure, the
`error`. Read the message off the result:
  
  ```ts
  const { message, status, error } = await chat.pipeAndCapture(result, {
  signal,
  });
  if (message) conversation.addResponse(message);
  if (status === "error") logger.error("turn failed", { error });
  ```
  
Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`.
Update call sites to read `.message` from the returned result.
- Suppress a build-time warning that could appear in Vite-based projects
when the optional `@ai-sdk/otel` package is not installed.
([#4188](https://github.com/triggerdotdev/trigger.dev/pull/4188))

## Bug fixes
- Fixes intermittent `trigger dev` run crashes where a run could fail at
boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a
rebuild had cleaned up the build directory the run was launched against.
Dev runs now retry cleanly instead of hard-crashing when their build
directory is missing, the dev watchdog no longer removes the build tree
of a still-running session, and a run assigned to a worker version that
was superseded by a rebuild now fails fast with a clear message instead
of silently hanging until it times out.
([#4276](https://github.com/triggerdotdev/trigger.dev/pull/4276))

## Server changes

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

- Refreshed the side menu: separate organization and account menus, a
new project switcher, and the menu is now resizable by dragging its
edge. The account Profile page has also been redesigned.
([#4066](https://github.com/triggerdotdev/trigger.dev/pull/4066))
- Allow different organization members to use the same development
branch name without sharing or colliding with each other's branch
environments.
([#4323](https://github.com/triggerdotdev/trigger.dev/pull/4323))
- Limit account settings email input to 254 characters.
([#4330](https://github.com/triggerdotdev/trigger.dev/pull/4330))
- Prevent duplicate Staging and Preview environments when account setup
requests overlap
([#4261](https://github.com/triggerdotdev/trigger.dev/pull/4261))
- Fix the docs link on the empty Prompts page, which pointed to a page
that no longer exists.
([#4247](https://github.com/triggerdotdev/trigger.dev/pull/4247))

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

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

### Patch Changes

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

### Patch Changes

- Fixes intermittent `trigger dev` run crashes where a run could fail at
boot with a cryptic `Cannot find module .../dev-run-worker.mjs` after a
rebuild had cleaned up the build directory the run was launched against.
Dev runs now retry cleanly instead of hard-crashing when their build
directory is missing, the dev watchdog no longer removes the build tree
of a still-running session, and a run assigned to a worker version that
was superseded by a rebuild now fails fast with a clear message instead
of silently hanging until it times out.
([#4276](https://github.com/triggerdotdev/trigger.dev/pull/4276))
- Add `node-24` and `node-26` as supported `runtime` options in
`trigger.config.ts`. The `experimental-node-24` and
`experimental-node-26` names are now deprecated aliases and emit a
deprecation warning; switch to `node-24` / `node-26` instead.
([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337))

  ```ts
  import { defineConfig } from "@trigger.dev/sdk";

  export default defineConfig({
    runtime: "node-24",
    project: "<your-project-ref>",
  });
  ```

- Avoid logging task run environment variable values at debug level
([#4336](https://github.com/triggerdotdev/trigger.dev/pull/4336))
- Updated dependencies:
  - `@trigger.dev/core@4.5.7`
  - `@trigger.dev/build@4.5.7`
  - `@trigger.dev/schema-to-json@4.5.7`
## @trigger.dev/core@4.5.7

### Patch Changes

- Add `node-24` and `node-26` as supported `runtime` options in
`trigger.config.ts`. The `experimental-node-24` and
`experimental-node-26` names are now deprecated aliases and emit a
deprecation warning; switch to `node-24` / `node-26` instead.
([#4337](https://github.com/triggerdotdev/trigger.dev/pull/4337))

  ```ts
  import { defineConfig } from "@trigger.dev/sdk";

  export default defineConfig({
    runtime: "node-24",
    project: "<your-project-ref>",
  });
  ```
## @trigger.dev/python@4.5.7

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

- Custom chat agent loops get two ergonomic wins for owning the turn
loop. ([#4304](https://github.com/triggerdotdev/trigger.dev/pull/4304))

`chat.writeTurnComplete()` now returns the turn boundary's resume
cursors (`lastEventId` for the output stream and `sessionInEventId` for
the input stream), so you can persist them straight from the task
instead of round-tripping them back from the client.

  ```ts
const { lastEventId, sessionInEventId } = await
chat.writeTurnComplete();
  await db.chats.update(chatId, { lastEventId, sessionInEventId });
  ```

`chat.pipeAndCapture()` no longer throws when a stream is stopped or
fails. It now returns a `PipeAndCaptureResult` whose `message` holds any
partial output captured before the stop or failure, alongside a typed
`status` (`"complete" | "aborted" | "error"`) and, on failure, the
`error`. Read the message off the result:

  ```ts
  const { message, status, error } = await chat.pipeAndCapture(result, {
    signal,
  });
  if (message) conversation.addResponse(message);
  if (status === "error") logger.error("turn failed", { error });
  ```

Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`.
Update call sites to read `.message` from the returned result.

- Suppress a build-time warning that could appear in Vite-based projects
when the optional `@ai-sdk/otel` package is not installed.
([#4188](https://github.com/triggerdotdev/trigger.dev/pull/4188))
- Updated dependencies:
  - `@trigger.dev/core@4.5.7`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 15:51:29 +01:00
Katia Bulatova b3b1441df9 fix(webapp): guard workload auth gate metric against dev HMR re-registration (#4339)
Wraps the workload_auth_gate_total Counter in the singleton helper (same
pattern as reloadingRegistry.server.ts) so a dev hot reload doesn't
crash with "A metric with the name workload_auth_gate_total has already
been registered". No production behavior change.
2026-07-22 15:54:18 +02:00
Chris Arderne 55a3bf2858 feat(core,cli): add node-24 and node-26 runtimes, deprecate experimental aliases (#4337)
## Summary

Adds `node-24` and `node-26` as first-class `runtime` options in
`trigger.config.ts`. Previously these Node versions were only reachable
via the `experimental-node-24` / `experimental-node-26` names.

Those experimental names are now **deprecated aliases**: they still
resolve to `node-24` / `node-26` for backwards compatibility, but
loading a config that uses them prints a deprecation warning pointing at
the new name.

```ts
export default defineConfig({
  runtime: "node-24",
  project: "<your-project-ref>",
});
```

## Details

- `ConfigRuntime` (the public config schema) now accepts `node-24` and
`node-26` directly; the internal `BuildRuntime` already supported them,
so base images and the deploy path are unchanged.
- `resolveBuildRuntime` passes the new names straight through and keeps
mapping the experimental aliases to their replacements.
- Renamed the runtime helper from `isExperimentalConfigRuntime` to
`isDeprecatedConfigRuntime` and added `deprecatedRuntimeReplacement` so
the CLI can name the replacement in its warning.
- Docs snippet updated to list the new versions and flag the deprecated
names.
2026-07-22 14:10:27 +01:00
nicktrn 14fa90672b chore: ignore .worktrees/ in the repo gitignore (#4334)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
Add `.worktrees/` to the repo `.gitignore`.

The pre-push hook runs `oxfmt --check .` and `oxlint .` over the whole
tree, and those tools only read the in-repo ignore files (not a user's
global gitignore). Local git-worktree checkouts placed under
`.worktrees/` therefore got linted/formatted, failing the hook on
unrelated code. Ignoring the directory keeps both tools out of worktree
checkouts. No source changes.
2026-07-22 13:38:20 +01:00
nicktrn 84add4ad3d feat(supervisor): export workload_token_enforcement_mode gauge (#4335)
Add a Prometheus gauge `workload_token_enforcement_mode` set to 1 for
the active `WORKLOAD_TOKEN_ENFORCEMENT` value
(`disabled`/`log`/`enforce`), emitted at startup on the shared registry.

The existing mint/verify counters don't distinguish `log` from `enforce`
(the verify outcome is recorded before the reject decision), so
dashboards can't tell which mode a cluster is running. This gauge makes
the active mode queryable at a glance. Supervisor typecheck passes.
2026-07-22 13:38:07 +01:00
Chris Arderne 509a4597bd fix(cli): redact task run env values from debug log (#4336) 2026-07-22 12:32:37 +00:00
James Ritchie 11d8a05fa6 fix(webapp): restore admin debug tooltip on Tasks and Runs, make its IDs copyable (#4332)
Restores the debug panel on the **Tasks** and **Runs** pages, and makes
the data it shows copyable.

Admin/impersonation only — no change for regular users, so there's no
`.server-changes`

<img width="909" height="1420" alt="CleanShot 2026-07-22 at 12 05 27@2x"
src="https://github.com/user-attachments/assets/ce2da167-dc23-422f-83f3-4f4aee9ed32c"
/>
2026-07-22 13:10:31 +01:00
Matt Aitken 81eac67069 fix(webapp): correct docs link on the blank prompts page (#4247)
## Summary

The empty-state panel on the Prompts page linked to a docs path that no
longer exists, so the "Prompts docs" button returned a 404. It now
points to the current prompts documentation at /docs/ai/prompts,
matching the link already used in the page header.
2026-07-22 12:51:06 +01:00
James Ritchie e7de120661 fix(webapp): remove Enterprise badge from SSO & Directory Sync menu item (#4333)
## What

The organization side menu previously showed an "Enterprise" badge next
to the SSO & Directory Sync item for any org not on the enterprise plan.
That badge is now removed so the item renders without it.

## Screenshot (before)

<img width="1428" height="649" alt="CleanShot 2026-07-10 at 08 24 41"
src="https://github.com/user-attachments/assets/b9787363-972f-4dd5-bf61-486680f49f4c"
/>
2026-07-22 12:49:37 +01:00
Chris Arderne 7a14188663 fix(webapp): limit account email address length (#4330)
## Summary

Limits user account email addresses to 254 characters in profile
settings and onboarding. Oversized values are rejected before the
uniqueness lookup, and the form fields enforce the same limit in the
browser.

## Fix

Both email update flows use a shared bounded email schema. Basic
validation completes before the uniqueness lookup runs.
2026-07-22 10:29:30 +01:00
Eric Allam a9815f745c fix(cli): stop dev runs crashing when a rebuild removes an in-use build dir (#4276) 2026-07-22 08:47:58 +01:00
Chris Arderne bb34a2e224 fix(webapp): scope development branches to each member (#4323)
## Summary

Allow each organization member to use the same development branch name
without colliding with another member's environment. Fixes #4320.

## Fix

Development branches now use the existing member-scoped project, slug,
and organization-member key for upserts. Preview branches retain their
project-wide shortcode behavior.

New development branches receive distinct shortcodes while keeping their
readable, member-scoped slugs. Existing branches continue to resolve
through the member-scoped key, so this requires no migration or
backfill.
2026-07-22 08:20:43 +01:00
Matt Aitken 95307ba33c fix(webapp): tidy Usage page credits display (#4322)
Two small corrections to the organization **Usage** page credits
display.

### 1. Label the credits panel "Credits" (was "Promo credits")
The panel surfaces any credit balance, not only promo-code redemptions,
so "Promo credits" is misleading when the credits come from another
source. Renamed the heading to "Credits".

### 2. Don't show "Included usage" for Enterprise orgs
Enterprise inherits the Pro plan's `includedUsage` value, so the Usage
bar rendered an "Included usage: $50" tier marker for Enterprise
organizations. Enterprise bills against prepaid credits rather than a
per-month included-usage tier, so the marker was misleading. The
`tierLimit` marker is now suppressed for Enterprise (`plan.type ===
"enterprise"`).

Verified with `pnpm run typecheck --filter webapp`.
2026-07-21 17:37:55 +01:00
James Ritchie 0b2919465c feat(webapp): redesign the side menu project and organization menus (#4066)
Redesign of the main side menu: separates Projects and Accounts from the
Organization menu and makes the menu resizable.

**Main changes**

- **Organization & Account menus**: the top-left is now a dedicated
organization menu (Settings, Usage, Billing, Team, SSO, integrations),
with a separate account menu beside it (Profile, PATs, Security,
Logout).
- **Project switcher**: a new Project section above the Environment
selector.
- **Resizable side menu**: drag the right edge to set a custom width
(saved per user), or click the edge to collapse/expand.
- **Environment selector**: reworked to match the Project menu,
including dev-branch handling.
- **Account Profile page**: redesigned into the Security page's
row-and-divider layout.

Preview URL: https://samejr-org-menu-update.triggerlabs.dev/



https://github.com/user-attachments/assets/9b199576-6037-4ea6-9bdb-3ee15265b8c2
2026-07-21 17:25:17 +01:00
Matt Aitken e2d3b8388c feat(sdk): return lastEventId from writeTurnComplete and typed capture result (#4304)
## Summary

Two ergonomic additions for custom chat-agent loops that own the turn
loop (`chat.customAgent`, `chat.createSession`, and the hand-rolled
primitives).

`chat.writeTurnComplete()` now resolves to `{ lastEventId }`, the resume
cursor for the start of the next turn. A custom loop can persist it
straight from the task instead of round-tripping it back from the client
after the turn ends. The value was already produced internally by the
turn-complete write; the public wrapper simply discarded it.

`chat.pipeAndCapture()` no longer throws when a stream is stopped or
fails. It now resolves to a `PipeAndCaptureResult` carrying any partial
`message` captured before the stop or failure, a typed `status`
(`"complete" | "aborted" | "error"`), and the `error` on failure.
Previously a failed stream threw and the partial was lost, and an abort
was captured only when the AI SDK happened to fire `onFinish` in time.

```ts
const { message, status, error } = await chat.pipeAndCapture(result, { signal });
if (message) conversation.addResponse(message);
if (status === "error") logger.error("turn failed", { error });

const { lastEventId } = await chat.writeTurnComplete();
await db.chats.update(chatId, { lastEventId });
```

## Design

`pipeAndCapture` wraps the pipe in a `try/catch` and classifies the
outcome from the abort signal (a stop drains the source stream cleanly
rather than throwing) versus a thrown error. It also races the
`onFinish` capture against a timeout so a hard stop that prevents
`onFinish` from firing can't hang the caller. This mirrors the capture
path `chat.agent` already uses internally.

The `finishReason` from `onFinish` is surfaced too, since it was already
captured on the built-in path.

The internal `turn.complete()` helper keeps its existing contract: it
still returns `UIMessage | undefined`, still throws on a genuine stream
failure, and still discards output on a full run cancel.

## Breaking change

`chat.pipeAndCapture` previously resolved to `UIMessage | undefined`.
Call sites now read `.message` off the result. This is a young,
low-level API; the docs examples are updated in this PR.
2026-07-21 17:08:30 +02:00
Chris Arderne 6642c8b785 fix(webapp): prevent duplicate envs from provision race (#4261)
Fixes TRI-12078

## Summary

Prevents concurrent environment setup requests from creating duplicate
Staging and Preview environments.

## Fix

Adds database-enforced uniqueness for root Staging and Preview
environments.

If two requests race, the losing request loads the environment created
by the winner and continues successfully instead of creating a duplicate
or returning an error.
2026-07-21 15:40:48 +01:00
Katia Bulatova d05f1a7398 chore(webapp): migrate from Remix compiler to Vite (#4188)
Replaces Remix compiler with the Vite plugin. The Express server
(cluster, socket.io, ws) and the Docker image contract are unchanged.
2026-07-21 15:57:13 +02:00
Chris Arderne dc87b884e7 chore: upgrade to typescript 6 (#4310)
## Summary

Upgrades the workspace to TypeScript 6.0.3 and applies the compiler,
type, and build configuration changes required to preserve package
layouts and existing runtime behavior, apart from correcting the HTTP
status field used for deployment connection errors.

## Compatibility

- Centralizes TypeScript 6.0.3 through the pnpm workspace catalog.
- Replaces compiler options and module resolution modes that TypeScript
6 no longer accepts.
- Restores explicit Node types where TypeScript 6 no longer includes
them transitively.
- Adds explicit declaration build roots that preserve each package's
existing output layout.
- Patches tsup to stop injecting the removed `baseUrl` option during
declaration builds.
- Uses type-only assertions for stricter typed-array and stream
definitions without changing runtime behavior.
- Reads the EventSource v3 HTTP status from `code`, so deployment
connection errors include it correctly.
- Keeps standalone CLI compatibility fixtures pinned to their existing
TypeScript version and lockfiles.

`turbo run typecheck` and the complete PR test suite are green.
2026-07-21 13:57:52 +01:00
DKP cbec61309a fix(webapp): fix promo page heading typography (#4311)
## What

The `/promo` page heading rendered with overlapping lines — the two
lines of "Promo codes are for new accounts" collided.

## Why

The page used `Header2` stretched to display sizes (`sm:text-2xl
md:text-3xl lg:text-4xl`), but `Header2` bakes in a fixed `leading-6`
(24px). A 36px font in a 24px line box makes wrapped lines overlap. It
only showed at `sm`+ widths and only on headings that wrap to 2+ lines,
which is why it slipped through — the short single-line headings on the
same page looked fine.

## Fix

Switch both headings to `Header1` — the page-title primitive the sibling
login pages (`login._index`, `login.magic`) already use for exactly this
size. Add `leading-tight` (relative line-height, scales with font size,
and this heading uniquely wraps to two lines) and `pb-4` to match the
login pages' spacing convention.

## Testing

Manually verified the signed-in view (`/promo` while logged in) renders
as two clean, non-overlapping lines across breakpoints. Pure CSS/layout
change — no automated test.
2026-07-21 13:48:05 +01:00
github-actions[bot] 325b906319 chore: release v4.5.6 (#4317)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-supervisor (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🧭 Helm Chart Release / release (push) Has been cancelled
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
## Summary
5 improvements, 9 bug fixes.

## Breaking changes
- Self-hosted deployments no longer ship shared default credentials;
fresh installs generate their own. If yours still uses a previously
published default, set a unique value before upgrading, or set
`ALLOW_INSECURE_DEFAULT_SECRETS=true` to keep booting while you migrate.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))

## Improvements
- Require explicit browser approval for CLI and MCP login, with
resilient polling while approval is pending.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Deployed task telemetry now reports the deployment identifier (e.g.
`deployment_abc123`) in the `worker.id` attribute, instead of an opaque
internal value. Upgrade to get the readable identifier in your own
OpenTelemetry exporters.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Prevent prototype pollution when applying run metadata operations or
reconstructing nested telemetry attributes, while preserving legitimate
`constructor` and `prototype` fields.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Add helpers to mint and verify the deployment-scoped token used to
authenticate run controllers to the platform.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))

## Server changes

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

- Added optional request rate limiting for telemetry ingestion
endpoints.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Background-worker deployment lookups are now scoped to the
authenticated environment.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Updating a GitHub App installation from the callback flow is now
scoped to your own organization, so an installation ID belonging to
another organization can no longer be used to refresh that
organization's installation record. The GitHub App installation session
is also now single-use, so completing an installation callback
invalidates its state and it can no longer be replayed.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Scope schedule and environment-variable writes to the caller's project
and environment
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Reject compute snapshot callbacks that do not match the snapshot
request that created them.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Require secret-key authentication to initialize the session out
(agent→client) stream, matching the append route.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Live run and trace subscriptions now validate their identifiers more
strictly and only return data from your own organization.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Window-function names in the query compiler are now validated against
the allowlist, matching how other function calls are handled.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Authenticate run controllers to the platform with a signed,
deployment-scoped token.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Verify that worker actions (starting, completing, and continuing a
run, and reading its snapshots) target a run belonging to the caller's
environment.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))

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

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

### Patch Changes

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

### Patch Changes

- Require explicit browser approval for CLI and MCP login, with
resilient polling while approval is pending.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Deployed task telemetry now reports the deployment identifier (e.g.
`deployment_abc123`) in the `worker.id` attribute, instead of an opaque
internal value. Upgrade to get the readable identifier in your own
OpenTelemetry exporters.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Updated dependencies:
  - `@trigger.dev/core@4.5.6`
  - `@trigger.dev/build@4.5.6`
  - `@trigger.dev/schema-to-json@4.5.6`
## @trigger.dev/core@4.5.6

### Patch Changes

- Prevent prototype pollution when applying run metadata operations or
reconstructing nested telemetry attributes, while preserving legitimate
`constructor` and `prototype` fields.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Require explicit browser approval for CLI and MCP login, with
resilient polling while approval is pending.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
- Add helpers to mint and verify the deployment-scoped token used to
authenticate run controllers to the platform.
([#4316](https://github.com/triggerdotdev/trigger.dev/pull/4316))
## @trigger.dev/python@4.5.6

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.6`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-21 12:07:52 +01:00
Chris Arderne 6997aeb05e fix: security release 2026-07-08 (#4316)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
2026-07-21 12:00:58 +01:00
Chris Arderne cc748422d8 test(webapp): replace slow metadata replica guard with unit test (#4312) 2026-07-20 19:17:44 +01:00
github-actions[bot] 1cbe25bd1d chore: release v4.5.5 (#4267)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-supervisor (push) Has been skipped
🚀 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 / units (push) Failing after 0s
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
## Summary
5 improvements, 5 bug fixes.

## Improvements
- Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to
`experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.
([#4085](https://github.com/triggerdotdev/trigger.dev/pull/4085))
- Add `defaultRegion` to the project GET and list API responses; null
when unset.
([#4146](https://github.com/triggerdotdev/trigger.dev/pull/4146))

## Server changes

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

- Transient internal sync failures are now retried quietly instead of
surfacing as errors.
([#4270](https://github.com/triggerdotdev/trigger.dev/pull/4270))
- Optionally route ClickHouse read traffic to a read replica while
writes stay on the primary. Set `CLICKHOUSE_READER_URL` to move all
reads, or target the busiest paths with `RUNS_LIST_CLICKHOUSE_URL` (runs
list) and `EVENTS_READER_CLICKHOUSE_URL` (traces, spans, logs). All
optional; unset keeps current behavior.
([#4081](https://github.com/triggerdotdev/trigger.dev/pull/4081))
- Remove the deprecated realtime stream write endpoint used by retired
v3 task clients.
([#4250](https://github.com/triggerdotdev/trigger.dev/pull/4250))
- Fix batchTrigger requests that set a per-item idempotency key failing
with an error instead of creating and deduplicating the runs
([#4271](https://github.com/triggerdotdev/trigger.dev/pull/4271))
- Speed up idempotency checks on `batchTrigger` calls that use
idempotency keys. Large batches against a task with a big run history no
longer degrade to multi-second lookups.
([#4255](https://github.com/triggerdotdev/trigger.dev/pull/4255))
- The "Preview branches" usage on the Limits page now counts only
preview branches.
([#4283](https://github.com/triggerdotdev/trigger.dev/pull/4283))
- Avoid opening a redundant database connection pool when the legacy and
primary databases are the same server, preventing connection usage from
doubling.
([#4253](https://github.com/triggerdotdev/trigger.dev/pull/4253))
- Fix pages occasionally loading unstyled or failing to load during a
deploy. The dashboard now reloads automatically to recover.
([#4282](https://github.com/triggerdotdev/trigger.dev/pull/4282))

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

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

### Patch Changes

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

### Patch Changes

- Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to
`experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.
([#4085](https://github.com/triggerdotdev/trigger.dev/pull/4085))
- Updated dependencies:
  - `@trigger.dev/core@4.5.5`
  - `@trigger.dev/build@4.5.5`
  - `@trigger.dev/schema-to-json@4.5.5`
## @trigger.dev/core@4.5.5

### Patch Changes

- Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to
`experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.
([#4085](https://github.com/triggerdotdev/trigger.dev/pull/4085))
- Add `defaultRegion` to the project GET and list API responses; null
when unset.
([#4146](https://github.com/triggerdotdev/trigger.dev/pull/4146))
## @trigger.dev/python@4.5.5

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

- Updated dependencies:
  - `@trigger.dev/core@4.5.5`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-20 14:01:37 +01:00
nicktrn d5f1696a97 ci(publish): unify image scanning across published images (#4306)
## What

Run the shared Trivy image scan on every published image through a
single reusable workflow.

## Changes

- Generalise the image-scan workflow (`trivy-image-webapp.yml` ->
`trivy-image.yml`) - it was already parameterised by `image-ref`; only
the run-summary label was image-specific.
- `publish-worker-v4.yml`: expose `version` + `image_repo` as workflow
outputs (single-entry matrix, so unambiguous).
- `publish.yml`: run the shared scan from each publish job
(`scan-webapp`, `scan-supervisor`).

Report-only (writes a table to the run summary), OS packages only
(`vuln-type: os` - library deps stay with Dependabot), never blocks the
publish.
2026-07-20 12:16:03 +01:00
Daniel Sutton a7c734c223 test: caller-driven replica-lag + idempotency guards (stacked on #4284) (#4285)
## Stacked on #4284 — tests only

This PR contains **only the tests** that guard the production fixes in
#4284 (its base). Review #4284 first; this branch adds no production
code.

## What

Caller-driven replica-lag and idempotency guards for every fixed site:
- Each guard **drives the real exported caller** (route loader/action,
presenter `.call()`, service, or engine method) against a **real
Postgres** with the owning replica frozen via the shared
`laggingReplica` testcontainer primitive — never a store-seam
reimplementation.
- For a **fixed** site the guard goes **RED when the production change
is reverted**; for a **tolerated read-view** site it's a caller-driven
**GREEN** proof the miss self-heals (returns null/empty, no mutation,
row live on primary).
- The **global-scope idempotency** guard drives the real dedup + claim
path through a **real `MollifierBuffer` over a Redis testcontainer**
(real SETNX/poll/publish), and covers the cross-DB **andWait** waitpoint
wiring and the **expired/failed clear-and-recreate** reacquire cases.

Run with `vitest --no-file-parallelism` (testcontainers). Verified
GREEN, and revert→RED verified per fixed site.
2026-07-19 17:06:45 +00:00
Daniel Sutton ae96b6c175 fix: read-your-writes + global-scope idempotency correctness under the run-ops split (#4284)
## What & why

Two related correctness fixes for the run-ops DB split. Under the split,
run-store reads can route to a **lagging read replica**; a just-written
run/waitpoint/batch can then be missed, causing a wrong decision.

**1. Read-your-writes → owning primary.** Surfaced first as an
intermittent `wait.until({ idempotencyKey })` re-wait on retry. Auditing
the run-store read surface found the same class at sibling sites (some
gating mutations or returning spurious 404s, others
tolerable/self-healing). Reads that must observe their own writes now
route to the owning **primary**
(`findRun`/`findWaitpoint`/`findBatchTaskRunByFriendlyId` →
`*OnPrimary`, a primary re-read on a miss, or a retryable 404 where the
SDK polls). Read-view reads stay on the replica. All additive — the
happy path is unchanged.

**2. Global-scope idempotency across the split.** A `global`-scope key
carries no per-run salt, so the same `(env, task, key)` triggered
concurrently from parents resident on **different** run-ops DBs could
dedup-miss on each DB and create a duplicate (the per-DB unique index
can't enforce cross-DB uniqueness). Such triggers (global scope, or
scope-absent, while split is active) are serialized through the existing
Redis idempotency claim, the loser resolves the winner by id across both
DBs, and the claim is reacquired on the expired/failed
clear-and-recreate path. `run`/`attempt` scope embed the run id and
never contend.

## Stacked for review

This is the **base** of a 2-PR stack, split so review is easier:
- **This PR** — production code only (34 files).
- **Stacked tests PR →
https://github.com/triggerdotdev/trigger.dev/pull/4285** — the
caller-driven guards (55 test files) on top of this branch.

## Validation

Local run-ops split, **both 2-DB and 3-DB**, fresh boot on this branch:
SDK canary 64/71 (only the known concurrency/input-streams/s3 failures),
quarantine sweep **0 unexpected** (340 pass / 16 known / 4 local) in
each topology, dashboard e2e 0 failed. No product regressions.
2026-07-19 17:57:41 +01:00
Chris Arderne cecdfd94be fix: only count preview branches toward the preview branch limit (#4283) 2026-07-17 15:54:25 +00:00
Daniel Sutton 285666290f ci(webapp): wire the run-ops legacy guard into CI and add oxlint residency fences (#4279)
## What
- Runs `apps/webapp/scripts/runOpsLegacyGuard.ts --check` as its own PR
job (`runops-guard`), so code that reaches a run-graph table through the
control-plane Prisma client instead of the RunStore fails the build.
- Adds a `trigger-runops` oxlint plugin with two fast, in-editor rules
scoped to `apps/webapp/app`: one for direct `prisma.taskRun`-style
access, one for a control-plane client wired into a read-through slot.
These are the cheap fence; the guard is the type-aware gate.
- Fixes `CancelTaskRunService.callV1`: historical V1 runs are
legacy-resident, so its two finalize writes now go through
`runOpsLegacyPrisma` instead of the control-plane client (they'd miss
the row once legacy is a separate database).
- Regenerates the guard baseline, which had drifted stale (it referenced
files deleted in an earlier PR).

## Why
The guard existed but ran nowhere, so its baseline rotted and a real
residency gap (the V1 cancel writes) sat undetected. Wiring it into CI
turns it into a ratchet against new control-plane run-graph access.

## Verification
Local, against a clean regen: `oxfmt --check`, `oxlint .`, `guard
--check`, and `typecheck --filter webapp` all pass. Remaining baseline
entries are 4 batch-results router reads through type-opaque `as
PrismaReplicaClient` casts (correct at runtime, accepted) + 2 sanctioned
legacy annotations.
2026-07-17 16:27:30 +01:00
Daniel Sutton 821972176d fix(run-store,webapp): correct split-database read routing, write residency, and batches list ordering (#4272)
## Summary

Correctness and performance fixes for deployments that split run data
across more than one database. Single-database / self-hosted deployments
are unaffected (they collapse to a single read/write path).

- **Batches list (dashboard):** for some organizations the Batches list
could hide older batches or show them out of order. It now orders and
paginates by creation time (with the id as a stable tiebreak), so every
batch appears exactly once, newest first. The pagination cursor format
changes; older in-flight cursors simply restart from the first page.
- **Reads:** waitpoint and snapshot lookups that are keyed by a single
run now read only the database that holds that run instead of querying
both, removing redundant queries on hot paths (unblock, snapshot reads).
- **Writes:** environment-scoped writes with no owning run (standalone
wait tokens, waitpoint tags, idempotency-key resets) now land in the
same database as that environment's runs, rather than defaulting to the
other one. An idempotency-key reset also falls back to the other
database when it matches nothing, so a reset still clears the key
wherever the run actually lives.

## Notes

Verified end-to-end against multi-database setups: run-keyed reads and
env-scoped writes land on the correct database with no cross-database
writes, and the batches list surfaces every batch in creation order. New
tests cover the batches ordering/reachability and the write-residency
routing.
2026-07-17 16:26:56 +01:00
nicktrn 0ff0abd776 fix(webapp): recover from stale /build assets via a bounded reload (#4282)
## Problem

The webapp's HTML references content-hashed `/build` assets, and each
running
instance contains exactly one build and returns 404 for asset hashes it
doesn't
have. During a rolling deploy a client can hold HTML from one build
while a
request for one of its assets is served by an instance on a different
build →
missing styles or a failed chunk load.

## What this does

On a `/build` stylesheet/script/chunk load failure, the client does a
**bounded
full-document reload** (at most 2 per 5 minutes, tracked in
`sessionStorage`) so
the page reloads onto a single consistent build. That's the whole
mechanism — no
polling, no `fetch` interception, no blocking overlay, no form
snapshotting.

- `apps/webapp/app/components/StaleAssetRecovery.tsx` — authored as a
typed,
lint-checked function and serialized to an inline script via
`.toString()` (so
the logic is real, reviewable code, not an opaque string), injected
before
  `<Links />`, production only.
- Detection: capture-phase `error` listener for
`<link>`/`<script>`/modulepreload
failures under `/build/`, plus an `unhandledrejection` guard for
dynamic-import
  failures.
- Guards: once-per-page re-entrancy guard, the bounded reload budget,
and a
`navigator.onLine` check so it never reloads into an offline error page.
- Unit tests in `StaleAssetRecovery.test.ts`.

## Relationship to #4260

Replaces the recovery introduced in #4260 (reverted in #4280) with a
much
smaller, reload-only approach — the previous version intercepted `fetch`
and
could turn a data request into a navigation, and showed a full-screen
overlay on
any asset error; this drops both.

## `/build-version` compatibility shim

`apps/webapp/server.ts` adds a tiny `GET /build-version` endpoint (build
id only,
`no-store`). A previously-deployed client build polls it after an asset
failure
and reloads once it sees a newer build, so those older tabs recover in
one reload
instead of getting stuck. Temporary — safe to remove once older clients
have
cycled out. It deliberately does **not** re-add an `X-Build-Id` response
header.

## Also

Restores the `.server-changes` writing guidance in
`.claude/rules/server-apps.md`
(reverted alongside #4260).

## Self-hosting note

Recovery is most reliable when your load balancer keeps a client on one
instance
for the duration of a deploy (short session stickiness) — the reload
then lands
on a consistent build in one hop.
2026-07-17 16:11:25 +01:00
Chris Arderne 73d966ad22 chore(webapp): remove deprecated realtime stream write action (#4250)
Removes the deprecated realtime stream write action kept for retired v3
task clients. Supported clients use the targeted stream write routes,
while the existing stream read loader remains unchanged.
2026-07-17 13:49:54 +01:00
nicktrn 051d7080d6 Revert "fix(webapp): survive asset hash rotation across rolling deploys (#4260)" (#4280)
Standard `git revert` of #4260.

Its client-side stale-asset recovery is net-negative during normal
deploys:

- The `fetch` interception treats any `?_data=` request (Remix loader
**and** action traffic) as a navigation and, on a build-id mismatch,
`location.assign`es the tab to the fetched URL — an open dashboard tab
can be hard-navigated to a raw data URL during a rolling deploy, losing
unsaved input.
- Any transient `/build` asset error (a network blip, an extension, an
unrelated failed dynamic import) blanks the page behind a full-screen
overlay for ~60s before offering a manual reload.
- It serialized form field values to `sessionStorage` to restore them
across the reload.

This returns the webapp to the pre-#4260 baseline as a fast, low-risk
step.

Follow-ups (separate PRs):
- a minimal reload-only recovery to replace this,
- restore the unrelated `.claude/rules/server-apps.md` docs tidy-up from
#4260 (via cherry-pick),
- a load-balancer stickiness change addressing the root cause.
2026-07-17 14:42:52 +02:00
Chris Arderne 939c00782d feat(webapp): show runtime versions in deployment lists (#4273) 2026-07-16 16:47:05 +01:00
Chris Arderne eccc8e3ae0 fix: .env.example file state DIRECT_URL without ref (#4275)
The `DIRECT_URL=${DATABASE_URL}` wasn't working in at least one user of
the var.
2026-07-16 16:43:13 +01:00
Chris Arderne d7ec75d5ad feat(runtime): add experimental Node.js 24 and 26 task runtimes (#4085)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
## Summary

Adds experimental Node.js 24 and 26 task runtimes through the
`experimental-node-24` and `experimental-node-26` config values.

Existing runtime defaults and the `node`, `node-22`, and `bun` behavior
remain unchanged. The unprefixed `node-24` and `node-26` config values
remain unavailable until the runtimes are ready for general use.

## Design

Experimental config values normalize to canonical runtime identifiers
before build manifests are created, keeping deployment metadata and
execution behavior consistent. Kubernetes task pods also use the
runtime-default seccomp profile so modern Node.js versions fall back
from io_uring to checkpoint-compatible system calls.
2026-07-16 12:19:03 +01:00
Eric Allam 43250522a5 fix(run-store): fix batch idempotency lookup on the dedicated run-ops store (#4271)
## Summary

`batchTrigger` requests that set a per-item `idempotencyKey` failed with
a 500 when the run-store is split across databases: the per-item
idempotency lookup errored before any run was created. Batches without
per-item keys, single `trigger` idempotency, and batch-level
(`idempotency-key` header) idempotency were unaffected.

## Root cause

`findRunsByIdempotencyKeys` built its `UNION ALL` of per-key
point-lookups with `@trigger.dev/database`'s `Prisma.sql` /
`Prisma.join`, then executed it on whichever store client it was handed.
On the dedicated run-ops store that client is a *separate* generated
Prisma client, and a `Sql` object from a different generated client is
not recognized: the bare `$queryRaw(Prisma.join(...))` form dropped the
query text entirely (`Argument \`query\` is missing`). The
tagged-template form is no better here: joining nested `Prisma.sql`
fragments across the two clients mis-numbers the bound parameters
(`syntax error at or near "$1"`).

## Fix

Build the lookup as a plain parameterized string and run it via
`$queryRawUnsafe` with positional placeholders and bound values, so it
no longer depends on which generated client executes it. The query text
contains only static SQL and integer placeholders; every value
(`runtimeEnvironmentId`, `taskIdentifier`, each key) is bound, so it is
not a raw-interpolation site. Same per-key point-lookup shape as before,
no change on the single-client path.

Verified end-to-end against a bundled build with the run-store split
enabled: before the fix, `batchTrigger` with a per-item key 500s; after,
it returns the runs and dedups correctly across fresh, repeat, and mixed
batches.
2026-07-15 19:36:12 +01:00
Iss 80cbc46bf6 fix(webapp): log transient Attio 5xx/429 at warn instead of error (#4270)
The signup → Attio sync (`attio.server.ts` `#assert`) logged every
non-2xx response at `error` level and threw the same way regardless of
status. Transient upstream failures (5xx/429) are retried by the common
worker and self-heal, so treating them as errors created false alerts
for something that isn't actually a bug.

Now `#assert` splits the two cases:

- **5xx / 429** — Logged at `warn` and thrown with `logLevel: "warn"`,
so they continue to be retried but don't raise error-level alerts. This
reuses the same pattern the worker already honors
(`directorySyncEffects`).
- **4xx** — Unchanged: logged at `error` and thrown, so genuine
integration bugs (schema, permissions, auth, etc.) remain visible.

There is no behavior change to retries or the signup flow. This is a
server-only change.

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-15 10:57:39 -04:00
nicktrn 890dd66eb5 feat(webapp): route ClickHouse reads to an optional read replica (#4081)
## Summary

Adds optional configuration to send ClickHouse read traffic to a
separate instance (for example a read replica) while writes stay on the
primary `CLICKHOUSE_URL`. This lets operators offload read load (runs
list, traces, logs, queries) from the cluster that handles inserts.
Fully backwards compatible: with nothing new set, every client resolves
to `CLICKHOUSE_URL` exactly as before.

## What it adds

- `CLICKHOUSE_READER_URL` (optional): a single reader endpoint that the
read-only clients fall back to. Read clients resolve `<own URL> ??
CLICKHOUSE_READER_URL ?? CLICKHOUSE_URL`. The task-events client (which
both inserts events and reads traces, spans, and logs) is built as a
reader/writer pair so queries use the reader while inserts stay on
`CLICKHOUSE_URL`.
- `RUNS_LIST_CLICKHOUSE_URL` (optional): a dedicated client for the runs
list (dashboard list, runs list API, live reload, child-status counts),
so the highest-traffic read path can target its own instance.

## Safety

Only read-only clients fall back to the reader: logs, query, admin, runs
list, the pending-version lookup, and the realtime run-id resolver. The
query page is constrained to read-only (the TSQL parser rejects anything
that is not a `SELECT`, and a `readonly` setting is applied). The
task-events client routes inserts to the writer and queries to the
reader per method, so a write can never reach the reader. Pure-write
clients (event inserts, replication) always use `CLICKHOUSE_URL`.

Note: this PR targets a baseline branch rather than `main` so the diff
stays scoped to the read-replica changes. It will be retargeted to
`main` before merge.

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2026-07-15 14:59:42 +01:00
Chris Arderne b902e65dfb chore: standardise internal node on 24.18.0 (#4254)
## Summary

Updates the internal development, CI, and runtime-image Node version to
24.18.0. SDK compatibility coverage continues to include Node 20, 22,
24, and 26.

The Node type definitions and the package-manager lockfiles now resolve
against Node 24 types.
2026-07-15 12:49:12 +01:00
nicktrn 976171ea16 feat(webapp): management API for orgs, projects, members, and settings (#4146)
## Summary

Adds a set of PAT-authenticated management API endpoints so orgs,
projects, members/invites, environment variables, and a few
project/environment settings can be managed programmatically (scripting,
automation) rather than only through the dashboard. Each route is a thin
wrapper over the **existing** service the dashboard already uses, with
the same authorization applied at the route layer - no new business
logic.

## Endpoints

**Organizations**
- `POST /api/v1/orgs` - create an org (`createOrganization`)
- `PATCH /api/v1/orgs/:orgParam` - rename (title)
- `DELETE /api/v1/orgs/:orgParam` - soft-delete
(`DeleteOrganizationService`; keeps the active-subscription guard)

**Members & invites**
- `GET /api/v1/orgs/:orgParam/members` - list members + pending invites
- `DELETE /api/v1/orgs/:orgParam/members/:memberId` - remove a member
(last-member guarded)
- `POST /api/v1/orgs/:orgParam/invites` - invite by email
(`inviteMembers`, sends the invite email)
- `DELETE /api/v1/orgs/:orgParam/invites/:inviteId` - revoke an invite

**Projects**
- `PATCH /api/v1/projects/:projectRef` - rename
(`ProjectSettingsService`)
- `DELETE /api/v1/projects/:projectRef` - soft-delete
(`DeleteProjectService`)
- `PUT /api/v1/projects/:projectRef/default-region` - set the default
region by worker-group name (`SetDefaultRegionService`)
- project GET/list now return `defaultRegion` (worker-group name, or
null when unset)

**Environments**
- `POST /api/v1/projects/:projectRef/:env/pause` and `/resume`
(`PauseEnvironmentService`)
- `POST /api/v1/projects/:projectRef/:env/regenerate-api-key` - rotate
the env secret key (`regenerateApiKey`, RBAC `write:apiKeys`)
- env var create now accepts an optional `isSecret` flag

## Auth & authorization

- All routes authenticate with a **Personal Access Token**
(`Authorization: Bearer tr_pat_...`).
- Org/project routes are built on the PAT route builders in
`apiBuilder.server.ts`: `createLoaderPATApiRoute` (already existed) and
**`createActionPATApiRoute`** (added here - the loader builder had no
mutation counterpart). The builder runs auth, resolves the org/project
role-floor via `context`, and enforces a declarative `authorization`
block using the same RBAC actions the dashboard applies
(`manage:organization` / `read:members` / `manage:members` /
`manage:project`). Handlers keep a membership-scoped query as the floor,
so a non-member gets a 404. This also gives these routes `tenantContext`
user attribution (Sentry) and `ServiceValidationError`-to-status mapping
for free.
- **Membership floor (important).** The OSS RBAC fallback grants a
permissive ability, so `ability.can(...)` can't reject a non-member on
self-hosted. Every handler therefore resolves the target scoped to the
caller's membership (`members: { some: { userId } }`) → 404 for
non-members. `authorization` is the *role* gate; this is the *tenant*
gate. `resolveOrganizationForApiUser`
(`organizationApiAccess.server.ts`) is the org-tier version of the
existing `findProjectByRef` - org-addressed PAT routes are new, so no
such helper existed before.
- Env-tier routes reuse the existing `authorizePatEnvironmentAccess`
(`write:apiKeys`).

### What `createActionPATApiRoute` gives you

A route is pure declaration - the builder handles auth, RBAC,
validation, tracing, and error mapping:

```ts
export const action = createActionPATApiRoute(
  {
    method: "PUT",                          // one verb, or ["PATCH", "DELETE"] for multi-verb routes
    params: ParamsSchema,
    body: SetDefaultRegionRequestBody,      // zod-validated
    context: async ({ projectRef }) => {    // resolve the org for the RBAC role-floor
      const project = await prisma.project.findFirst({
        where: { externalRef: projectRef, deletedAt: null },
        select: { organizationId: true },
      });
      return project ? { organizationId: project.organizationId } : {};
    },
    authorization: { action: "manage", resource: () => ({ type: "project" }) },
  },
  async ({ params, body, authentication, ability }) => {
    // auth + authz already enforced. Just do the work.
    // `throw new ServiceValidationError("Region not found", 400)` → mapped to that status.
    return json({ ok: true });
  }
);
```

Handled for you, so handlers stay thin:

- **Method allowlist** - `method` accepts a verb or an array; any other
verb → `405` with an `Allow` header, *before* auth runs:
  ```ts
const allowedMethods = method ? (Array.isArray(method) ? method :
[method]) : undefined;
if (allowedMethods && !(allowedMethods as
string[]).includes(request.method.toUpperCase())) {
return json({ error: "Method not allowed" }, { status: 405, headers: {
Allow: allowedMethods.join(", ") } });
  }
  ```
- **PAT / user-actor auth** in a single roundtrip → `401` on
missing/invalid/revoked token.
- **RBAC** - `context` computes the caller's role-floor for the target
org/project; `authorization` gates it → `403` with a structured error
body.
- **Sentry attribution** - `tenantContext.enrich({ userId })` so events
from the handler carry the acting user.
- **Typed errors** - a thrown `ServiceValidationError` is mapped to its
`.status` (default 400); anything else → `500`, and expected boundary
errors are logged as `warn` (kept out of Sentry).
- **Validation** - params / query / headers / body are all zod-checked →
`400` with details.

## Notes for reviewers

- Everything wraps an existing service; the intent is API parity for
things that are currently dashboard-only, not new behaviour.
- `createActionPATApiRoute` is new shared infra (the PAT + RBAC mutation
builder that didn't exist). It's self-contained - the loader builder and
existing routes are untouched.
- `@trigger.dev/core` gets one additive field (`defaultRegion` on the
project response, optional/nullable for client-server version skew) -
changeset included, patch.
- `removeTeamMember`'s last-member guard is now atomic (Serializable
transaction via the `$transaction` helper, with retry), so the dashboard
and API both get it server-side. Added a `## Transactions` rule to
`apps/webapp/CLAUDE.md` (always use the `$transaction` helper);
migrating the remaining direct usages is tracked in TRI-11698.

## Open questions

- ~~Is PAT the right auth (vs OAT for automation)?~~ **Resolved: PAT.**
Organization Access Tokens are currently internal-only (used by the
image builder) and not user-accessible, so they can't back this yet.
- Should any of these be gated behind a flag or scope?
- Naming/shape of the routes.
2026-07-15 10:20:08 +01:00
Eric Allam 1ab5066ed0 perf(webapp,run-store): point-lookup batch idempotency keys (#4255)
## Summary

Batch triggers that use per-item idempotency keys could take seconds
instead of milliseconds when the target task had a large run history.
This keeps the idempotency lookup fast regardless of how many runs a
task has accumulated.

## Root cause

The batch path checks which items already have runs by looking up their
idempotency keys with a single `WHERE runtimeEnvironmentId = ? AND
taskIdentifier = ? AND idempotencyKey IN (...)` query. On a very large
`TaskRun` table Postgres underestimates the row count of a specific
`(environment, task)` pair, so once the `IN` list grows past a handful
of keys it stops doing per-key index probes and instead scans every run
for that `(environment, task)` and filters the keys in memory. The cost
is then flat and large regardless of how many keys are being checked,
and a routine `ANALYZE` does not correct the estimate at that table
size.

## Fix

Look each idempotency key up on its own, batched into a `UNION ALL` of
point lookups (chunked, run with bounded concurrency). Each branch is an
equality on all three columns of the unique index, so the planner can
only do a per-key index probe and can never fall back to the range scan.
Same results, same columns, confined to the batch trigger path.
2026-07-15 08:25:41 +01:00
Katia Bulatova 2aa64200f8 fix(webapp): survive asset hash rotation across rolling deploys (#4260)
### Problem

Webapp HTML references content-hashed /build assets, and each Docker
image contains exactly one build with a hard 404 for unknown hashes.
During a rolling deploy, a client holding HTML from the old build may
request old asset hashes from a replica running the new image, causing
missing styles or failed chunk loads.

The page should recover automatically once a compatible build becomes
available, without reload loops or unnecessary interruptions during
normal deployments.

### What changed

- Build changes alone do nothing — no polling, no automatic reloads.
- If a CSS or JavaScript asset fails to load, a recovery overlay is
shown immediately.
- While the server still reports the same build, the client polls for a
newer build using exponential backoff (up to ~60s). As soon as a newer
build is detected, the page reloads automatically.
- If no newer build appears within the timeout, recovery falls back to a
manual Reload action.
- If recovery still fails after the automatic reload, the client stops
retrying and displays a final recovery screen instead of entering a
reload loop.
- Recovery preserves form values and scroll position across the
automatic reload.
2026-07-14 23:48:21 +01:00
Matt Aitken c936c79e39 docs: update ClickHouse chat agent example for generative UI (#4251)
📚 Publish docs / publish (push) Has been cancelled
Updates the ClickHouse chat agent example page to match the upgraded
example (triggerdotdev/examples#124), which is now a fullstack
generative-UI chat app rather than an agent-only project.

## What changed

- **Overview / tech stack / features** rewritten: Next.js chat app
(`useChat` + `useTriggerChatTransport`, no API route), a
`renderVisualization` tool taking json-render specs rendered with
`@json-render/shadcn` + shadcn charts (Recharts) + mapcn point maps, and
a shared catalog that generates both the system-prompt component
reference and tool-call validation.
- **The agent section** now shows the versioned [AI
Prompt](https://trigger.dev/docs/ai/prompts) pattern (`prompts.define()`
+ `chat.prompt.set()` + `chat.toStreamTextOptions({ registry })`), with
a warning that `experimental_telemetry` comes from the stored prompt —
the docs previously showed a static `system:` string, which silently
ships no LLM observability.
- **New sections** for the shared catalog, the `renderVisualization`
tool, the Next.js chat UI and registry.
- **Relevant code links** updated to the new `src/` layout.
- **Learn more** cards now include Frontend and AI Prompts.

Note: merge after triggerdotdev/examples#124 lands, so the GitHub file
links resolve.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-14 13:59:15 +01:00
Chris Arderne 313fe03481 test(core): fix flakey run-stream test depending on ordering (#4256) 2026-07-14 12:29:57 +01:00
Daniel Sutton a1ca64613b fix(webapp): reuse the primary db pool for legacy run-ops when DSNs match (#4253)
## Summary

When the run-ops split is enabled, the legacy run-ops database client
was always constructed as its own connection pool, even when it points
at the same database as the primary (control-plane) client. On setups
where those two DSNs resolve to the same physical database, this opened
a second, redundant pool and doubled the number of connections used
against that database. This change makes the legacy client reuse the
primary client's pool whenever their DSNs point at the same database,
and only open a separate pool when they genuinely differ.

## Fix

A small `sameDatabaseTarget` comparison (host, port, database name,
user) decides whether the legacy DSN points at the same database as the
primary. When it does, the legacy handle reuses the primary client by
reference, so no second pool is opened. When the DSNs diverge, the
legacy client is built independently as before, so the split still works
once the databases are actually separate.

Two smaller changes ride along:

- An optional per-pool limit for the run-ops read replica, which
connects unpooled and so draws raw backend connections; unset, it falls
back to the existing default and behaviour is unchanged.
- A startup warning about a missing legacy replica URL is now suppressed
when the legacy client shares the primary pool, where it would be
misleading.

## Verification

Booted the webapp end-to-end in three modes and confirmed the pools
opened as expected via the client's own startup logs and live backend
connection counts: split off (single pool), split on with a shared
database (legacy reuses the primary pool, no doubling), and split on
with separate databases (legacy opens its own pool).
2026-07-14 11:20:21 +01:00
github-actions[bot] 165955781d chore: release v4.5.4 (#4228)
📚 Publish docs / publish (push) Has been cancelled
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 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 / units (push) Failing after 0s
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
## Summary
2 new features, 11 improvements, 5 bug fixes.

## Breaking changes
- Trigger.dev v3 is no longer supported. For self-hosted deployments,
4.5.0 is the last version we officially support for running v3; stay on
4.5.0 or upgrade to v4. v3 triggers, batch triggers, reschedules, and
deploys now return a clear upgrade message instead of running.
([#4236](https://github.com/triggerdotdev/trigger.dev/pull/4236))

## Improvements
- You can now mark environment variables synced via the `syncEnvVars`
build extension as secrets. Return `{ name, value, isSecret: true }`
from your callback and those variables are stored redacted in the
dashboard, just like manually created secret env vars.
([#4203](https://github.com/triggerdotdev/trigger.dev/pull/4203))
- Remove the legacy `--mcp` and `--mcp-port` options from the `dev`
command. Run the dedicated `trigger mcp` command to start the
Trigger.dev MCP server.
([#4246](https://github.com/triggerdotdev/trigger.dev/pull/4246))
- Removed the unused `ResourceMonitor` export from
`@trigger.dev/core/v3/serverOnly`. It was a server-side logging helper
with no remaining consumers.
([#4244](https://github.com/triggerdotdev/trigger.dev/pull/4244))
- Removed the unused `@trigger.dev/core/v3/zodNamespace` export and the
legacy v3 socket message schemas. These were only used by the
now-retired v3 engine and have no v4 consumers.
([#4236](https://github.com/triggerdotdev/trigger.dev/pull/4236))

## Bug fixes
- Fix a `chat.agent` message-loss race where sending a message right
after an action (such as an undo) could drop the follow-up's response
from the UI until a refresh.
([#4234](https://github.com/triggerdotdev/trigger.dev/pull/4234))

## Server changes

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

- Added `EVENT_REPOSITORY_POSTGRES_WRITES_DISABLED` to skip all
PostgreSQL task-event writes for deployments that store task events in
ClickHouse. Leave it off unless `EVENT_REPOSITORY_DEFAULT_STORE` is
`clickhouse_v2`, otherwise task events are lost.
([#4242](https://github.com/triggerdotdev/trigger.dev/pull/4242))
- Promo credits: a /promo signup landing page, redeeming a promo code
when a new org selects a plan, and showing remaining credits on the
usage page.
([#4138](https://github.com/triggerdotdev/trigger.dev/pull/4138))
- Speed up retrieving a background worker by version. The endpoint no
longer runs a slow lookup that scanned the full task table for large
deployments; it now reuses data it already loads, so the response is the
same but returns much faster.
([#4245](https://github.com/triggerdotdev/trigger.dev/pull/4245))
- Clearer login error when an email address is blocked by the
WHITELISTED_EMAILS setting: the message now explains the address isn't
allowed on this instance instead of the ambiguous "This email is
unauthorized".
([#4220](https://github.com/triggerdotdev/trigger.dev/pull/4220))
- Make the native build server the default in project build settings.
It's now opt-out, stored as a new `disableNativeBuildServer` key. Also
clarifies in the UI that build settings apply to GitHub-triggered and
native build server deployments.
([#3980](https://github.com/triggerdotdev/trigger.dev/pull/3980))
- Optionally process high-volume telemetry ingestion in parallel for
higher throughput under heavy load by setting
`OTEL_TRANSFORM_WORKER_POOL_ENABLED=1`. Off by default.
([#4232](https://github.com/triggerdotdev/trigger.dev/pull/4232))
- Add a `REALTIME_BACKEND_DEFAULT` env var to choose the default
realtime backend (`electric`, `native`, or `shadow`) for environments
whose org has no per-org override. Defaults to `electric`, so existing
behavior is unchanged.
([#4231](https://github.com/triggerdotdev/trigger.dev/pull/4231))
- Clarified on the Regions page that a region only affects where your
runs execute, not where your data is stored. This shows as a tooltip on
the Location column and in the confirmation dialog when you change your
default region.
([#4226](https://github.com/triggerdotdev/trigger.dev/pull/4226))
- Improved the reliability of how run data is read and written.
([#4237](https://github.com/triggerdotdev/trigger.dev/pull/4237))
- Fixed stale login errors: an error from a previous login attempt (for
example a rejected email address) no longer keeps reappearing on the
login page and no longer makes later, successful attempts look like they
failed.
([#4220](https://github.com/triggerdotdev/trigger.dev/pull/4220))
- The Errors page now shows better details for each error. Errors that
don't carry a message — such as errors thrown without a message, or
values thrown that aren't `Error` objects — get a meaningful title
instead of all reading "Unknown error", and are grouped by their name
(or value) rather than collapsed into a single group. The error type now
shows the actual error name, and stack traces now appear where
previously they were missing.
([#4225](https://github.com/triggerdotdev/trigger.dev/pull/4225))
- Return a clear client error when SSO form submissions use an
unsupported content type
([#4238](https://github.com/triggerdotdev/trigger.dev/pull/4238))
- Query page: extracting fields from a run's output with JSON functions
(such as JSONExtractString or JSONExtractInt) no longer fails with an
"illegal type: JSON" error.
([#4221](https://github.com/triggerdotdev/trigger.dev/pull/4221))

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

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

### Patch Changes

- You can now mark environment variables synced via the `syncEnvVars`
build extension as secrets. Return `{ name, value, isSecret: true }`
from your callback and those variables are stored redacted in the
dashboard, just like manually created secret env vars.
([#4203](https://github.com/triggerdotdev/trigger.dev/pull/4203))
- Updated dependencies:
  - `@trigger.dev/core@4.5.4`
## trigger.dev@4.5.4

### Patch Changes

- Remove the legacy `--mcp` and `--mcp-port` options from the `dev`
command. Run the dedicated `trigger mcp` command to start the
Trigger.dev MCP server.
([#4246](https://github.com/triggerdotdev/trigger.dev/pull/4246))
- Updated dependencies:
  - `@trigger.dev/core@4.5.4`
  - `@trigger.dev/build@4.5.4`
  - `@trigger.dev/schema-to-json@4.5.4`
## @trigger.dev/core@4.5.4

### Patch Changes

- Removed the unused `ResourceMonitor` export from
`@trigger.dev/core/v3/serverOnly`. It was a server-side logging helper
with no remaining consumers.
([#4244](https://github.com/triggerdotdev/trigger.dev/pull/4244))
- Removed the unused `@trigger.dev/core/v3/zodNamespace` export and the
legacy v3 socket message schemas. These were only used by the
now-retired v3 engine and have no v4 consumers.
([#4236](https://github.com/triggerdotdev/trigger.dev/pull/4236))
## @trigger.dev/python@4.5.4

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

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

### Patch Changes

- Fix a `chat.agent` message-loss race where sending a message right
after an action (such as an undo) could drop the follow-up's response
from the UI until a refresh.
([#4234](https://github.com/triggerdotdev/trigger.dev/pull/4234))
- Updated dependencies:
  - `@trigger.dev/core@4.5.4`

</details>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 10:07:58 +01:00
Iss 9f4d8d8b0c docs: update schedule & test navigation for the new dashboard UI (#4252)
The dashboard was redesigned and two pages moved, but the docs still
described the old sidebar:

- **Schedules** no longer has its own sidebar page — schedules are
managed from the **Tasks** page (open a scheduled task to create / view
/ edit / enable-disable / delete them).
- The standalone list-based **Test** page is deprecated — you test a
task from its own **Test** button now.

## Changes

- `tasks/scheduled.mdx`: rewrote the "attaching schedules" and "testing
schedules" sections for the Tasks-based flow, added a "managing
schedules in the dashboard" section, and added explicit callouts noting
both pages moved (so readers — and search — aren't pointed at a page
that no longer exists). Re-shot the four schedule screenshots and fixed
a mislabeled alt text.
- `run-tests.mdx`, `snippets/step-run-test.mdx`,
`guides/examples/sentry-error-tracking.mdx`: replaced "select the Test
page in the sidebar" with the task-first flow plus a callout, and
refreshed `test-dashboard.png`.

TRI-11939
2026-07-13 20:01:15 -04:00
Chris Arderne 64e5d732ad chore(webapp,core): remove the unused ResourceMonitor server logging helper (#4244)
The `ResourceMonitor` server-side logging helper is no longer used. It
periodically logged the webapp process own memory, disk, and CPU usage
behind the `RESOURCE_MONITOR_ENABLED` flag (off by default), and was
also exported from `@trigger.dev/core/v3/serverOnly` with no other
consumers.

This removes the helper, its `@trigger.dev/core` export, the webapp
wiring, and the `RESOURCE_MONITOR_ENABLED` env var. The supervisor has
its own unrelated `ResourceMonitor` class, which is left untouched.
2026-07-13 20:32:29 +01:00
Eric Allam 29598a77b8 feat(webapp): add option to disable PostgreSQL task-event writes (#4242)
## Summary

Adds `EVENT_REPOSITORY_POSTGRES_WRITES_DISABLED` (default off), which
makes the task-event store skip all PostgreSQL `TaskEvent` writes. It's
for deployments that store task events in ClickHouse
(`EVENT_REPOSITORY_DEFAULT_STORE=clickhouse_v2`) and no longer want the
PostgreSQL copy.

## How it works

The guard sits at the single postgres write boundary,
`TaskEventStore.create` / `createMany`, so it covers every write path
(OTLP ingestion and run-lifecycle events) with one check. Reads are
untouched (`findMany` / trace queries / streaming), so existing
PostgreSQL events remain readable.

Leave it off unless the default store is `clickhouse_v2`, otherwise task
events for any run still routed to PostgreSQL would be dropped.
2026-07-13 17:17:02 +01:00
Chris Arderne 6e943f2421 chore(cli): remove --mcp option from trigger dev (#4246) 2026-07-13 16:23:05 +01:00
Daniel Sutton e0b42a88d6 perf(webapp): avoid unindexed fileId scan in get-background-worker-by-version (#4245)
## What

The `GET
/api/v1/projects/:projectRef/background-workers/:envSlug/:version`
endpoint loaded each file's tasks through the nested `files.tasks`
relation. Prisma resolves that as a separate query:

```sql
SELECT id, slug, "fileId" FROM "BackgroundWorkerTask" WHERE "fileId" IN (...)
```

`BackgroundWorkerTask.fileId` is not indexed — the FK constraint exists,
but Postgres does not auto-create an index for foreign keys — so on a
large table this can only run as a sequential scan, which gets
progressively slower as the table grows and was observed taking minutes
per call in production.

The loader already loads every task for the worker via `tasks: true`,
which uses the indexed `workerId` relation, and those rows already
include `fileId`. This PR groups task slugs by `fileId` in memory from
that already-loaded data and drops the `files.tasks` include entirely.

## Behavior change (latent bug fix)

The response shape is unchanged, but there is a semantic correction for
**source files reused across worker versions** (files are de-duplicated
by `@@unique([projectId, contentHash])`, so one file row can be linked
to many workers).

- **Before:** `file.tasks` came from the `BackgroundWorkerFile.tasks`
relation, i.e. *every* `BackgroundWorkerTask` with that `fileId` —
across all workers sharing the file. So a worker's manifest could list
tasks it doesn't actually have.
- **After:** `file.tasks` is grouped from the queried worker's own
tasks, so it reflects only that worker version's tasks.

Verified on a local DB: 460 files are referenced by tasks from more than
one worker; of 6819 (worker, file) pairs, 6 differ — all one file where
the old union leaked a task slug (`cancellation-test`) into worker
versions that never had it. The new per-worker behavior is the correct
one for a worker-version manifest. (Thanks to the automated review for
flagging this.)

## Analysis

Captured the exact SQL before/after by instrumenting Prisma against real
data (a worker with 62 files):

- **Before:** 5 statements, including the `WHERE "fileId" IN (...)`
scan.
- **After:** 4 statements; the `fileId` query is gone and the other four
are identical.

EXPLAIN of the two access paths:

```
Before  WHERE "fileId" IN (...)
  Seq Scan on "BackgroundWorkerTask"
    Filter: ("fileId" = ANY (...))          -- reads the whole table, scales with table size

After   WHERE "workerId" IN (...)
  Index Scan using "BackgroundWorkerTask_workerId_slug_key"
    Index Cond: ("workerId" = ...)          -- bounded by matching rows, scale-independent
```

No new index is required: the `workerId` access path is already covered
by the existing `BackgroundWorkerTask_workerId_slug_key` unique index.

## Testing

- `pnpm run typecheck --filter webapp` passes.
- Query capture + EXPLAIN performed against a local database seeded with
real worker/file/task data.
2026-07-13 16:14:20 +01:00
Chris Arderne 703a6dcb4c chore(ci): optimise runners, distribute test shards (#4240)
- Use bigger/smaller runners as recommended by warpbuild
- Distribute test shards more evenly, move internal tests single big
shard
2026-07-13 15:58:33 +01:00
nicktrn 022e5c1ad0 chore(deps): pin transitive deps and upgrade nodemailer to 9 (#4243)
Routine dependency maintenance.

- Pin a few high-fanout transitive deps to current patched versions via
`pnpm.overrides`: `form-data`, `ws`, `undici`, `hono`. Lockfile-only (no
published-package dependency changes); net shrinks via dedup.
- Upgrade `nodemailer` 8 → 9 in `internal-packages/emails` (private
package). The SES transport already uses SESv2 and the
`createTransport`/`sendMail` API is unchanged, so no code changes were
needed. `@types/nodemailer` stays at 8 (no 9.x published yet; types are
compatible).

Verified locally: `pnpm i` clean; `pnpm run typecheck --filter emails`
and `--filter webapp` both pass.
2026-07-13 15:49:27 +01:00
Daniel Sutton bea7e2be90 feat(webapp,run-store): route run-graph reads and writes through the run-store router (#4237)
## Summary

Run-graph data (runs, batches, waitpoints, and their related tables) can
now live in a database separate from the control plane, with every read
and write routed to the correct database by each run's residency. This
makes reading and writing run data more reliable once the two are split,
and is a no-op for single-database installs.

## Design

- Run-graph table access goes through the run-store router, which
selects the legacy or the new run-ops store per run instead of assuming
one shared client.
- The legacy run-ops client is now independently pointable, so legacy
run data can be served from its own database (and replica) rather than
the control-plane connection.
- Run-graph writes go straight to the run-graph database instead of
being forwarded through the control plane, and replication targets are
split so runs in the new database still replicate to analytics without
under-counting.
- Read-through slots refuse the control-plane client, so a missing
residency fails loudly instead of silently reading the wrong database.
- Migration `20260710120000_drop_remaining_run_graph_seam_foreign_keys`
drops the foreign keys that still crossed the run-graph / control-plane
seam, which is what lets the two live in separate databases.

The split stays off unless explicitly enabled and the two databases are
confirmed physically distinct; startup fails closed otherwise.

Verified by running the full dashboard end-to-end suite against both a
single-database configuration and a three-database configuration
(control plane, the new database, and a physically separate legacy
database), with runs on both residencies. No misrouted reads in either
configuration.
2026-07-13 13:54:54 +01:00
Eric Allam c23585710c docs: note v3 is retired and 4.5.0 is the last version supporting v3 (#4241)
## Summary

Refreshes the docs for the v3 sunset: v3 (SDK v3) is end of life, and
4.5.0 is the last version we officially support for running v3.

- The self-hosting overview, plus the docker and kubernetes
version-locking sections, now tell self-hosters on v3 to stay on 4.5.0
or migrate to v4. 4.5.1 and later reject v3 triggers and deploys with an
upgrade message.
- The migration guide's deprecation notice was still written in the
future tense (with dates that have since passed); it now describes v3 as
retired and adds the self-hosted 4.5.0 cutoff. This is the page the
server's upgrade message links to.
- Fixes a stale "v3 project" reference in the CLI overview.

The Mintlify preview will render the callouts for a visual check.
2026-07-13 12:35:42 +01:00
Eric Allam 5ba8557a51 chore(webapp,core): remove the end-of-life v3 (engine V1) execution stack (#4236)
## Summary

v3 (the engine that ran the SDK v3 era, internally
`RunEngineVersion.V1`) is end-of-life. Following the removal of the v3
execution apps
([#4194](https://github.com/triggerdotdev/trigger.dev/pull/4194)) and
the legacy dev websocket
([#4198](https://github.com/triggerdotdev/trigger.dev/pull/4198)), this
removes the remaining v3 execution stack from the server.

Clients still on v3 (an old SDK or CLI that has not upgraded) keep
getting a clear "upgrade to v4" response. Triggers, batch triggers,
reschedules, and deploys that resolve to v3 are rejected with a graceful
4xx pointing at the migration guide, never a 5xx, so a stale client
cannot affect server health. Self-hosted instances still running v3
should stay on the 4.5.x release line until they migrate.

## What is removed

- The MarQS queue and its shared/dev queue consumers.
- The v3 socket.io namespaces (coordinator, provider, shared-queue) and
the v3 run lifecycle services (attempt, checkpoint, and batch-resume).
- The graphile-worker background job system; all live jobs already run
on `@trigger.dev/redis-worker`.
- The `DEPRECATE_V3_ENABLED` flag: v3 is now rejected unconditionally,
so the flag is gone.
- Unused v3 exports from `@trigger.dev/core` (the `v3/zodNamespace`
subpath and the legacy socket message catalogs) and the now-dead MarQS
environment variables.

## What stays

The v4 engine is untouched. The graceful v3 rejection boundary stays,
`determineEngineVersion` still detects a v3 project so it can reject it,
and the batch service plus batch-completion worker stay for current
clients. Live queue concurrency limits and metrics now read from the v4
run engine instead of MarQS, and a brand-new dev environment now
defaults to v4.



## Dependency cleanup

Removes webapp dependencies left unused by this change: `seedrandom` and
`semver` (only the removed v3 code used them) plus a set that was
already dead, their orphaned `@types` packages, and two dead files. Adds
a `knip:deps` script and a `knip.json` config so unused dependencies can
be found the same way going forward.
2026-07-13 11:32:06 +01:00
Chris Arderne c0f7c803b1 fix(webapp): return 415 for invalid SSO form content types (#4238)
## Summary

SSO form submissions with an unsupported content type now receive a 415
response instead of failing while parsing the request body.
2026-07-13 11:01:08 +01:00
Daniel Sutton c601739d35 perf(webapp,run-store): grouped run-ops reads + mint-kind flip grace (#4227)
## Summary

Two threads on the run-ops split path.

Read path: per-item run reads are batched into grouped queries, a
waitpoint's connected-run reads are bounded, and the dedicated-schema
relation hydrators fetch only the requested columns instead of whole
rows. Retrieve also falls back to the other database when a routed read
misses, so a run whose physical residency diverges from its id shape is
still found rather than returning a spurious not-found. Fewer and
lighter queries on the run read path, with no change to results.

Mint-kind flip safety: flipping which database new runs mint to is now a
deterministic wall-clock cutover, for both per-org and global flips. For
a grace window every process resolves the same database, so a flip
cannot route two concurrent triggers that share an idempotency key to
different databases (which would bypass the per-database unique
constraint and create a duplicate run).

Supersedes the earlier #4205 and #4208.

Draft: validation in progress.
2026-07-13 10:17:12 +01:00
Saadi Myftija 5f2541d94f feat(webapp): make native build server the default in build settings (#3980)
Switches the native build server from opt-in to opt-out in project build
settings.

- It's now enabled by default, stored as a new
\`disableNativeBuildServer\` opt-out key so previously-saved
\`useNativeBuildServer: false\` values aren't treated as deliberate
opt-outs.
- The "Use native build server" checkbox is checked by default;
unchecking it persists the opt-out.
- Brief wording: clarifies build settings apply to GitHub-triggered and
native build server deployments, and the native build server hint no
longer says "in the future".
2026-07-13 11:05:46 +02:00
Chris Arderne fda8e77175 fix(docs): openapi labels for different bulk api variants (#4223)
Replace Option 1 Option 2 etc with labelled variants.
2026-07-13 09:39:33 +01:00
Eric Allam 45527e317a feat(webapp): opt-in worker pool for OTLP ingest transform (#4232)
## Summary

Under high OTLP ingest volume, the whole decode, transform, and enrich
pipeline runs on the request event loop, so a single CPU core becomes
the ceiling while the rest sit idle. This adds an opt-in worker pool
that moves decode, transform, and LLM-cost enrichment onto worker
threads, keeping the main thread free for I/O. It is off by default
(`OTEL_TRANSFORM_WORKER_POOL_ENABLED`), so behavior is unchanged unless
enabled.

## Design

Workers do decode, filter, convert, and enrich (including LLM pricing
match). The main thread stays the single database reader: it loads the
pricing registry and broadcasts the compiled model rows to the workers
(re-broadcasting on every reload), so workers never touch the database.
The pure transform is extracted into a dependency-light module (no
Prisma/Redis/ClickHouse imports) so it can run inside a worker.

Importantly, the main thread keeps the existing single consolidated
insert path, so ClickHouse insert batching and part count are unchanged.
The parallelism buys CPU headroom, not more insert streams (which would
add merge pressure).

The worker is bundled as a standalone file at build time and ships in
the existing image with no Dockerfile change. In local load testing the
pool sustained roughly 2.6x the throughput of the single-thread path and
kept the main thread responsive under load.
2026-07-11 13:38:12 +01:00
Eric Allam 9b3a7bd7b2 fix(sdk,webapp): stop chat losing a message sent right after an action (#4234)
## Summary

Sending a chat message immediately after an action (for example an undo)
could make the message's response vanish from the UI. The transport
opened a response stream that closed on the *earlier* turn's completion
instead of waiting for the send's own turn. The agent still produced and
persisted the answer, so it reappeared on refresh. Same "disappearing
message" class as
[#4176](https://github.com/triggerdotdev/trigger.dev/pull/4176),
different cause.

## Fix

A send's response stream had no way to tell whether a `turn-complete`
belonged to its turn. `POST /realtime/v1/sessions/:id/in/append` now
returns the appended record's sequence number, and the transport skips
any turn-complete whose `session-in-event-id` (the agent's committed
`.in` cursor) is below that seq, closing only on its own turn. Older
webapps omit the seq, in which case the transport falls back to the
previous behavior, so the SDK and server can ship independently.

Because the fix spans the SDK and the server, both a webapp deploy and
an SDK release are needed for the full effect.

Verified end to end with the ai-chat reference app:
undo-then-immediate-send loses the follow-up's answer before the fix and
streams it inline after, with a revert-the-guard run reproducing the
loss on the same script. Unit tests cover the skip and the no-seq
fallback.
2026-07-11 12:46:24 +01:00
Eric Allam 5d0e9d9dc5 feat(webapp): make the default realtime backend configurable (#4231)
## Summary

The default realtime backend was hardcoded to Electric. This adds a
`REALTIME_BACKEND_DEFAULT` env var (`electric` | `native` | `shadow`,
default `electric`) that chooses the backend for any environment whose
org has no `realtimeBackend` override. Behavior is unchanged unless you
set it; per-org overrides still win.

The default is applied at every point where the per-org flag falls
through: the initial value, the flag lookup default, and the error
fallback.
2026-07-11 09:36:09 +01:00
Matt Aitken 2cac63f13a fix: improve error labelling, grouping, and stack traces in the Errors feature (#4225)
## Problem

Several display/grouping issues in the **Errors** feature, all rooted in
how the ClickHouse error materialized views (`errors_mv_v1`,
`error_occurrences_mv_v1`) read the stored error JSON produced by
`parseError`:

1. **Messageless errors show "Unknown error".** An empty message falls
straight through `coalesce(nullIf(message,''), 'Unknown error')` to the
literal, even though the error's class `name` is available (e.g. an
Effect tagged error `ListMessagesError` with no message).
2. **Unrelated errors collapse into one group.**
`calculateErrorFingerprint` keys on `type : message : stack`, where
`type` is always the union tag (`BUILT_IN_ERROR`, …), `message` is
empty, and the stack isn't read — so every messageless built-in error
(and every string/custom error) hashes to the same constant input → one
fingerprint.
3. **error_type shows the internal tag.** `coalesce(type, name, …)`
always resolves to `type` (always present), so the column shows
`BUILT_IN_ERROR` instead of the real class name.
4. **Stack traces never populate.** The MVs read `error.data.stack`, but
the serializer stores the trace under `stackTrace` — so the column is
always empty.

## Fix

All display changes are `ALTER TABLE … MODIFY QUERY` on the two views
(migration `035`); the fingerprint change is in the webapp.

- **Fingerprint** (`errorFingerprinting.ts`): fall back **message → name
→ raw**. Messageless errors now group by class name (or raw value for
non-Error throws); message-bearing errors are **unchanged**
(short-circuits at `message`), so existing groups don't split — only
currently-messageless errors get their own group going forward.
- **error_message**: same `message → name → raw` fallback before
`'Unknown error'`.
- **error_type**: coalesce `name → code → 'Error'` (drops the reliance
on the union tag). Built-in → class name, internal → `code`,
string/custom → `Error`.
- **stack trace**: read `error.data.stackTrace`. Bounded as before
(serializer caps 50 frames / 1024 chars per line; MV clips to 2000
chars).

## Migration notes

- `MODIFY QUERY` swaps the view query in place (no drop/recreate gap);
Down restores the previous query.
- **Existing rows are left unchanged** — changes apply only to rows
inserted after the migration. No backfill.

## Tests

`errorFingerprinting.test.ts` — 57 pass, incl. new cases for messageless
class names, string/custom raw values, and stability of message-bearing
fingerprints.

Fixes the display-derivation half of TRI-11938 (error_type + stack
trace); relates to TRI-9254 and TRI-9250.
2026-07-10 18:30:02 +01:00
Oskar Otwinowski 4be32d411c fix(webapp): keep the last Owner on directory-sync role changes (#4230)
Applying a directory-sync effect that would demote the org's last Owner
(a
group remap, or a provision) previously threw and 500'd the settings
save. Now
rbac.setUserRole reports code:"last_owner" and applyEffect skips just
that
member (they keep Owner) while the rest of the batch applies.

Adds the machine-readable RoleAssignmentResult.code to the plugin
contract so
callers can tell the last-owner guard apart from a real failure.
2026-07-10 19:28:16 +02:00
Matt Aitken b64b54c74e feat(webapp): pass database writer and reader config to auth plugins (#4229)
## Summary

The RBAC and SSO auth plugins can own their own database client, but
they could only read `DATABASE_URL`, so every connection they opened
landed on the primary. The host webapp now resolves writer and
read-replica URLs from its env (the same fallback chain its own Prisma
clients use: control-plane URL first, then the default) and passes them
to the plugins at create time via a shared `PluginDatabaseConfig`, along
with separate connection limits for writes (default 2) and reads
(default 5, tunable via `RBAC_DATABASE_*_CONNECTION_LIMIT` and
`SSO_DATABASE_*_CONNECTION_LIMIT`).

A plugin can then route hot-path reads (per-request auth checks, login
routing) to the read replica and keep only rare mutations on the
primary. With no replica configured, or no plugin installed, nothing
changes: the OSS fallback ignores the new option and keeps reading
through the Prisma clients it is already given.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-10 17:25:42 +01:00
DKP 25eb0c71a0 fix(webapp): clarify that region only affects where runs execute (#4226)
## Summary

This adds an always-visible info tooltip on the Location column and a
note in the "set default region" confirmation dialog making it explicit.
It also removes the obsolete "V4" badge from the Regions page title.
2026-07-10 17:02:36 +01:00
Matt Aitken 48a0b83ec6 feat(webapp): promo credits — /promo signup landing, redeem at plan selection, usage display (#4138)
## What & why

Signup promo credits. A new logged-out `/promo?code=<code>` landing page
validates the code and carries it through signup via a cookie. When the
new organization is activated by selecting a plan, the code is redeemed
and its credits are applied; the usage page then shows the remaining
promo credits and their expiry.

## Notes

- The code is redeemed at **plan selection**, not org creation: the
credit grant targets the org's usage allowance, which only exists once a
plan is selected — applying at creation would have nothing to grant
onto. Redemption is best-effort and never blocks plan selection.
- Pairs with the corresponding billing-service change (promo code
validate/apply/credits + grant issuance); the two are released together.

## Testing

Verified locally end to end: `/promo` shows the offer, a new account
carries the code through signup, selecting the Free plan redeems it, and
the usage page shows the remaining credits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-10 17:48:39 +02:00
767 changed files with 49076 additions and 31801 deletions
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output.
-5
View File
@@ -1,5 +0,0 @@
---
"@trigger.dev/build": patch
---
You can now mark environment variables synced via the `syncEnvVars` build extension as secrets. Return `{ name, value, isSecret: true }` from your callback and those variables are stored redacted in the dashboard, just like manually created secret env vars.
+14 -21
View File
@@ -3,31 +3,24 @@ paths:
- "apps/webapp/app/v3/**"
---
# Legacy V1 Engine Code in `app/v3/`
# v3 (engine V1) has been removed
The `v3/` directory name is misleading - most code here is actively used by the current V2 engine. Only the specific files below are legacy V1-only code.
The v3 engine (RunEngineVersion `V1`: MarQS queue + Graphile worker) is end-of-life and its execution code has been removed from the webapp. The `app/v3/` directory name is historical: everything under it now serves the current V2 engine (`@internal/run-engine` + `@trigger.dev/redis-worker`).
## V1-Only Files - Never Modify
There is no `V1` execution path anymore. If you find a `RunEngineVersion` branch, the `V1` arm should only reject or finalize gracefully (for example, mark a historical run cancelled in the DB), never run V1 work. Do not reintroduce MarQS, the graphile worker, or the v3 socket.io namespaces.
- `marqs/` directory (entire MarQS queue system: sharedQueueConsumer, devQueueConsumer, fairDequeuingStrategy, devPubSub)
- `legacyRunEngineWorker.server.ts` (V1 background job worker)
- `services/triggerTaskV1.server.ts` (deprecated V1 task triggering)
- `services/cancelTaskRunV1.server.ts` (deprecated V1 cancellation)
- `authenticatedSocketConnection.server.ts` (V1 dev WebSocket using DevQueueConsumer)
- `sharedSocketConnection.ts` (V1 shared queue socket using SharedQueueConsumer)
## The deprecation boundary (keep this)
## V1/V2 Branching Pattern
Requests from clients still on v3 (old SDK/CLI) or historical V1 runs must return a clean 4xx, never a 5xx. The boundary lives in:
Some services act as routers that branch on `RunEngineVersion`:
- `services/cancelTaskRun.server.ts` - calls V1 service or `engine.cancelRun()` for V2
- `services/batchTriggerV3.server.ts` - uses marqs for V1 path, run-engine for V2
- `engineDeprecation.server.ts` - the `V3_TRIGGER_DEPRECATION_MESSAGE` / `V3_DEV_DEPRECATION_MESSAGE` / `V3_MIGRATION_URL` upgrade messages.
- `engineVersion.server.ts` - `determineEngineVersion()` still detects a V1 project/run so callers can reject it.
- `services/triggerTask.server.ts`, `services/cancelTaskRun.server.ts`, `services/rescheduleTaskRun.server.ts` - the `V1` arm rejects or finalizes gracefully instead of executing.
- `services/initializeDeployment.server.ts` - the `DEPRECATE_V3_CLI_DEPLOYS_ENABLED`-gated v3 CLI deploy rejection.
- `handleWebsockets.server.ts` - the legacy `trigger dev` websocket closes with the upgrade message.
When editing these shared services, only modify V2 code paths.
## V2 modern stack
## V2 Modern Stack
- **Run lifecycle**: `@internal/run-engine` (internal-packages/run-engine)
- **Background jobs**: `@trigger.dev/redis-worker` (not graphile-worker/zodworker)
- **Queue operations**: RunQueue inside run-engine (not MarQS)
- **V2 engine singleton**: `runEngine.server.ts`, `runEngineHandlers.server.ts`
- **V2 workers**: `commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`
- **Run lifecycle**: `@internal/run-engine` (`runEngine.server.ts`, `runEngineHandlers.server.ts`)
- **Background jobs**: `@trigger.dev/redis-worker` (`commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`; `legacyRunEngineWorker.server.ts` still hosts the live batch-completion jobs)
- **Queue operations**: RunQueue inside run-engine (`runQueue.server.ts`), not MarQS
+3 -1
View File
@@ -14,10 +14,12 @@ area: webapp
type: fix
---
Brief description of what changed and why.
Fix pages occasionally loading unstyled during deploys. The dashboard now recovers automatically.
EOF
```
- **area**: `webapp` | `supervisor`
- **type**: `feature` | `fix` | `improvement` | `breaking`
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
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
View File
@@ -26,7 +26,6 @@
"esModuleInterop": true,
"emitDecoratorMetadata": false,
"experimentalDecorators": false,
"downlevelIteration": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
+3 -2
View File
@@ -2,11 +2,12 @@
SESSION_SECRET=abcdef1234
MAGIC_LINK_SECRET=abcdef1234
ENCRYPTION_KEY=ae13021afef0819c3a307ad487071c06 # Must be a random 16 byte hex string. You can generate an encryption key by running `openssl rand -hex 16` in your terminal
MANAGED_WORKER_SECRET=abcdef1234 # Must match the supervisor's MANAGED_WORKER_SECRET
LOGIN_ORIGIN=http://localhost:3030
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
# This sets the URL used for direct connections to the database and should only be needed in limited circumstances
# See: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#fields:~:text=the%20shadow%20database.-,directUrl,-No
DIRECT_URL=${DATABASE_URL}
DIRECT_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
# Dedicated run-ops database (@internal/run-ops-database). Only needed to run prisma commands
# against it or to enable the run-ops split; start it with `docker compose --profile runops up`.
RUN_OPS_DATABASE_URL=postgresql://postgres:postgres@localhost:5434/postgres?schema=public
@@ -166,4 +167,4 @@ POSTHOG_PROJECT_KEY=
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: Install dependencies
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: Install + build the CLI and the agent's deps
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 🐳 Login to DockerHub
+2 -2
View File
@@ -14,7 +14,7 @@ on:
jobs:
e2eTests:
name: "🧪 E2E Tests: Webapp"
runs-on: warp-ubuntu-latest-x64-8x
runs-on: warp-ubuntu-latest-x64-16x
timeout-minutes: 20
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -59,7 +59,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [warp-ubuntu-latest-x64-4x, warp-windows-latest-x64-4x]
os: [warp-ubuntu-latest-x64-4x, warp-windows-latest-x64-8x]
package-manager: ["npm", "pnpm"]
steps:
- name: ⬇️ Checkout repo
@@ -37,7 +37,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
+3 -1
View File
@@ -52,12 +52,14 @@ jobs:
- name: Lint Helm Chart
run: |
helm lint ./hosting/k8s/helm/
helm lint ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/ci/lint-values.yaml
- name: Render templates
run: |
helm template test-release ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/values.yaml \
--values ./hosting/k8s/helm/ci/lint-values.yaml \
--output-dir ./helm-output
- name: Validate manifests
+7
View File
@@ -56,6 +56,7 @@ jobs:
- '.github/workflows/pr_checks.yml'
- '.github/workflows/unit-tests-webapp.yml'
- '.github/workflows/e2e-webapp.yml'
- '.github/workflows/runops-guard.yml'
- '.configs/**'
- 'package.json'
- 'pnpm-lock.yaml'
@@ -111,6 +112,11 @@ jobs:
if: needs.changes.outputs.code == 'true' || needs.changes.outputs.typecheck_self == 'true'
uses: ./.github/workflows/typecheck.yml
runops-guard:
needs: changes
if: needs.changes.outputs.webapp == 'true'
uses: ./.github/workflows/runops-guard.yml
webapp:
needs: changes
if: needs.changes.outputs.webapp == 'true'
@@ -161,6 +167,7 @@ jobs:
- changes
- code-quality
- typecheck
- runops-guard
- webapp
- e2e-webapp
- packages
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Install dependencies
+13
View File
@@ -13,6 +13,13 @@ on:
type: string
required: false
default: ""
outputs:
version:
description: The published image tag
value: ${{ jobs.build.outputs.version }}
image_repo:
description: The image repository the build was published to (without tag)
value: ${{ jobs.build.outputs.image_repo }}
push:
tags:
- "re2-test-*"
@@ -38,6 +45,11 @@ jobs:
matrix:
package: [supervisor]
runs-on: warp-ubuntu-latest-x64-2x
# Single-entry matrix, so these job outputs are unambiguous (consumed by the
# scan-supervisor job in publish.yml).
outputs:
version: ${{ steps.get_tag.outputs.tag }}
image_repo: ${{ steps.set_tags.outputs.image_repo }}
env:
DOCKER_BUILDKIT: "1"
steps:
@@ -81,6 +93,7 @@ jobs:
fi
echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT"
echo "image_repo=${ref_without_tag}" >> "$GITHUB_OUTPUT"
env:
IMAGE_REGISTRY: ${{ inputs.image_registry || vars.IMAGE_REGISTRY || format('ghcr.io/{0}', github.repository_owner) }}
STEPS_GET_REPOSITORY_OUTPUTS_REPO: ${{ steps.get_repository.outputs.repo }}
+10 -1
View File
@@ -97,10 +97,19 @@ jobs:
permissions:
contents: read
packages: read # pull the just-published image from GHCR
uses: ./.github/workflows/trivy-image-webapp.yml
uses: ./.github/workflows/trivy-image.yml
with:
image-ref: ${{ needs.publish-webapp.outputs.image_repo }}:${{ needs.publish-webapp.outputs.version }}
scan-supervisor:
needs: [publish-worker-v4]
permissions:
contents: read
packages: read # pull the just-published image from GHCR
uses: ./.github/workflows/trivy-image.yml
with:
image-ref: ${{ needs.publish-worker-v4.outputs.image_repo }}:${{ needs.publish-worker-v4.outputs.version }}
# Announce the freshly published mutable `main` webapp image to subscriber
# repos via repository_dispatch, handing them a digest-pinned ref to build or
# deploy from. The repo, ref prefix, and dispatch target all default to the
+3 -1
View File
@@ -47,12 +47,14 @@ jobs:
- name: Lint Helm Chart
run: |
helm lint ./hosting/k8s/helm/
helm lint ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/ci/lint-values.yaml
- name: Render templates
run: |
helm template test-release ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/values.yaml \
--values ./hosting/k8s/helm/ci/lint-values.yaml \
--output-dir ./helm-output
- name: Validate manifests
+4 -4
View File
@@ -48,7 +48,7 @@ jobs:
release:
name: 🚀 Release npm packages
runs-on: ubuntu-latest
runs-on: ubuntu-latest # this cannot run on non-GH runner
environment: npm-publish
permissions:
contents: write
@@ -90,7 +90,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
@@ -281,7 +281,7 @@ jobs:
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
prerelease:
name: 🧪 Prerelease
runs-on: ubuntu-latest
runs-on: ubuntu-latest # this cannot run on non-GH runner
environment: npm-publish
permissions:
contents: read
@@ -303,7 +303,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
+38
View File
@@ -0,0 +1,38 @@
name: "🛡️ Run-ops Legacy Guard"
on:
workflow_call:
permissions:
contents: read
jobs:
runops-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: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🛡️ Run-ops legacy guard
run: pnpm --filter webapp run guard:runops-legacy -- --check
+3 -3
View File
@@ -70,7 +70,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 🥟 Setup Bun
@@ -112,7 +112,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 🦕 Setup Deno
@@ -158,7 +158,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
@@ -1,7 +1,7 @@
name: Trivy Image Scan (webapp)
name: Trivy Image Scan
# OS-level CVE scan of a published webapp image. Called by the publish pipeline
# (publish.yml) to scan each build right after it's pushed to GHCR — so every
# OS-level CVE scan of a published image. Called by the publish pipeline
# (publish.yml) to scan each image right after it's pushed to GHCR — so every
# main build and every release is scanned, not rebuilt. Also runnable ad-hoc
# via workflow_dispatch against any image ref.
#
@@ -27,7 +27,7 @@ on:
permissions: {}
concurrency:
group: trivy-image-webapp-${{ inputs.image-ref }}
group: trivy-image-${{ inputs.image-ref }}
cancel-in-progress: true
jobs:
@@ -59,7 +59,7 @@ jobs:
ignore-unfixed: true
severity: HIGH,CRITICAL
format: table
output: trivy-image-webapp.txt
output: trivy-image.txt
- name: Job summary
if: always()
@@ -67,9 +67,9 @@ jobs:
IMAGE_REF: ${{ inputs.image-ref }}
run: |
{
echo "## Trivy Image Scan (webapp) — \`${IMAGE_REF}\`"
echo "## Trivy Image Scan — \`${IMAGE_REF}\`"
echo '```'
# GitHub step summary is capped at 1 MiB; truncate large reports.
head -c 900000 trivy-image-webapp.txt 2>/dev/null || echo "(no report produced)"
head -c 900000 trivy-image.txt 2>/dev/null || echo "(no report produced)"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+2 -2
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
typecheck:
runs-on: warp-ubuntu-latest-x64-8x
runs-on: warp-ubuntu-latest-x64-16x
steps:
- name: ⬇️ Checkout repo
@@ -25,7 +25,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+36 -51
View File
@@ -14,17 +14,14 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests: Internal"
runs-on: warp-ubuntu-latest-x64-8x
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]
# Single big machine instead of a 12-job matrix: the internal suites are serial
# (fileParallelism: false) and container-wait-bound, so 12 in-machine shard processes
# fit comfortably in 32 vCPUs while paying the setup cost (install, prisma generate,
# image pulls) once instead of 12 times.
runs-on: warp-ubuntu-latest-x64-32x
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
SHARD_INDEX: ${{ matrix.shardIndex }}
SHARD_TOTAL: ${{ matrix.shardTotal }}
SHARD_TOTAL: 12
steps:
- name: 🔧 Disable IPv6
run: |
@@ -66,7 +63,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -108,8 +105,34 @@ jobs:
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🧪 Run Internal Unit Tests
run: pnpm run test:internal --reporter=default --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --passWithNoTests
- name: 🏗️ Build test dependencies
# Build once up-front so the parallel shard runs below (turbo --only) never race
# to build or cache-restore the same outputs concurrently.
run: pnpm exec turbo run build --filter "@internal/*..."
- name: 🧪 Run Internal Unit Tests (${{ env.SHARD_TOTAL }} in-machine shards)
run: |
# Same shard partitioning as the old 12-job matrix (DurationShardingSequencer
# keys off --shard=i/N), but as parallel local processes. --only skips the
# ^build dependency handled by the step above.
status=0
declare -a pids
for i in $(seq 1 "$SHARD_TOTAL"); do
pnpm exec turbo run test --only --concurrency=1 --filter "@internal/*" -- \
--run --reporter=default --reporter=blob --shard="$i/$SHARD_TOTAL" --passWithNoTests \
> "/tmp/internal-shard-$i.log" 2>&1 &
pids[i]=$!
done
for i in $(seq 1 "$SHARD_TOTAL"); do
if ! wait "${pids[i]}"; then
status=1
echo "::error::internal unit test shard $i/$SHARD_TOTAL failed"
fi
echo "::group::🧪 shard $i/$SHARD_TOTAL"
cat "/tmp/internal-shard-$i.log"
echo "::endgroup::"
done
exit "$status"
- name: Gather all reports
if: ${{ !cancelled() }}
@@ -118,44 +141,6 @@ jobs:
find . -type f -path '*/.vitest-reports/blob-*.json' \
-exec bash -c 'src="$1"; basename=$(basename "$src"); pkg=$(dirname "$src" | sed "s|^\./||;s|/\.vitest-reports$||;s|/|_|g"); cp "$src" ".vitest-reports/${pkg}-${basename}"' _ {} \;
- name: Upload blob reports to GitHub Actions Artifacts
- name: 📊 Merge reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: internal-blob-report-${{ matrix.shardIndex }}
path: .vitest-reports/*
include-hidden-files: true
retention-days: 1
merge-reports:
name: "📊 Merge Reports"
if: ${{ !cancelled() }}
needs: [unitTests]
runs-on: warp-ubuntu-latest-x64-2x
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: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: .vitest-reports
pattern: internal-blob-report-*
merge-multiple: true
- name: Merge reports
run: pnpm dlx vitest@4.1.7 run --merge-reports --pass-with-no-tests
+2 -2
View File
@@ -66,7 +66,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -146,7 +146,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+10 -5
View File
@@ -14,13 +14,18 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests: Webapp"
runs-on: warp-ubuntu-latest-x64-8x
# 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.
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]
shardTotal: [10]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
shardTotal: [12]
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
SHARD_INDEX: ${{ matrix.shardIndex }}
@@ -66,7 +71,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -155,7 +160,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+3
View File
@@ -76,3 +76,6 @@ apps/**/public/build
ailogger-output.log
# per-package vitest timing capture (transient; merged into root test-timings.json)
.vitest-timing.json
# local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them
.worktrees/
+1 -1
View File
@@ -1 +1 @@
v22.23.1
v24.18.0
+21 -2
View File
@@ -1,7 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "react"],
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"],
"jsPlugins": [
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
"./oxlint-plugins/runops-residency.mjs"
],
"ignorePatterns": [
"**/dist/**",
"**/build/**",
@@ -34,5 +37,21 @@
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"trigger/no-thrown-unawaited-redirect": "error"
}
},
"overrides": [
{
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
"rules": {
"trigger-runops/no-control-plane-run-graph-access": "error",
"trigger-runops/no-control-plane-in-runops-slot": "error"
}
},
{
"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"
}
}
]
}
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Speed up the Batches list page for environments with a large number of batches, which could previously time out while loading.
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
List API endpoints now clamp the page size to a maximum of 100. Requests asking for a larger page size return up to 100 items and keep paginating, rather than pulling an unbounded page.
@@ -1,6 +0,0 @@
---
area: webapp
type: improvement
---
Clearer login error when an email address is blocked by the WHITELISTED_EMAILS setting: the message now explains the address isn't allowed on this instance instead of the ambiguous "This email is unauthorized".
@@ -1,6 +0,0 @@
---
area: webapp
type: fix
---
Fixed stale login errors: an error from a previous login attempt (for example a rejected email address) no longer keeps reappearing on the login page and no longer makes later, successful attempts look like they failed.
@@ -0,0 +1,6 @@
---
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.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs.
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
When you create a Personal Access Token, the generated token now shows its first and last few characters instead of being fully hidden, so you can confirm you copied the right value.
@@ -0,0 +1,6 @@
---
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: supervisor
type: improvement
---
Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor.
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
The runs list on a task's page now updates live — run statuses change and newly triggered runs appear without a manual refresh, matching the main Runs page.
@@ -1,6 +0,0 @@
---
area: webapp
type: fix
---
Query page: extracting fields from a run's output with JSON functions (such as JSONExtractString or JSONExtractInt) no longer fails with an "illegal type: JSON" error.
+2 -3
View File
@@ -138,11 +138,10 @@ User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Superv
- **internal-packages/redis**: Redis client creation utilities (ioredis)
- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers
- **internal-packages/schedule-engine**: Durable cron scheduling
- **internal-packages/zod-worker**: Graphile-worker wrapper (DEPRECATED - use redis-worker)
### Legacy V1 Engine Code
### v3 (engine V1) removed
The `apps/webapp/app/v3/` directory name is misleading - most code there is actively used by V2. Only specific files are V1-only legacy (MarQS queue, triggerTaskV1, cancelTaskRunV1, etc.). See `apps/webapp/CLAUDE.md` for the exact list. When you encounter V1/V2 branching in services, only modify V2 code paths. All new work uses Run Engine 2.0 (`@internal/run-engine`) and redis-worker.
v3 (engine V1: MarQS + Graphile worker) is end-of-life and its execution code has been removed. The `apps/webapp/app/v3/` directory name is historical - everything there now serves V2 (Run Engine 2.0, `@internal/run-engine` + redis-worker). There is no V1 execution path: a `RunEngineVersion` `V1` branch only rejects or finalizes gracefully so v3 clients get a clean 4xx, never a 5xx. Do not reintroduce V1. See `apps/webapp/CLAUDE.md` and `.claude/rules/legacy-v3-code.md`.
### Documentation
+3 -3
View File
@@ -29,7 +29,7 @@ branch are tagged into a release periodically.
### Prerequisites
- [Node.js](https://nodejs.org/en) version 22.23.1
- [Node.js](https://nodejs.org/en) version 24.18.0
- [pnpm package manager](https://pnpm.io/installation) version 10.33.2
- [Docker](https://www.docker.com/get-started/)
- [protobuf](https://github.com/protocolbuffers/protobuf)
@@ -49,7 +49,7 @@ branch are tagged into a release periodically.
```
cd trigger.dev
```
3. Ensure you are on the correct version of Node.js (22.23.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
3. Ensure you are on the correct version of Node.js (24.18.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
4. Run `corepack enable` to use the correct version of pnpm (`10.33.2`) as specified in the root `package.json` file.
@@ -181,7 +181,7 @@ pnpm exec trigger dev --log-level debug
6. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks.
7. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the reference project's `src/trigger` folder. Many of them accept an empty payload.
7. On the Tasks page, open a task and press the "Test" button to open its test page. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the reference project's `src/trigger` folder. Many of them accept an empty payload.
8. Feel free to add additional files in the reference project's `src/trigger` dir to test out specific aspects of the system, or add in edge cases.
-1
View File
@@ -23,7 +23,6 @@ This is a pnpm 10.33.2 monorepo that uses turborepo @turbo.json. The following w
- <root>/internal-packages/run-engine is the `@internal/run-engine` package that is "Run Engine 2.0" and handles moving a run all the way through it's lifecycle
- <root>/internal-packages/redis is the `@internal/redis` package that exports Redis types and the `createRedisClient` function to unify how we create redis clients in the repo. It's not used everywhere yet, but it's the preferred way to create redis clients from now on.
- <root>/internal-packages/testcontainers is the `@internal/testcontainers` package that exports a few useful functions for spinning up local testcontainers when writing vitest tests. See our [tests.md](./tests.md) file for more information.
- <root>/internal-packages/zodworker is the `@internal/zodworker` package that implements a wrapper around graphile-worker that allows us to use zod to validate our background jobs. We are moving away from using graphile-worker as our background job system, replacing it with our own redis-worker package.
## References
+2 -2
View File
@@ -1,8 +1,8 @@
# This needs to match the token of the worker group you want to connect to
TRIGGER_WORKER_TOKEN=
# This needs to match the MANAGED_WORKER_SECRET env var on the webapp
MANAGED_WORKER_SECRET=managed-secret
# Must match the webapp's MANAGED_WORKER_SECRET. Generate with: openssl rand -hex 16
MANAGED_WORKER_SECRET=
# Point this at the webapp in prod
TRIGGER_API_URL=http://localhost:3030
+1 -1
View File
@@ -1 +1 @@
v22.23.1
v24.18.0
+1 -1
View File
@@ -7,7 +7,7 @@ Node.js app that manages task execution containers. Receives work from the platf
- `src/services/` - Core service logic
- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes)
- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots)
- `src/clients/` - Platform communication (webapp/coordinator)
- `src/clients/` - Platform communication (webapp)
- `src/env.ts` - Environment configuration
## Architecture
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:22-alpine@sha256:9bef0ef1e268f60627da9ba7d7605e8831d5b56ad07487d24d1aa386336d1944 AS node-22-alpine
FROM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS node-24-alpine
WORKDIR /app
FROM node-22-alpine AS pruner
FROM node-24-alpine AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@2.10.0 prune --scope=supervisor --docker
FROM node-22-alpine AS base
FROM node-24-alpine AS base
RUN apk add --no-cache dumb-init
+19 -1
View File
@@ -14,8 +14,17 @@ export const Env = z
// Required settings
TRIGGER_API_URL: z.string().url(),
TRIGGER_WORKER_TOKEN: z.string(), // accepts file:// path to read from a file
TRIGGER_WORKER_TOKEN: z.string().min(1), // accepts file:// path to read from a file
MANAGED_WORKER_SECRET: z.string(),
// Deployment token: sign a token into TRIGGER_DEPLOYMENT_ID at pod creation and verify it on
// inbound workload calls. "disabled" = off; "log" = mint + verify + metrics only; "enforce" =
// also reject invalid tokens.
WORKLOAD_TOKEN_SECRET: z.string().optional(),
WORKLOAD_TOKEN_ENFORCEMENT: z.enum(["disabled", "log", "enforce"]).default("disabled"),
// 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"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), // set on the runners
// Workload API settings (coordinator mode) - the workload API is what the run controller connects to
@@ -103,6 +112,7 @@ export const Env = z
// Optional services
TRIGGER_WARM_START_URL: z.string().optional(),
TRIGGER_WARM_START_DISPATCH_URL: z.string().optional(),
TRIGGER_CHECKPOINT_URL: z.string().optional(),
TRIGGER_METADATA_URL: z.string().optional(),
@@ -365,6 +375,14 @@ export const Env = z
path: ["TRIGGER_WORKLOAD_API_DOMAIN"],
});
}
if (data.WORKLOAD_TOKEN_ENFORCEMENT !== "disabled" && !data.WORKLOAD_TOKEN_SECRET) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"WORKLOAD_TOKEN_SECRET is required when WORKLOAD_TOKEN_ENFORCEMENT is not disabled",
path: ["WORKLOAD_TOKEN_SECRET"],
});
}
if (
data.TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED &&
!data.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST
+59 -3
View File
@@ -22,11 +22,12 @@ import {
isKubernetesEnvironment,
} from "@trigger.dev/core/v3/serverOnly";
import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js";
import { collectDefaultMetrics, Gauge, Histogram } from "prom-client";
import { collectDefaultMetrics, Counter, Gauge, Histogram } from "prom-client";
import { register } from "./metrics.js";
import { PodCleaner } from "./services/podCleaner.js";
import { FailedPodHandler } from "./services/failedPodHandler.js";
import { getWorkerToken } from "./workerToken.js";
import { mintDeploymentToken } from "./workloadToken.js";
import { OtlpTraceService } from "./services/otlpTraceService.js";
import {
WarmStartVerificationService,
@@ -59,6 +60,21 @@ const workloadCreateDuration = new Histogram({
registers: [register],
});
const outboundRequestsTotal = new Counter({
name: "supervisor_outbound_request_total",
help: "Count of outbound HTTP requests from the supervisor, by target name, method, response status, and outcome (ok, http_error, invalid_response, network_error).",
labelNames: ["name", "method", "status", "outcome"],
registers: [register],
});
const outboundRequestDuration = new Histogram({
name: "supervisor_outbound_request_duration_seconds",
help: "Duration of outbound HTTP requests from the supervisor, by target name and outcome. Includes the HTTP client's internal retries and backoff.",
labelNames: ["name", "outcome"],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 11, 12.5, 15, 20, 30, 60],
registers: [register],
});
class ManagedSupervisor {
private readonly workerSession: SupervisorSession;
private readonly metricsServer?: HttpServer;
@@ -79,6 +95,8 @@ class ManagedSupervisor {
private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED);
private readonly warmStartUrl = env.TRIGGER_WARM_START_URL;
private readonly warmStartDispatchUrl =
env.TRIGGER_WARM_START_DISPATCH_URL ?? env.TRIGGER_WARM_START_URL;
private readonly wideEventOpts: WideEventOptions = {
service: "supervisor",
@@ -96,6 +114,7 @@ class ManagedSupervisor {
COMPUTE_GATEWAY_AUTH_TOKEN,
DOCKER_REGISTRY_PASSWORD,
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PASSWORD,
WORKLOAD_TOKEN_SECRET,
...envWithoutSecrets
} = env;
@@ -120,6 +139,7 @@ class ManagedSupervisor {
snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS,
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS,
checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL,
} satisfies WorkloadManagerOptions;
this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED
@@ -289,8 +309,10 @@ class ManagedSupervisor {
});
}
const workerToken = getWorkerToken();
this.workerSession = new SupervisorSession({
workerToken: getWorkerToken(),
workerToken,
apiUrl: env.TRIGGER_API_URL,
instanceName: env.TRIGGER_WORKER_INSTANCE_NAME,
managedWorkerSecret: env.MANAGED_WORKER_SECRET,
@@ -317,6 +339,10 @@ class ManagedSupervisor {
runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED,
heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS,
sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS,
onHttpRequestComplete: ({ name, method, status, outcome, durationMs }) => {
outboundRequestsTotal.inc({ name, method, status, outcome });
outboundRequestDuration.observe({ name, outcome }, durationMs / 1000);
},
preDequeue: async () => {
// Synchronous, hot-path-safe cached read; false when no monitors are active.
const skipForBackpressure = this.backpressureMonitors.some((m) => m.shouldSkipDequeue());
@@ -568,6 +594,7 @@ class ManagedSupervisor {
checkpointClient: this.checkpointClient,
computeManager: this.computeManager,
tracing: this.tracing,
snapshotCallbackSecret: workerToken,
wideEventOpts: this.wideEventOpts,
wideEventsNoisyRoutes: this.wideEventsNoisyRoutes,
});
@@ -602,6 +629,15 @@ class ManagedSupervisor {
throw new Error("Image is missing");
}
const deploymentToken = await mintDeploymentToken({
deployment: message.deployment.friendlyId,
deployment_version: message.backgroundWorker.version,
environment_id: message.environment.id,
environment_type: message.environment.type,
org_id: message.organization.id,
project_id: message.project.id,
});
await this.workloadManager.create({
dequeuedAt: message.dequeuedAt,
dequeueResponseMs: timings.dequeueResponseMs,
@@ -615,6 +651,8 @@ class ManagedSupervisor {
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runtime: message.backgroundWorker.runtime,
deploymentToken,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -662,7 +700,7 @@ class ManagedSupervisor {
return false;
}
const warmStartUrlWithPath = new URL("/warm-start", this.warmStartUrl);
const warmStartUrlWithPath = new URL("/warm-start", this.warmStartDispatchUrl);
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -675,6 +713,18 @@ class ManagedSupervisor {
headers.traceparent = traceparent;
}
const requestStart = performance.now();
const record = (
status: string,
outcome: "ok" | "http_error" | "invalid_response" | "network_error"
) => {
outboundRequestsTotal.inc({ name: "warm_start", method: "POST", status, outcome });
outboundRequestDuration.observe(
{ name: "warm_start", outcome },
(performance.now() - requestStart) / 1000
);
};
try {
const res = await fetch(warmStartUrlWithPath.href, {
method: "POST",
@@ -683,8 +733,10 @@ class ManagedSupervisor {
});
if (!res.ok) {
record(String(res.status), "http_error");
this.logger.error("Warm start failed", {
runId: dequeuedMessage.run.id,
statusCode: res.status,
});
return false;
}
@@ -693,6 +745,7 @@ class ManagedSupervisor {
const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data);
if (!parsedData.success) {
record(String(res.status), "invalid_response");
this.logger.error("Warm start response invalid", {
runId: dequeuedMessage.run.id,
data,
@@ -700,8 +753,11 @@ class ManagedSupervisor {
return false;
}
record(String(res.status), "ok");
return parsedData.data.didWarmStart;
} catch (error) {
record("none", "network_error");
this.logger.error("Warm start error", {
runId: dequeuedMessage.run.id,
error,
@@ -20,13 +20,26 @@ function createService() {
snapshot,
} as unknown as ComputeWorkloadManager;
const submitSuspendCompletion = vi.fn(async () => ({ success: true }));
const service = new ComputeSnapshotService({
computeManager,
workerClient: {} as SupervisorHttpClient,
workerClient: { submitSuspendCompletion } as unknown as SupervisorHttpClient,
wideEventOpts: { service: "supervisor-test", env: {}, enabled: false },
snapshotCallbackSecret: "test-secret",
});
return { service, snapshot };
return { service, snapshot, submitSuspendCompletion };
}
function dispatchedMetadata(snapshot: {
mock: { calls: Array<Array<{ metadata?: Record<string, string> }>> };
}) {
const metadata = snapshot.mock.calls[0]?.[0]?.metadata;
if (!metadata) {
throw new Error("Snapshot was not dispatched");
}
return metadata;
}
function delayedSnapshot(runnerId = "runner-1") {
@@ -38,6 +51,24 @@ function delayedSnapshot(runnerId = "runner-1") {
}
describe("ComputeSnapshotService", () => {
it("refuses to construct with an empty callback secret", () => {
const computeManager = {
snapshotDelayMs: DELAY_MS,
snapshotDispatchLimit: 1,
snapshot: vi.fn(async () => true),
} as unknown as ComputeWorkloadManager;
expect(
() =>
new ComputeSnapshotService({
computeManager,
workerClient: {} as SupervisorHttpClient,
wideEventOpts: { service: "supervisor-test", env: {}, enabled: false },
snapshotCallbackSecret: "",
})
).toThrow();
});
it("dispatches a scheduled snapshot after the delay", async () => {
const { service, snapshot } = createService();
try {
@@ -46,7 +77,12 @@ describe("ComputeSnapshotService", () => {
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 });
expect(snapshot).toHaveBeenCalledWith({
runnerId: "runner-1",
metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_1" },
metadata: expect.objectContaining({
runId: "run_1",
snapshotFriendlyId: "snapshot_1",
snapshotCallbackNonce: expect.any(String),
snapshotCallbackToken: expect.any(String),
}),
});
} finally {
service.stop();
@@ -121,10 +157,86 @@ describe("ComputeSnapshotService", () => {
expect(snapshot).toHaveBeenCalledTimes(1);
expect(snapshot).toHaveBeenCalledWith({
runnerId: "runner-1",
metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_2" },
metadata: expect.objectContaining({
runId: "run_1",
snapshotFriendlyId: "snapshot_2",
snapshotCallbackNonce: expect.any(String),
snapshotCallbackToken: expect.any(String),
}),
});
} finally {
service.stop();
}
});
it("accepts a snapshot callback with the dispatched token", async () => {
const { service, snapshot, submitSuspendCompletion } = createService();
try {
service.schedule("run_1", delayedSnapshot());
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 });
const metadata = dispatchedMetadata(snapshot);
const result = await service.handleCallback({
status: "completed",
instance_id: "instance_1",
snapshot_id: "compute_snapshot_1",
metadata,
});
expect(result).toEqual({ ok: true, status: 200 });
expect(submitSuspendCompletion).toHaveBeenCalledWith({
runId: "run_1",
snapshotId: "snapshot_1",
body: {
success: true,
checkpoint: {
type: "COMPUTE",
location: "compute_snapshot_1",
},
},
});
} finally {
service.stop();
}
});
it("rejects a snapshot callback without a valid token", async () => {
const { service, submitSuspendCompletion } = createService();
try {
const result = await service.handleCallback({
status: "completed",
instance_id: "instance_1",
snapshot_id: "compute_snapshot_1",
metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_1" },
});
expect(result).toEqual({ ok: false, status: 401 });
expect(submitSuspendCompletion).not.toHaveBeenCalled();
} finally {
service.stop();
}
});
it("rejects a snapshot callback whose token is for a different snapshot", async () => {
const { service, snapshot, submitSuspendCompletion } = createService();
try {
service.schedule("run_1", delayedSnapshot());
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 });
const metadata = dispatchedMetadata(snapshot);
const result = await service.handleCallback({
status: "completed",
instance_id: "instance_1",
snapshot_id: "compute_snapshot_1",
metadata: { ...metadata, snapshotFriendlyId: "snapshot_2" },
});
expect(result).toEqual({ ok: false, status: 401 });
expect(submitSuspendCompletion).not.toHaveBeenCalled();
} finally {
service.stop();
}
});
});
@@ -1,3 +1,4 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import pLimit from "p-limit";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
@@ -16,6 +17,13 @@ import {
type WideEventOptions,
} from "../wideEvents/index.js";
const SNAPSHOT_CALLBACK_NONCE_METADATA_KEY = "snapshotCallbackNonce";
const SNAPSHOT_CALLBACK_TOKEN_METADATA_KEY = "snapshotCallbackToken";
// Domain-separation label so the callback-signing key is derived from, rather
// than equal to, the secret used for other protocols. Bump the suffix to rotate.
const SNAPSHOT_CALLBACK_KEY_INFO = "compute-snapshot-callback-v1";
type DelayedSnapshot = {
runnerId: string;
runFriendlyId: string;
@@ -34,6 +42,7 @@ export type ComputeSnapshotServiceOptions = {
workerClient: SupervisorHttpClient;
tracing?: OtlpTraceService;
wideEventOpts: WideEventOptions;
snapshotCallbackSecret: string;
};
export class ComputeSnapshotService {
@@ -48,6 +57,7 @@ export class ComputeSnapshotService {
private readonly workerClient: SupervisorHttpClient;
private readonly tracing?: OtlpTraceService;
private readonly wideEventOpts: WideEventOptions;
private readonly snapshotCallbackKey: Buffer;
constructor(opts: ComputeSnapshotServiceOptions) {
this.computeManager = opts.computeManager;
@@ -55,6 +65,18 @@ export class ComputeSnapshotService {
this.tracing = opts.tracing;
this.wideEventOpts = opts.wideEventOpts;
// Reject an empty secret up front: an empty HMAC key would make callback
// tokens forgeable by anyone. Guarding here (rather than only at env parse)
// also covers the case where the secret is read from an empty file.
if (!opts.snapshotCallbackSecret) {
throw new Error("snapshotCallbackSecret must not be empty");
}
// Derive a dedicated key by domain separation so the raw secret is never
// used directly as a MAC key for this protocol.
this.snapshotCallbackKey = createHmac("sha256", opts.snapshotCallbackSecret)
.update(SNAPSHOT_CALLBACK_KEY_INFO)
.digest();
this.dispatchLimit = pLimit(this.computeManager.snapshotDispatchLimit);
this.timerWheel = new TimerWheel<DelayedSnapshot>({
delayMs: this.computeManager.snapshotDelayMs,
@@ -146,15 +168,29 @@ export class ComputeSnapshotService {
instanceId: body.instance_id,
status: body.status,
error: body.status === "failed" ? body.error : undefined,
metadata: body.metadata,
runId,
snapshotFriendlyId,
durationMs: body.duration_ms,
});
if (!runId || !snapshotFriendlyId) {
this.logger.error("Snapshot callback missing metadata", { body });
this.logger.error("Snapshot callback missing metadata", {
status: body.status,
instanceId: body.instance_id,
metadataKeys: Object.keys(body.metadata ?? {}),
});
return { ok: false as const, status: 400 };
}
if (!this.#verifyCallbackToken(body.metadata, runId, snapshotFriendlyId)) {
this.logger.error("Snapshot callback failed token verification", {
runId,
snapshotFriendlyId,
instanceId: body.instance_id,
});
return { ok: false as const, status: 401 };
}
this.#emitSnapshotSpan(runId, body.duration_ms, snapshotId);
if (body.status === "completed") {
@@ -266,11 +302,18 @@ export class ComputeSnapshotService {
},
},
async () => {
const callbackNonce = randomBytes(16).toString("hex");
const result = await this.computeManager.snapshot({
runnerId: snapshot.runnerId,
metadata: {
runId: snapshot.runFriendlyId,
snapshotFriendlyId: snapshot.snapshotFriendlyId,
[SNAPSHOT_CALLBACK_NONCE_METADATA_KEY]: callbackNonce,
[SNAPSHOT_CALLBACK_TOKEN_METADATA_KEY]: this.#createCallbackToken(
callbackNonce,
snapshot.runFriendlyId,
snapshot.snapshotFriendlyId
),
},
});
@@ -281,6 +324,51 @@ export class ComputeSnapshotService {
);
}
#createCallbackToken(nonce: string, runFriendlyId: string, snapshotFriendlyId: string): string {
return createHmac("sha256", this.snapshotCallbackKey)
.update(nonce)
.update("\0")
.update(runFriendlyId)
.update("\0")
.update(snapshotFriendlyId)
.digest("hex");
}
/**
* Verify that a callback carries a token this supervisor issued for the given
* run and snapshot. The token binds only the identifiers known at dispatch
* time (nonce, run, snapshot); it intentionally does not cover result fields
* such as the snapshot location or status/error, which are produced by the
* gateway after the snapshot and so cannot be signed in advance. Verification
* is also stateless, so a token is not single-use.
*
* This closes the primary risk (a caller that can merely reach the endpoint
* cannot mint a valid token, so cannot forge a result for an arbitrary run).
* It does not defend against an attacker who can observe a genuine callback
* and then replay it or alter its unsigned result fields - that relies on the
* gateway->supervisor callback channel being authenticated and encrypted.
*/
#verifyCallbackToken(
metadata: Record<string, string> | undefined,
runFriendlyId: string,
snapshotFriendlyId: string
): boolean {
const nonce = metadata?.[SNAPSHOT_CALLBACK_NONCE_METADATA_KEY];
const token = metadata?.[SNAPSHOT_CALLBACK_TOKEN_METADATA_KEY];
if (!nonce || !token) {
return false;
}
const expected = this.#createCallbackToken(nonce, runFriendlyId, snapshotFriendlyId);
const expectedBuffer = Buffer.from(expected, "hex");
const tokenBuffer = Buffer.from(token, "hex");
return (
expectedBuffer.length === tokenBuffer.length && timingSafeEqual(expectedBuffer, tokenBuffer)
);
}
#emitSnapshotSpan(runFriendlyId: string, durationMs?: number, snapshotId?: string) {
if (!this.tracing) return;
@@ -151,7 +151,9 @@ export class ComputeWorkloadManager implements WorkloadManager {
TRIGGER_DEQUEUED_AT_MS: String(opts.dequeuedAt.getTime()),
TRIGGER_POD_SCHEDULED_AT_MS: String(Date.now()),
TRIGGER_ENV_ID: opts.envId,
TRIGGER_DEPLOYMENT_ID: opts.deploymentFriendlyId,
TRIGGER_DEPLOYMENT_ID: opts.deploymentToken ?? opts.deploymentFriendlyId,
// Plain friendlyId for telemetry (worker.id), so it isn't the opaque token in DEPLOYMENT_ID.
TRIGGER_DEPLOYMENT_FRIENDLY_ID: opts.deploymentFriendlyId,
TRIGGER_DEPLOYMENT_VERSION: opts.deploymentVersion,
TRIGGER_RUN_ID: opts.runFriendlyId,
TRIGGER_SNAPSHOT_ID: opts.snapshotFriendlyId,
@@ -72,7 +72,9 @@ export class DockerWorkloadManager implements WorkloadManager {
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`TRIGGER_ENV_ID=${opts.envId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentToken ?? opts.deploymentFriendlyId}`,
// Plain friendlyId for telemetry (worker.id), so it isn't the opaque token in DEPLOYMENT_ID.
`TRIGGER_DEPLOYMENT_FRIENDLY_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
BLOCK_IO_URING_SECCOMP_PROFILE,
withBlockIoUringSeccompProfile,
} from "./kubernetesPodSpec.js";
const basePodSpec = {
restartPolicy: "Never" as const,
automountServiceAccountToken: false,
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
fsGroup: 1000,
},
};
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,
},
},
});
}
});
it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => {
for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) {
expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec);
}
});
});
@@ -14,6 +14,7 @@ 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";
type ResourceQuantities = {
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
@@ -105,6 +106,11 @@ 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;
await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
body: {
@@ -119,7 +125,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
},
spec: {
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
...podSpec,
affinity: this.#getAffinity(opts),
tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)),
terminationGracePeriodSeconds: 60 * 60,
@@ -152,6 +158,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
{
name: "TRIGGER_DEPLOYMENT_ID",
value: opts.deploymentToken ?? opts.deploymentFriendlyId,
},
{
// Plain friendlyId for telemetry (worker.id), not the opaque token in DEPLOYMENT_ID.
name: "TRIGGER_DEPLOYMENT_FRIENDLY_ID",
value: opts.deploymentFriendlyId,
},
{
@@ -0,0 +1,33 @@
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.
*/
export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json";
/**
* 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.
*/
export function withBlockIoUringSeccompProfile(
podSpec: Omit<k8s.V1PodSpec, "containers">,
runtime: string | null | undefined
): Omit<k8s.V1PodSpec, "containers"> {
const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null;
if (!match || Number(match[1]) < 24) {
return podSpec;
}
return {
...podSpec,
securityContext: {
...podSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
};
}
@@ -16,6 +16,8 @@ export interface WorkloadManagerOptions {
snapshotPollIntervalSeconds?: number;
additionalEnvVars?: Record<string, string>;
dockerAutoremove?: boolean;
// Whether CRIU checkpoint/restore is enabled for this deployment
checkpointsEnabled?: boolean;
}
export interface WorkloadManager {
@@ -40,6 +42,10 @@ export interface WorkloadManagerCreateOptions {
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
// Canonical runtime identifier (e.g. "node", "node-22", "node-24")
runtime?: string;
// When set, overrides the TRIGGER_DEPLOYMENT_ID value the runner forwards as its identity header.
deploymentToken?: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
+155 -9
View File
@@ -25,6 +25,11 @@ import { type Namespace, Server, type Socket } from "socket.io";
import { z } from "zod";
import { env } from "../env.js";
import { register } from "../metrics.js";
import {
verifyDeploymentIdHeader,
workloadTokenEnforced,
workloadTokensEnabled,
} from "../workloadToken.js";
import {
ComputeSnapshotService,
type RunTraceContext,
@@ -86,6 +91,7 @@ type WorkloadServerOptions = {
checkpointClient?: CheckpointClient;
computeManager?: ComputeWorkloadManager;
tracing?: OtlpTraceService;
snapshotCallbackSecret: string;
wideEventOpts: WideEventOptions;
/** When true, high-frequency HTTP routes also emit wide events. */
wideEventsNoisyRoutes: boolean;
@@ -136,6 +142,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
workerClient: opts.workerClient,
tracing: opts.tracing,
wideEventOpts: this.wideEventOpts,
snapshotCallbackSecret: opts.snapshotCallbackSecret,
});
}
@@ -169,6 +176,34 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
return this.headerValueFromRequest(req, WORKLOAD_HEADERS.PROJECT_REF);
}
/**
* Verify the deployment token from the workload deployment-id header and return the verified
* 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.
*/
private async authorizeWorkloadRequest(
req: IncomingMessage
): Promise<{ ok: true; environmentId?: string } | { ok: false }> {
if (!workloadTokensEnabled) {
return { ok: true };
}
const result = await verifyDeploymentIdHeader(this.deploymentIdFromRequest(req), "http");
if (result.outcome === "jwt_invalid" && workloadTokenEnforced) {
return { ok: false };
}
return {
ok: true,
environmentId:
workloadTokenEnforced && result.outcome === "jwt_valid"
? result.claims.environment_id
: undefined,
};
}
/**
* Sets common route meta on the wide-event state from URL params.
*/
@@ -250,11 +285,17 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"POST",
async () => {
const { req, reply, params, body } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const startResponse = await this.workerClient.startRunAttempt(
params.runFriendlyId,
params.snapshotFriendlyId,
body,
this.runnerIdFromRequest(req)
this.runnerIdFromRequest(req),
auth.environmentId
);
if (!startResponse.success) {
@@ -286,6 +327,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"POST",
async () => {
const { req, reply, params, body } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const runnerId = this.runnerIdFromRequest(req);
// A completion attempt invalidates any pending delayed snapshot
@@ -304,7 +350,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
params.runFriendlyId,
params.snapshotFriendlyId,
body,
runnerId
runnerId,
auth.environmentId
);
if (!completeResponse.success) {
@@ -336,6 +383,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"POST",
async () => {
const { req, reply, params, body } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const heartbeatResponse = await this.workerClient.heartbeatRun(
params.runFriendlyId,
params.snapshotFriendlyId,
@@ -373,6 +425,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"GET",
async () => {
const { reply, params, req } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const runnerId = this.runnerIdFromRequest(req);
const deploymentVersion = this.deploymentVersionFromRequest(req);
const projectRef = this.projectRefFromRequest(req);
@@ -469,6 +526,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"GET",
async () => {
const { req, reply, params } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
this.logger.debug("Run continuation request", { params });
// Cancel any pending delayed snapshot for this run
@@ -477,7 +539,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const continuationResult = await this.workerClient.continueRunExecution(
params.runFriendlyId,
params.snapshotFriendlyId,
this.runnerIdFromRequest(req)
this.runnerIdFromRequest(req),
auth.environmentId
);
if (!continuationResult.success) {
@@ -511,10 +574,16 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"GET",
async () => {
const { req, reply, params } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const sinceSnapshotResponse = await this.workerClient.getSnapshotsSince(
params.runFriendlyId,
params.snapshotFriendlyId,
this.runnerIdFromRequest(req)
this.runnerIdFromRequest(req),
auth.environmentId
);
if (!sinceSnapshotResponse.success) {
@@ -585,9 +654,18 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const { req, reply, params, body } = ctx;
reply.empty(204);
// Redact TRIGGER_DEPLOYMENT_ID before relaying to the platform.
const sanitizedBody =
body.properties && "TRIGGER_DEPLOYMENT_ID" in body.properties
? {
...body,
properties: { ...body.properties, TRIGGER_DEPLOYMENT_ID: "[redacted]" },
}
: body;
await this.workerClient.sendDebugLog(
params.runFriendlyId,
body,
sanitizedBody,
this.runnerIdFromRequest(req)
);
},
@@ -681,7 +759,31 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
return;
}
this.logger.debug("[WS] auth success", socket.data);
if (workloadTokensEnabled) {
const result = await verifyDeploymentIdHeader(socket.data.deploymentId, "ws");
if (result.outcome === "jwt_invalid" && workloadTokenEnforced) {
this.logger.error("[WS] deployment token verification failed", {
runnerId: socket.data.runnerId,
});
socket.disconnect(true);
return;
}
// Re-source the deployment id from the verified claim; the raw header may be an opaque token.
// A legacy bare id is itself the friendlyId, so it's safe to keep.
socket.data.deploymentFriendlyId =
result.outcome === "jwt_valid"
? result.claims.deployment
: result.outcome === "legacy_bare"
? socket.data.deploymentId
: undefined;
}
this.logger.debug("[WS] handshake complete", {
runnerId: socket.data.runnerId,
deploymentFriendlyId: socket.data.deploymentFriendlyId,
});
next();
});
@@ -693,7 +795,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const getSocketMetadata = () => {
return {
deploymentId: socket.data.deploymentId,
deploymentId: socket.data.deploymentFriendlyId ?? socket.data.deploymentId,
runId: socket.data.runFriendlyId,
snapshotId: socket.data.snapshotId,
runnerId: socket.data.runnerId,
@@ -712,8 +814,9 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
populate: (state) => {
state.extras.event = event;
setMeta(state, "run_id", friendlyId);
if (socket.data.deploymentId) {
setMeta(state, "deployment_id", socket.data.deploymentId);
const deploymentId = socket.data.deploymentFriendlyId ?? socket.data.deploymentId;
if (deploymentId) {
setMeta(state, "deployment_id", deploymentId);
}
if (socket.data.runnerId) setMeta(state, "runner_id", socket.data.runnerId);
state.extras.socket_id = socket.id;
@@ -725,6 +828,33 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const runConnected = (friendlyId: string) => {
socketLogger.debug("runConnected", { ...getSocketMetadata() });
// Only the owning runner may (re)bind a run. A live socket from a *different*
// runner keeps its binding so an unrelated connection can't hijack the run. But
// the newest socket for the *same* runner is a legitimate reconnection/handoff and
// is allowed to take over even while the stale socket still reports connected -
// otherwise, during a reconnect race the fresh socket would silently stay unbound
// (missing continue/cancel/suspend notifications) until the dead socket times out.
const existing = this.runSockets.get(friendlyId);
if (existing && existing.id !== socket.id && existing.connected) {
const sameRunner =
!!socket.data.runnerId && existing.data.runnerId === socket.data.runnerId;
if (!sameRunner) {
socketLogger.warn("runConnected: run already bound to another socket", {
...getSocketMetadata(),
friendlyId,
existingSocketId: existing.id,
});
return;
}
socketLogger.debug("runConnected: replacing stale socket for same runner", {
...getSocketMetadata(),
friendlyId,
existingSocketId: existing.id,
});
}
// If there's already a run ID set, we should "disconnect" it from this socket
if (socket.data.runFriendlyId && socket.data.runFriendlyId !== friendlyId) {
socketLogger.debug("runConnected: disconnecting existing run", {
@@ -744,6 +874,22 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const runDisconnected = (friendlyId: string, reason: string) => {
socketLogger.debug("runDisconnected", { ...getSocketMetadata() });
// A newer socket may have taken over this run (same-runner reconnect race). If the
// run is now bound to a different socket, this stale socket must not clear the fresh
// binding or emit a spurious disconnect - just drop its own reference and bail.
const bound = this.runSockets.get(friendlyId);
if (bound && bound.id !== socket.id) {
socketLogger.debug("runDisconnected: run rebound to another socket, skipping", {
...getSocketMetadata(),
friendlyId,
boundSocketId: bound.id,
});
if (socket.data.runFriendlyId === friendlyId) {
socket.data.runFriendlyId = undefined;
}
return;
}
// The run is gone from this runner (crash, exit, or replaced by a new
// run), so a pending delayed snapshot for it is stale. Genuine
// waitpoint suspensions keep the socket connected, so this doesn't
@@ -0,0 +1,107 @@
import { mintWorkloadDeploymentToken } from "@trigger.dev/core/v3";
import { WORKLOAD_HEADERS } from "@trigger.dev/core/v3/workers";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
// Set enforce mode + secret before env.ts parses (vi.mock is hoisted above imports, so the secret
// must be a literal here). SECRET below mirrors it for use in the test body.
vi.mock("std-env", () => ({
env: {
TRIGGER_API_URL: "http://localhost:3030",
TRIGGER_WORKER_TOKEN: "test-token",
MANAGED_WORKER_SECRET: "test-secret",
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
WORKLOAD_TOKEN_SECRET: "integration-test-secret",
WORKLOAD_TOKEN_ENFORCEMENT: "enforce",
},
}));
const SECRET = "integration-test-secret";
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
const { WorkloadServer } = await import("./index.js");
const PORT = 18732;
const BASE = `http://127.0.0.1:${PORT}`;
function claims(environmentId = "env_test_123") {
return {
deployment: "deployment_test",
deployment_version: "20260710.1",
environment_id: environmentId,
environment_type: "PRODUCTION",
org_id: "org_1",
project_id: "proj_1",
};
}
// Records the args each relay method is called with so we can assert the forwarded claim.
const calls: { getSnapshotsSince: any[][] } = { getSnapshotsSince: [] };
const workerClient = {
getSnapshotsSince: vi.fn(async (...args: any[]) => {
calls.getSnapshotsSince.push(args);
return { success: true as const, data: { snapshots: [] } };
}),
} as any;
let server: InstanceType<typeof WorkloadServer>;
beforeAll(async () => {
server = new WorkloadServer({
port: PORT,
workerClient,
snapshotCallbackSecret: "snapshot-callback-secret",
wideEventOpts: { service: "supervisor", env: { nodeId: "test" }, enabled: false },
wideEventsNoisyRoutes: false,
});
await server.start();
});
afterAll(async () => {
await server.stop();
});
function snapshotsSince(deploymentIdHeader?: string) {
const headers: Record<string, string> = {
[WORKLOAD_HEADERS.RUNNER_ID]: "runner_1",
};
if (deploymentIdHeader !== undefined) {
headers[WORKLOAD_HEADERS.DEPLOYMENT_ID] = deploymentIdHeader;
}
return fetch(`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/since/snap_1`, { headers });
}
describe("WorkloadServer auth (enforce mode)", () => {
it("allows a valid token and forwards the verified environment_id", async () => {
const token = await mintWorkloadDeploymentToken(claims("env_forwarded_42"), SECRET, EXP);
const res = await snapshotsSince(token);
expect(res.status).toBe(200);
const lastCall = calls.getSnapshotsSince.at(-1)!;
// getSnapshotsSince(runId, snapshotId, runnerId, environmentId)
expect(lastCall[3]).toBe("env_forwarded_42");
});
it("rejects a token signed with the wrong secret (401) and does not relay", async () => {
const before = calls.getSnapshotsSince.length;
const badToken = await mintWorkloadDeploymentToken(claims(), "wrong-secret", EXP);
const res = await snapshotsSince(badToken);
expect(res.status).toBe(401);
expect(calls.getSnapshotsSince.length).toBe(before);
});
it("allows a legacy bare friendlyId and forwards no environment_id", async () => {
const res = await snapshotsSince("deployment_legacy_bare");
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
it("allows an absent token and forwards no environment_id", async () => {
const res = await snapshotsSince(undefined);
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { mintWorkloadDeploymentToken } from "@trigger.dev/core/v3";
import { WORKLOAD_HEADERS } from "@trigger.dev/core/v3/workers";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
// Log mode: mint + verify + metrics, but the platform must NOT be scoped, so no environment_id is
// forwarded even for a valid token. (vi.mock is hoisted; secret literal here, mirrored below.)
vi.mock("std-env", () => ({
env: {
TRIGGER_API_URL: "http://localhost:3030",
TRIGGER_WORKER_TOKEN: "test-token",
MANAGED_WORKER_SECRET: "test-secret",
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
WORKLOAD_TOKEN_SECRET: "integration-test-secret",
WORKLOAD_TOKEN_ENFORCEMENT: "log",
},
}));
const SECRET = "integration-test-secret";
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
const { WorkloadServer } = await import("./index.js");
const PORT = 18733;
const BASE = `http://127.0.0.1:${PORT}`;
const claims = {
deployment: "deployment_test",
deployment_version: "20260710.1",
environment_id: "env_should_not_forward",
environment_type: "PRODUCTION",
org_id: "org_1",
project_id: "proj_1",
};
const calls: { getSnapshotsSince: any[][] } = { getSnapshotsSince: [] };
const workerClient = {
getSnapshotsSince: vi.fn(async (...args: any[]) => {
calls.getSnapshotsSince.push(args);
return { success: true as const, data: { snapshots: [] } };
}),
} as any;
let server: InstanceType<typeof WorkloadServer>;
beforeAll(async () => {
server = new WorkloadServer({
port: PORT,
workerClient,
snapshotCallbackSecret: "snapshot-callback-secret",
wideEventOpts: { service: "supervisor", env: { nodeId: "test" }, enabled: false },
wideEventsNoisyRoutes: false,
});
await server.start();
});
afterAll(async () => {
await server.stop();
});
describe("WorkloadServer auth (log mode)", () => {
it("allows a valid token but forwards no environment_id", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const res = await fetch(`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/since/snap_1`, {
headers: {
[WORKLOAD_HEADERS.RUNNER_ID]: "runner_1",
[WORKLOAD_HEADERS.DEPLOYMENT_ID]: token,
},
});
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
it("does not reject an invalid token in log mode", async () => {
const badToken = await mintWorkloadDeploymentToken(claims, "wrong-secret", EXP);
const res = await fetch(`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/since/snap_1`, {
headers: {
[WORKLOAD_HEADERS.RUNNER_ID]: "runner_1",
[WORKLOAD_HEADERS.DEPLOYMENT_ID]: badToken,
},
});
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
});
+95
View File
@@ -0,0 +1,95 @@
import {
classifyDeploymentIdHeader,
mintWorkloadDeploymentToken,
type WorkloadDeploymentTokenClaims,
type WorkloadDeploymentTokenInput,
} from "@trigger.dev/core/v3";
import { Counter, Gauge } from "prom-client";
import { env } from "./env.js";
import { register } from "./metrics.js";
const secret = env.WORKLOAD_TOKEN_SECRET;
// Absolute expiry (epoch seconds) shared by every mint, so tokens stay byte-deterministic per
// deployment regardless of when/where a pod is created.
const tokenExpSeconds = Math.floor(new Date(env.WORKLOAD_TOKEN_EXP).getTime() / 1000);
/** Mint + verify run in "log" (dry-run) and "enforce"; the env superRefine guarantees a secret then. */
export const workloadTokensEnabled = env.WORKLOAD_TOKEN_ENFORCEMENT !== "disabled";
/** Only "enforce" rejects a present-but-invalid token; "log" observes and always allows. */
export const workloadTokenEnforced = env.WORKLOAD_TOKEN_ENFORCEMENT === "enforce";
const mintCounter = new Counter({
name: "workload_token_minted_total",
help: "Deployment tokens minted and injected into TRIGGER_DEPLOYMENT_ID at pod creation",
labelNames: ["env_type"] as const,
registers: [register],
});
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",
help: "Runner-boundary token verification outcomes at the supervisor workload server",
labelNames: ["outcome", "transport", "env_type"] as const,
registers: [register],
});
// Exports the active mode (value 1 for the current WORKLOAD_TOKEN_ENFORCEMENT) so dashboards can show
// disabled/log/enforce at a glance — the counters alone don't distinguish log from enforce.
const enforcementModeGauge = new Gauge({
name: "workload_token_enforcement_mode",
help: "Active runner-boundary auth mode: value 1 for the label matching WORKLOAD_TOKEN_ENFORCEMENT",
labelNames: ["mode"] as const,
registers: [register],
});
enforcementModeGauge.set({ mode: env.WORKLOAD_TOKEN_ENFORCEMENT }, 1);
export async function mintDeploymentToken(
claims: WorkloadDeploymentTokenInput
): Promise<string | undefined> {
if (!workloadTokensEnabled || !secret) {
return undefined;
}
const token = await mintWorkloadDeploymentToken(claims, secret, tokenExpSeconds);
mintCounter.inc({ env_type: claims.environment_type });
return token;
}
export type VerifiedDeploymentHeader =
| { outcome: "jwt_valid"; claims: WorkloadDeploymentTokenClaims }
| { outcome: "jwt_invalid" | "legacy_bare" | "token_absent"; claims?: undefined };
/**
* Verify the deployment-id header value and record the outcome. "jwt_valid" returns the claims so the
* caller can forward the verified environment_id upstream; other outcomes carry no trusted data.
*/
export async function verifyDeploymentIdHeader(
value: string | undefined,
transport: WorkloadAuthTransport
): Promise<VerifiedDeploymentHeader> {
const result = await classify(value);
verifyCounter.inc({
outcome: result.outcome,
transport,
env_type: result.outcome === "jwt_valid" ? result.claims.environment_type : "unknown",
});
return result;
}
async function classify(value: string | undefined): Promise<VerifiedDeploymentHeader> {
if (!value || !secret) {
return { outcome: "token_absent" };
}
const result = await classifyDeploymentIdHeader(value, secret);
if (result.outcome === "jwt_valid" && result.claims) {
return { outcome: "jwt_valid", claims: result.claims };
}
return { outcome: result.outcome === "jwt_valid" ? "jwt_invalid" : result.outcome };
}
+12 -12
View File
@@ -91,24 +91,14 @@ Background job workers use `@trigger.dev/redis-worker`:
- `app/v3/alertsWorker.server.ts`
- `app/v3/batchTriggerWorker.server.ts`
Do NOT add new jobs using zodworker/graphile-worker (legacy).
## Real-time
- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts`
- Electric SQL: Powers real-time data sync for the dashboard
## Legacy V1 Code
## v3 (engine V1) removed
The `app/v3/` directory name is misleading - most code is actively used by V2. Only these specific files are V1-only legacy:
- `app/v3/marqs/` (old MarQS queue system)
- `app/v3/legacyRunEngineWorker.server.ts`
- `app/v3/services/triggerTaskV1.server.ts`
- `app/v3/services/cancelTaskRunV1.server.ts`
- `app/v3/authenticatedSocketConnection.server.ts`
- `app/v3/sharedSocketConnection.ts`
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
v3 (engine V1: MarQS + Graphile worker) is end-of-life and its execution code is gone. The `app/v3/` directory name is historical; everything under it now serves V2. There is no V1 execution path: a `RunEngineVersion` `V1` branch (e.g. in `triggerTask.server.ts`, `cancelTaskRun.server.ts`) only rejects/finalizes gracefully so v3 clients get a clean 4xx, never a 5xx. Do not reintroduce V1. See `.claude/rules/legacy-v3-code.md` for the deprecation boundary.
## Performance: Trigger Hot Path
@@ -125,6 +115,16 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
## Transactions
- **Always use the `$transaction` helper from `~/db.server`, never `prisma.$transaction` (or `$replica.$transaction`) directly.** The helper wraps the raw call with tracing (an OTEL span + an `isolation_level` attribute) and boundary logging for infrastructure errors (e.g. `PrismaClientInitializationError`) that the raw client swallows. Signature: `$transaction(prisma, name?, async (tx) => { ... }, options?)`.
- Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths.
- The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value.
## PAT-authenticated API routes
- **A PAT route must resolve its target org/project scoped to the caller's membership** (`members: { some: { userId } }`, or a helper like `findProjectByRef` / `resolveOrganizationForApiUser`). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive — so `ability.can(...)` alone does NOT reject a non-member on self-hosted. The RBAC `authorization` gate enforces the *role*; the membership-scoped query is the *tenant* floor. Skipping it opens cross-org access on OSS.
## React Patterns
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.
@@ -1,4 +1,4 @@
export function AvatarCircleIcon({ className }: { className?: string }) {
function AvatarCircle({ className, strokeWidth }: { className?: string; strokeWidth: number }) {
return (
<svg
className={className}
@@ -8,13 +8,28 @@ export function AvatarCircleIcon({ className }: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
<circle cx="12" cy="9.5" r="2.5" stroke="currentColor" strokeWidth="2" />
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={strokeWidth} />
<circle cx="12" cy="9.5" r="2.5" stroke="currentColor" strokeWidth={strokeWidth} />
<path
d="M6 19C7.00156 16.6478 9.32233 15 12.0254 15C14.6837 15 16.9724 16.5938 18 18.884"
stroke="currentColor"
strokeWidth="2"
strokeWidth={strokeWidth}
/>
</svg>
);
}
/** User avatar placeholder with a 2px stroke (the default). */
export function AvatarCircleIcon({ className }: { className?: string }) {
return <AvatarCircle className={className} strokeWidth={2} />;
}
/** Thinner 1.5px-stroke variant of {@link AvatarCircleIcon}. */
export function AvatarCircleIconThin({ className }: { className?: string }) {
return <AvatarCircle className={className} strokeWidth={1.5} />;
}
/** Thinnest 1.25px-stroke variant of {@link AvatarCircleIcon}. */
export function AvatarCircleIconExtraThin({ className }: { className?: string }) {
return <AvatarCircle className={className} strokeWidth={1.25} />;
}
@@ -0,0 +1,27 @@
export function ChainLinkIcon({ 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 19.0004L9.82843 19.1719C8.26634 20.734 5.73368 20.734 4.17158 19.1719L3.82843 18.8288C2.26634 17.2667 2.26633 14.734 3.82843 13.1719L7.17158 9.8288C8.73368 8.2667 11.2663 8.2667 12.8284 9.8288L13.1716 10.1719C13.8252 10.8256 14.2053 11.6491 14.312 12.5004"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.68799 12.5004C9.79463 13.3516 10.1748 14.1752 10.8284 14.8288L11.1715 15.1719C12.7336 16.734 15.2663 16.734 16.8284 15.1719L20.1715 11.8288C21.7336 10.2667 21.7336 7.73404 20.1715 6.17194L19.8284 5.8288C18.2663 4.2667 15.7336 4.2667 14.1715 5.8288L14 6.00037"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,22 @@
export function LeftSideMenuCollapsedIcon({ 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"
>
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="2" />
<rect x="6" y="6" width="2" height="12" rx="1" fill="currentColor" />
<path
d="M12 14.5L14.5 12L12 9.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,44 @@
import { motion } from "framer-motion";
import { useState } from "react";
export function LeftSideMenuIcon({
className,
hovered: controlledHovered,
}: {
className?: string;
/** Drives the animation when provided (e.g. parent hover); otherwise the icon uses its own hover. */
hovered?: boolean;
}) {
const [internalHovered, setInternalHovered] = useState(false);
const isControlled = controlledHovered !== undefined;
const hovered = isControlled ? controlledHovered : internalHovered;
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={isControlled ? undefined : () => setInternalHovered(true)}
onMouseLeave={isControlled ? undefined : () => setInternalHovered(false)}
>
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
{/* Animate a transform (scaleX), not the SVG `width` attr — framer snaps the first animation
of an idle SVG geometry attribute. Left origin collapses the panel right-to-left. */}
<motion.rect
x="6"
y="6"
width="5"
height="12"
rx="1"
fill="currentColor"
initial={false}
style={{ originX: 0 }}
animate={{ scaleX: hovered ? 0.2 : 1 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
/>
</svg>
);
}
+101 -32
View File
@@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
import { useCallback, useEffect, useRef, useState } from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader } from "~/root";
import { Button } from "./primitives/Buttons";
import { Callout } from "./primitives/Callout";
@@ -38,6 +39,104 @@ function useKapaWebsiteId() {
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);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
const [searchParams, setSearchParams] = useSearchParams();
const openAskAI = useCallback((question?: string) => {
if (question) {
setInitialQuery(question);
} else {
setInitialQuery(undefined);
}
setIsOpen(true);
}, []);
const closeAskAI = useCallback(() => {
setIsOpen(false);
setInitialQuery(undefined);
}, []);
// Handle URL param functionality
useEffect(() => {
const aiHelp = searchParams.get("aiHelp");
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");
setSearchParams(next);
}
}, [searchParams, 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).
*/
export function AskAIRoot({
children,
}: {
children: (openAskAI: (() => void) | undefined) => ReactNode;
}) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
if (!isManagedCloud || !websiteId) {
return <>{children(undefined)}</>;
}
return (
<ClientOnly fallback={<>{children(undefined)}</>}>
{() => <AskAIRootProvider websiteId={websiteId}>{children}</AskAIRootProvider>}
</ClientOnly>
);
}
function AskAIRootProvider({
websiteId,
children,
}: {
websiteId: string;
children: (openAskAI: () => void) => ReactNode;
}) {
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true },
action: () => openAskAI(),
});
return (
<KapaProvider
integrationId={websiteId}
callbacks={{
askAI: {
onQuerySubmit: () => openAskAI(),
onAnswerGenerationCompleted: () => openAskAI(),
},
}}
botProtectionMechanism="hcaptcha"
>
{children(() => openAskAI())}
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
onOpenChange={setIsOpen}
closeAskAI={closeAskAI}
/>
</KapaProvider>
);
}
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
@@ -72,37 +171,7 @@ type AskAIProviderProps = {
};
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
const [searchParams, setSearchParams] = useSearchParams();
const openAskAI = useCallback((question?: string) => {
if (question) {
setInitialQuery(question);
} else {
setInitialQuery(undefined);
}
setIsOpen(true);
}, []);
const closeAskAI = useCallback(() => {
setIsOpen(false);
setInitialQuery(undefined);
}, []);
// Handle URL param functionality
useEffect(() => {
const aiHelp = searchParams.get("aiHelp");
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");
setSearchParams(next);
}
}, [searchParams, openAskAI]);
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
return (
<KapaProvider
@@ -748,11 +748,7 @@ export function PromptsNone() {
iconClassName="text-aiPrompts"
panelClassName="max-w-lg"
accessory={
<LinkButton
to={docsPath("prompt-management")}
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
<LinkButton to={docsPath("ai/prompts")} variant="docs/small" LeadingIcon={BookOpenIcon}>
Prompts docs
</LinkButton>
}
@@ -0,0 +1,40 @@
import { useNavigate, useSubmit } from "@remix-run/react";
import { useEffect } from "react";
import { useIsImpersonating } from "~/hooks/useOrganizations";
import { useOptionalUser } from "~/hooks/useUser";
import { adminPath } from "~/utils/pathBuilder";
/** App-wide keyboard shortcuts, mounted once at the root so they work everywhere. Renders nothing. */
export function GlobalShortcuts() {
const user = useOptionalUser();
const isImpersonating = useIsImpersonating();
const navigate = useNavigate();
const submit = useSubmit();
const isAdmin = Boolean(user?.admin) || isImpersonating;
useEffect(() => {
if (!isAdmin) return;
const onKeyDown = (event: KeyboardEvent) => {
// Admin escape hatch: Cmd+Option+A (Ctrl+Alt+A on Windows) opens the admin dashboard, or stops
// impersonating. Avoids Escape — Chrome/macOS never delivers a keydown for Escape+modifier (why
// the old Cmd+Esc did nothing). Matched on `event.code`, not `event.key`, because Option makes
// "A" report "å" (so a raw listener, not the `event.key`-based useShortcutKeys hook).
if (event.code !== "KeyA" || !event.altKey || !(event.metaKey || event.ctrlKey)) {
return;
}
event.preventDefault();
if (isImpersonating) {
submit(null, { action: "/resources/impersonation", method: "delete" });
} else {
navigate(adminPath());
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isAdmin, isImpersonating, navigate, submit]);
return null;
}
+29 -18
View File
@@ -36,7 +36,14 @@ const quotes: QuoteType[] = [
},
];
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
export function LoginPageLayout({
children,
rightContent,
}: {
children: React.ReactNode;
/** Replaces the default testimonials panel on the right (e.g. a promo highlight). */
rightContent?: React.ReactNode;
}) {
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
useEffect(() => {
const randomIndex = Math.floor(Math.random() * quotes.length);
@@ -62,23 +69,27 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
</div>
</div>
<div className="hidden grid-rows-[1fr_auto] pb-6 lg:grid">
<div className="flex h-full flex-col items-center justify-center px-16">
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
{randomQuote?.quote}
</Header3>
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
</div>
<div className="flex flex-col items-center gap-4 px-8">
<Paragraph>Trusted by developers at</Paragraph>
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
<LyftLogo className="w-11" />
<UnkeyLogo />
<MiddayLogo />
<AppsmithLogo />
<CalComLogo />
<TldrawLogo />
</div>
</div>
{rightContent ?? (
<>
<div className="flex h-full flex-col items-center justify-center px-16">
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
{randomQuote?.quote}
</Header3>
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
</div>
<div className="flex flex-col items-center gap-4 px-8">
<Paragraph>Trusted by developers at</Paragraph>
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
<LyftLogo className="w-11" />
<UnkeyLogo />
<MiddayLogo />
<AppsmithLogo />
<CalComLogo />
<TldrawLogo />
</div>
</div>
</>
)}
</div>
</main>
);
+6 -13
View File
@@ -1,8 +1,8 @@
import { KeyboardIcon } from "~/assets/icons/KeyboardIcon";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { SideMenuItemButton } from "./navigation/SideMenuItem";
import { Paragraph } from "./primitives/Paragraph";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3";
import { ShortcutKey } from "./primitives/ShortcutKey";
@@ -11,19 +11,12 @@ export function Shortcuts() {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={KeyboardIcon}
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
<SideMenuItemButton
icon={KeyboardIcon}
name="Shortcuts"
data-action="shortcuts"
fullWidth
textAlignLeft
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
className="gap-x-0 pl-1.5"
iconSpacing="gap-x-1.5"
>
Shortcuts
</Button>
trailing={<ShortcutKey shortcut={{ modifiers: ["shift"], key: "?" }} variant="medium" />}
/>
</SheetTrigger>
<ShortcutContent />
</Sheet>
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { staleAssetRecoveryScript } from "./StaleAssetRecovery";
// Each staleAssetRecoveryScript() call models a fresh page load: it reads the shared
// sessionStorage budget and returns its own `recover`. We drive recover() directly rather
// than dispatching resource-error events, so accumulated window listeners never fire.
describe("staleAssetRecoveryScript", () => {
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
vi.stubGlobal("location", { reload });
vi.stubGlobal("navigator", { onLine: true });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("reloads on a recovery", () => {
staleAssetRecoveryScript().recover();
expect(reload).toHaveBeenCalledTimes(1);
});
it("reloads only once per page even if several assets fail (re-entrancy guard)", () => {
const { recover } = staleAssetRecoveryScript();
recover();
recover();
recover();
expect(reload).toHaveBeenCalledTimes(1);
});
it("stops reloading once the budget is spent across reloads", () => {
staleAssetRecoveryScript().recover(); // reload 1
staleAssetRecoveryScript().recover(); // reload 2
staleAssetRecoveryScript().recover(); // budget spent -> no reload
expect(reload).toHaveBeenCalledTimes(2);
});
it("does not reload when offline", () => {
vi.stubGlobal("navigator", { onLine: false });
staleAssetRecoveryScript().recover();
expect(reload).not.toHaveBeenCalled();
});
it("does not reload when sessionStorage is unavailable", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
staleAssetRecoveryScript().recover();
expect(reload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,96 @@
// Recovers from a rolling deploy rotating the content-hashed /assets files out from
// under a page. Each image serves only its own build and hard-404s unknown hashes, so
// a client can request a hash the serving replica doesn't have and get missing styles
// or a failed asset load. On such an asset load failure we do a bounded full document
// reload: the fresh document (and, under sticky routing, all of its assets) lands on a
// single live build, so the asset resolves. Bounded via sessionStorage so it can never
// loop; when the budget is spent it stops rather than reloading forever.
//
// Deliberately minimal — no fetch interception, no build-version polling, no server
// build-id contract, no form snapshot, no blocking overlay.
// The recovery logic runs as an inline <script> injected before <Links /> (see the
// component below), so it must execute before the app bundle and before the stylesheet
// can fail to load. It is authored as a normal, type-checked and lint-checked function
// and serialized with .toString() at render time — NOT hand-written into a string — so
// the logic is real code the compiler and linter can see. Because it is serialized, it
// must stay fully self-contained: no imports, no references to module scope, and plain
// ES that the bundler won't rewrite to reach a hoisted helper. It returns its `recover`
// closure purely so the unit test can drive the logic directly (the inline IIFE that
// runs in the browser ignores the return value).
export function staleAssetRecoveryScript() {
var KEY = "trigger:assetReload";
var MAX_RELOADS = 2;
var WINDOW_MS = 300000;
var recovering = false;
function budgetAllows() {
try {
var raw = sessionStorage.getItem(KEY);
var state = raw ? (JSON.parse(raw) as { n: number; t: number }) : { n: 0, t: 0 };
if (Date.now() - state.t > WINDOW_MS) state = { n: 0, t: 0 };
if (state.n >= MAX_RELOADS) return false;
sessionStorage.setItem(KEY, JSON.stringify({ n: state.n + 1, t: Date.now() }));
return true;
} catch {
// Storage blocked (private mode / quota): can't bound reloads, so don't auto-reload.
return false;
}
}
function recover() {
// One recovery per page: a broken load fails several hashed assets at once and each
// fires its own error event before location.reload() commits — without this guard a
// single incident would burn the entire reload budget.
if (recovering) return;
recovering = true;
// Don't reload into the browser's offline error page.
if (navigator.onLine === false) return;
if (budgetAllows()) location.reload();
}
// Non-bubbling resource load failures (stylesheet, modulepreload, entry <script>) at
// document load — the failure class nothing else covers. Capture phase is required.
window.addEventListener(
"error",
function (event) {
var el = event.target as Element | null;
if (!el || typeof el.tagName !== "string") return; // window/global errors have no tagName
var url =
el.tagName === "LINK"
? (el as HTMLLinkElement).href
: el.tagName === "SCRIPT"
? (el as HTMLScriptElement).src
: null;
// Match the pathname, not the full URL — a query string or third-party
// URL containing /assets/ must not burn the reload budget.
if (url && new URL(url, location.href).pathname.indexOf("/assets/") !== -1) recover();
},
true
);
// Raw dynamic import() failures in app code. (Remix reloads its own route chunks, so
// that path rarely reaches here.) The message URL isn't reliable cross-browser, so
// match the chunk-load error shape; the once-guard + bounded budget make a rare stray
// reload harmless.
window.addEventListener("unhandledrejection", function (event) {
var message = (event.reason && event.reason.message) || "";
if (
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
) {
recover();
}
});
return { recover };
}
export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) {
if (!isProduction) {
return null;
}
return (
<script dangerouslySetInnerHTML={{ __html: `(${staleAssetRecoveryScript.toString()})()` }} />
);
}
+46 -15
View File
@@ -1,31 +1,62 @@
import { UserCircleIcon } from "@heroicons/react/24/solid";
import {
AvatarCircleIcon,
AvatarCircleIconExtraThin,
AvatarCircleIconThin,
} from "~/assets/icons/AvatarCircleIcon";
import { useOptionalUser } from "~/hooks/useUser";
import { cn } from "~/utils/cn";
export function UserProfilePhoto({ className }: { className?: string }) {
/** Stroke width (px) of the placeholder avatar icon shown when there is no photo. */
type AvatarStrokeWidth = 1.25 | 1.5 | 2;
const PLACEHOLDER_BY_STROKE_WIDTH = {
1.25: AvatarCircleIconExtraThin,
1.5: AvatarCircleIconThin,
2: AvatarCircleIcon,
} as const;
export function UserProfilePhoto({
className,
strokeWidth = 2,
}: {
className?: string;
strokeWidth?: AvatarStrokeWidth;
}) {
const user = useOptionalUser();
return <UserAvatar avatarUrl={user?.avatarUrl} name={user?.name} className={className} />;
return (
<UserAvatar
avatarUrl={user?.avatarUrl}
name={user?.name}
className={className}
strokeWidth={strokeWidth}
/>
);
}
export function UserAvatar({
avatarUrl,
name,
className,
strokeWidth = 2,
}: {
avatarUrl?: string | null;
name?: string | null;
className?: string;
strokeWidth?: AvatarStrokeWidth;
}) {
return avatarUrl ? (
<div className={cn("grid aspect-square place-items-center", className)}>
<img
className={cn("aspect-square rounded-full p-[7%]")}
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
/>
</div>
) : (
<UserCircleIcon className={cn("aspect-square text-text-dimmed", className)} />
);
if (avatarUrl) {
return (
<div className={cn("grid aspect-square place-items-center", className)}>
<img
className={cn("aspect-square rounded-full p-[7%]")}
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
/>
</div>
);
}
const PlaceholderIcon = PLACEHOLDER_BY_STROKE_WIDTH[strokeWidth];
return <PlaceholderIcon className={cn("aspect-square text-text-dimmed", className)} />;
}
+6 -258
View File
@@ -10,7 +10,6 @@ import { useEffect } from "react";
import { Spinner } from "../primitives/Spinner";
import * as Property from "~/components/primitives/PropertyTable";
import { ClipboardField } from "../primitives/ClipboardField";
import { MarQSShortKeyProducer } from "~/v3/marqs/marqsKeyProducer";
export function AdminDebugRun({ friendlyId }: { friendlyId: string }) {
const hasAdminAccess = useHasAdminAccess();
@@ -69,26 +68,13 @@ function DebugRunContent({ friendlyId }: { friendlyId: string }) {
function DebugRunData(props: UseDataFunctionReturn<typeof loader>) {
if (props.engine === "V1") {
return <DebugRunDataEngineV1 {...props} />;
return <DebugRunDataEngineV1 run={props.run} />;
}
return <DebugRunDataEngineV2 {...props} />;
}
function DebugRunDataEngineV1({
run,
environment,
queueConcurrencyLimit,
queueCurrentConcurrency,
envConcurrencyLimit,
envCurrentConcurrency,
queueReserveConcurrency,
envReserveConcurrency,
}: UseDataFunctionReturn<typeof loader>) {
const keys = new MarQSShortKeyProducer("marqs:");
const withPrefix = (key: string) => `marqs:${key}`;
function DebugRunDataEngineV1({ run }: { run: UseDataFunctionReturn<typeof loader>["run"] }) {
return (
<Property.Table>
<Property.Item>
@@ -98,247 +84,9 @@ function DebugRunDataEngineV1({
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Message key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(keys.messageKey(run.id))}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>GET message</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`GET ${withPrefix(keys.messageKey(run.id))}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
)}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Get queue set</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`ZRANGE ${withPrefix(
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
)} 0 -1`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue current concurrency key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(
keys.queueCurrentConcurrencyKey(
environment,
run.queue,
run.concurrencyKey ?? undefined
)
)}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Get queue current concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`SMEMBERS ${withPrefix(
keys.queueCurrentConcurrencyKey(
environment,
run.queue,
run.concurrencyKey ?? undefined
)
)}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue current concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{queueCurrentConcurrency ?? "0"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue reserve concurrency key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(
keys.queueReserveConcurrencyKeyFromQueue(
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
)
)}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Get queue reserve concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`SMEMBERS ${withPrefix(
keys.queueReserveConcurrencyKeyFromQueue(
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
)
)}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue reserve concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{queueReserveConcurrency ?? "0"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue concurrency limit key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(keys.queueConcurrencyLimitKey(environment, run.queue))}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>GET queue concurrency limit</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`GET ${withPrefix(keys.queueConcurrencyLimitKey(environment, run.queue))}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue concurrency limit</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{queueConcurrencyLimit ?? "Not set"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env current concurrency key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(keys.envCurrentConcurrencyKey(environment))}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Get env current concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`SMEMBERS ${withPrefix(keys.envCurrentConcurrencyKey(environment))}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env current concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{envCurrentConcurrency ?? "0"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env reserve concurrency key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(keys.envReserveConcurrencyKey(environment.id))}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Get env reserve concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`SMEMBERS ${withPrefix(keys.envReserveConcurrencyKey(environment.id))}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env reserve concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{envReserveConcurrency ?? "0"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env concurrency limit key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={withPrefix(keys.envConcurrencyLimitKey(environment))}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>GET env concurrency limit</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`GET ${withPrefix(keys.envConcurrencyLimitKey(environment))}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env concurrency limit</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{envConcurrencyLimit ?? "Not set"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Shared queue key</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`GET ${withPrefix(keys.envSharedQueueKey(environment))}`}
variant="tertiary/small"
iconButton
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Get shared queue set</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField
value={`ZRANGEBYSCORE ${withPrefix(
keys.envSharedQueueKey(environment)
)} -inf ${Date.now()} WITHSCORES`}
variant="tertiary/small"
iconButton
/>
<Property.Label>Engine</Property.Label>
<Property.Value>
Engine V1 (v3) is retired. Queue debug data is no longer available for V1 runs.
</Property.Value>
</Property.Item>
</Property.Table>
@@ -352,7 +100,7 @@ function DebugRunDataEngineV2({
envConcurrencyLimit,
envCurrentConcurrency,
keys,
}: UseDataFunctionReturn<typeof loader>) {
}: Extract<UseDataFunctionReturn<typeof loader>, { engine: "V2" }>) {
return (
<Property.Table>
<Property.Item>
@@ -1,4 +1,5 @@
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { CopyableText } from "~/components/primitives/CopyableText";
import * as Property from "~/components/primitives/PropertyTable";
import {
Tooltip,
@@ -25,7 +26,10 @@ export function AdminDebugTooltip({ children }: { children?: React.ReactNode })
<TooltipTrigger>
<ShieldCheckIcon className="size-5" />
</TooltipTrigger>
<TooltipContent className="max-h-[90vh] overflow-y-auto">
{/* The copy controls below pass `hideTooltip` so their own tooltips don't fire
Radix's global close and dismiss this panel. `pr-8` leaves room for the
copy button, which is absolutely positioned to the right of each value. */}
<TooltipContent className="max-h-[90vh] overflow-y-auto pr-8">
<Content>{children}</Content>
</TooltipContent>
</Tooltip>
@@ -44,23 +48,31 @@ function Content({ children }: { children: React.ReactNode }) {
<Property.Table>
<Property.Item>
<Property.Label>User ID</Property.Label>
<Property.Value>{user.id}</Property.Value>
<Property.Value>
<CopyableText value={user.id} asChild hideTooltip />
</Property.Value>
</Property.Item>
{organization && (
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{organization.id}</Property.Value>
<Property.Value>
<CopyableText value={organization.id} asChild hideTooltip />
</Property.Value>
</Property.Item>
)}
{project && (
<>
<Property.Item>
<Property.Label>Project ID</Property.Label>
<Property.Value>{project.id}</Property.Value>
<Property.Value>
<CopyableText value={project.id} asChild hideTooltip />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Project ref</Property.Label>
<Property.Value>{project.externalRef}</Property.Value>
<Property.Value>
<CopyableText value={project.externalRef} asChild hideTooltip />
</Property.Value>
</Property.Item>
</>
)}
@@ -68,7 +80,9 @@ function Content({ children }: { children: React.ReactNode }) {
<>
<Property.Item>
<Property.Label>Environment ID</Property.Label>
<Property.Value>{environment.id}</Property.Value>
<Property.Value>
<CopyableText value={environment.id} asChild hideTooltip />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment type</Property.Label>
@@ -81,7 +95,7 @@ function Content({ children }: { children: React.ReactNode }) {
</>
)}
</Property.Table>
<div className="pt-2">{children}</div>
{children && <div className="pt-2">{children}</div>}
</div>
);
}
@@ -7,6 +7,7 @@ import { z } from "zod";
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
import { Callout } from "~/components/primitives/Callout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -51,10 +52,14 @@ type BillingLimitActionData = {
export function isBillingLimitFormDirty(input: {
billingLimit: BillingLimitResult;
mode: "none" | "plan" | "custom";
mode: "" | "none" | "plan" | "custom";
customAmount: string;
cancelInProgressRuns: boolean;
}): boolean {
if (input.mode === "") {
return false;
}
const needsInitialSave = !input.billingLimit.isConfigured;
const savedMode = getBillingLimitMode(input.billingLimit);
const savedCustomAmount =
@@ -75,7 +80,7 @@ export function isBillingLimitFormDirty(input: {
export function getBillingLimitFormLastSubmission(
submission: BillingLimitActionData["submission"] | undefined,
mode: "none" | "plan" | "custom",
mode: "" | "none" | "plan" | "custom",
isDirty: boolean
) {
if (!isDirty || !submission) {
@@ -111,17 +116,20 @@ export function BillingLimitConfigSection({
: "";
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
// Unconfigured limit starts with nothing selected.
const resetMode: "" | "none" | "plan" | "custom" = billingLimit.isConfigured ? savedMode : "";
const [mode, setMode] = useState<"" | "none" | "plan" | "custom">(resetMode);
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
const customAmountInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
setMode(savedMode);
setMode(resetMode);
setCustomAmount(savedCustomAmount);
setCancelInProgressRuns(savedCancelInProgressRuns);
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
}, [resetMode, savedCustomAmount, savedCancelInProgressRuns]);
function handleModeChange(value: string) {
const nextMode = value as typeof mode;
@@ -183,6 +191,13 @@ export function BillingLimitConfigSection({
</Paragraph>
</div>
{!billingLimit.isConfigured && (
<Callout variant="warning" className="mb-3">
Configure a monthly billing limit below to cap your spend, or set no limit to let runs
keep going.
</Callout>
)}
<Form method="post" {...getFormProps(form)} ref={formRef}>
<input type="hidden" name="intent" value="billing-limit" />
<Fieldset>
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
</div>
</RadioGroup>
{mode !== "none" && (
{(mode === "plan" || mode === "custom") && (
<CheckboxWithLabel
className="mt-4"
name="cancelInProgressRuns"
@@ -295,14 +310,16 @@ export function BillingLimitConfigSection({
onChange={setCancelInProgressRuns}
/>
)}
<FormButtons
className={isDirty ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!isDirty}>
Save billing limit
</Button>
}
/>
{mode !== "" && (
<FormButtons
className={isDirty ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!isDirty}>
Save billing limit
</Button>
}
/>
)}
</Fieldset>
</Form>
</div>
@@ -27,11 +27,11 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
<span className="text-2sm text-text-bright">Free Plan</span>
<div className="flex min-w-0 items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 shrink-0 text-text-dimmed" />
<span className="truncate text-2sm text-text-bright">Free Plan</span>
</div>
<Link to={to} className="text-2sm text-text-link focus-custom">
<Link to={to} className="shrink-0 text-2sm text-text-link focus-custom">
Upgrade
</Link>
</div>
@@ -208,6 +208,11 @@ export function isLegacyDollarAmountField(
return false;
}
// The exact $1 absolute base marker always wins, even with levels below 100 (e.g. a $5 alert).
if (rawAmount === ABSOLUTE_ALERT_BASE_CENTS) {
return false;
}
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
return false;
}
@@ -315,8 +320,9 @@ export function getAlertPreviewLimitCents(
planLimitCents: number
): number {
const amountCents = getSavedAlertAmountCents(alerts);
// Percentages always apply to the current limit, not the base stored at last save.
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
return amountCents;
return effectiveLimitCents;
}
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
return amountCents;
@@ -81,12 +81,15 @@ export function EnvironmentLabel({
tooltipSideOffset = 34,
tooltipSide = "right",
disableTooltip = false,
truncate = true,
}: {
environment: Environment;
className?: string;
tooltipSideOffset?: number;
tooltipSide?: "top" | "right" | "bottom" | "left";
disableTooltip?: boolean;
/** When false, the label clips without an ellipsis (side menu fades it in place). Defaults true. */
truncate?: boolean;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
@@ -113,7 +116,12 @@ export function EnvironmentLabel({
const content = (
<span
ref={spanRef}
className={cn("truncate text-left", environmentTextClassName(environment), className)}
className={cn(
truncate ? "truncate" : "overflow-hidden whitespace-nowrap",
"text-left",
environmentTextClassName(environment),
className
)}
>
{text}
</span>
@@ -7,7 +7,6 @@ import {
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
@@ -34,7 +33,7 @@ export function AccountSideMenu({ user }: { user: User }) {
<span className="text-text-bright">Back to app</span>
</LinkButton>
</div>
<div className="mb-6 flex grow flex-col overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="mb-6 flex grow flex-col overflow-y-auto pl-2.5 pr-0 pt-2 scrollbar-gutter-stable scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<SideMenuHeader title="Account" />
<SideMenuItem
name="Profile"
@@ -45,6 +44,7 @@ export function AccountSideMenu({ user }: { user: User }) {
/>
<SideMenuItem
name="Personal Access Tokens"
nameClassName="tracking-[-0.04em]"
icon={ShieldIcon}
activeIconColor="text-text-bright"
to={personalAccessTokensPath()}
@@ -60,7 +60,6 @@ export function AccountSideMenu({ user }: { user: User }) {
</div>
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
@@ -35,18 +35,26 @@ import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
// Size this Env popover's items to match the Project popover (SIDE_MENU_POPOVER_ITEM_* in
// SideMenu.tsx). Only at these call sites, so shared EnvironmentLabel/EnvironmentCombo defaults stay.
const ENV_POPOVER_ITEM_ICON = "size-5";
const ENV_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]";
export function EnvironmentSelector({
organization,
project,
environment,
className,
isCollapsed = false,
isDragging = false,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
isCollapsed?: boolean;
/** True while the side menu is being drag-resized; keeps the row in its expanded arrangement. */
isDragging?: boolean;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -73,42 +81,58 @@ export function EnvironmentSelector({
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-1.75 transition-colors hover:bg-background-hover",
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
"group flex h-8 items-center rounded pl-1.75 hover:bg-background-hover focus-custom",
// Expanded arrangement also applies mid-drag (resting classes flip only on release).
isDragging || !isCollapsed ? "justify-between pr-1" : "justify-center pr-0.5",
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
{/*
In the side menu, opacity + max-width follow --sm-label-opacity (1 → 0): the label
fades in place and scales its width to 0 so it never holds width mid-drag. The
selector is also reused outside the side menu (BlankStatePanels, limits) where the var
is unset — the 0.2 max-width fallback pins a ~200px cap (0.2 * 1000px) so long names
ellipsis-truncate there instead of widening the control, while opacity stays 1.
*/}
<span
className={cn(
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
className="flex min-w-0 items-center overflow-hidden"
style={{
maxWidth: "calc(var(--sm-label-opacity, 0.2) * 1000px)",
opacity: "var(--sm-label-opacity, 1)",
}}
>
<EnvironmentLabel
environment={environment}
className="text-[0.90625rem] font-medium tracking-[-0.01em]"
className="text-ellipsis text-[0.90625rem] font-medium tracking-[-0.01em]"
disableTooltip
truncate={false}
/>
</span>
</span>
{/*
Chevron's 16px width follows --sm-label-opacity so an invisible span never holds width
mid-drag and pushes the row's clip edge into the icon.
*/}
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
className="overflow-hidden opacity-0 group-hover:opacity-100"
style={{ maxWidth: "calc(var(--sm-label-opacity, 1) * 16px)" }}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
<DropdownIcon className="size-4 min-w-4 text-text-dimmed group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={environmentFullTitle(environment)}
content={`${environmentFullTitle(environment)} environment`}
side="right"
sideOffset={8}
// Tooltip only on the collapsed rail (expanded shows the label; this selector is also reused
// outside the side menu, where a hover tooltip is unwanted).
hidden={!isCollapsed}
delayDuration={0}
buttonClassName="h-8!"
asChild
tabbable
disableHoverableContent
/>
<PopoverContent
@@ -144,7 +168,13 @@ export function EnvironmentSelector({
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />}
title={
<EnvironmentCombo
environment={env}
className={cn("mx-auto grow", ENV_POPOVER_ITEM_LABEL)}
iconClassName={ENV_POPOVER_ITEM_ICON}
/>
}
isSelected={env.id === environment.id}
/>
);
@@ -162,8 +192,12 @@ export function EnvironmentSelector({
)}
title={
<div className="flex w-full items-center justify-between">
<EnvironmentCombo environment={{ type: "STAGING" }} className="text-2sm" />
<span className="text-indigo-500">Upgrade</span>
<EnvironmentCombo
environment={{ type: "STAGING" }}
className={ENV_POPOVER_ITEM_LABEL}
iconClassName={ENV_POPOVER_ITEM_ICON}
/>
<span className={cn("text-indigo-500", ENV_POPOVER_ITEM_LABEL)}>Upgrade</span>
</div>
}
isSelected={false}
@@ -176,8 +210,12 @@ export function EnvironmentSelector({
)}
title={
<div className="flex w-full items-center justify-between">
<EnvironmentCombo environment={{ type: "PREVIEW" }} className="text-2sm" />
<span className="text-indigo-500">Upgrade</span>
<EnvironmentCombo
environment={{ type: "PREVIEW" }}
className={ENV_POPOVER_ITEM_LABEL}
iconClassName={ENV_POPOVER_ITEM_ICON}
/>
<span className={cn("text-indigo-500", ENV_POPOVER_ITEM_LABEL)}>Upgrade</span>
</div>
}
isSelected={false}
@@ -199,10 +237,6 @@ function Branches({
branchEnvironments: SideMenuEnvironment[];
currentEnvironment: SideMenuEnvironment;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { urlForEnvironment } = useEnvironmentSwitcher();
const navigation = useNavigation();
const [isMenuOpen, setMenuOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -234,23 +268,6 @@ function Branches({
}, 150);
};
const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null);
const state =
branchEnvironments.length === 0
? "no-branches"
: activeBranches.length === 0
? "no-active-branches"
: "has-branches";
// Only surface the active environment's archived-branch item in the submenu it
// actually belongs to. Both Development and Preview render this component, so
// without the parent check an archived dev branch would leak into the Preview
// submenu (and vice-versa).
const currentBranchIsArchived =
environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id;
const envTextClassName = environmentTextClassName(parentEnvironment);
return (
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="flex">
@@ -263,7 +280,11 @@ function Branches({
textAlignLeft
fullWidth
>
<EnvironmentCombo environment={parentEnvironment} className="mx-auto grow text-2sm" />
<EnvironmentCombo
environment={parentEnvironment}
className={cn("mx-auto grow", ENV_POPOVER_ITEM_LABEL)}
iconClassName={ENV_POPOVER_ITEM_ICON}
/>
</ButtonContent>
</PopoverTrigger>
<PopoverContent
@@ -276,88 +297,135 @@ function Branches({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex flex-col gap-1 p-1">
{currentBranchIsArchived && (
<PopoverMenuItem
key={environment.id}
to={urlForEnvironment(environment)}
title={
<>
<span className={cn("block w-full", envTextClassName)}>
{environment.branchName}
</span>
<Badge variant="extra-small">Archived</Badge>
</>
}
icon={
<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />
}
isSelected={environment.id === currentEnvironment.id}
/>
)}
{state === "has-branches" ? (
<>
{branchEnvironments
.filter((env) => env.archivedAt === null)
.map((env) => (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={
<span className={cn("block w-full", envTextClassName)}>
{env.branchName ?? DEFAULT_DEV_BRANCH}
</span>
}
icon={
<BranchEnvironmentIconSmall
className={cn("size-4 shrink-0", envTextClassName)}
/>
}
isSelected={env.id === currentEnvironment.id}
/>
))}
</>
) : state === "no-branches" ? (
<div className="flex max-w-sm flex-col gap-1 p-2">
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall className={cn("size-4", envTextClassName)} />
<Header2>Create your first branch</Header2>
</div>
<Paragraph spacing variant="small">
Branches are a way to test new features in isolation before merging them into the
main environment.
</Paragraph>
<Paragraph 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>
</div>
) : (
<div className="flex max-w-sm flex-col gap-1 p-2">
<Paragraph variant="extra-small">All branches are archived.</Paragraph>
</div>
)}
</div>
<div className="border-t border-grid-bright p-1">
{parentEnvironment.type === "DEVELOPMENT" ? (
<PopoverMenuItem
to={branchesDevPath(organization, project, environment)}
title="Manage dev branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
) : (
<PopoverMenuItem
to={branchesPath(organization, project, environment)}
title="Manage preview branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
)}
</div>
<BranchesPopoverContent
parentEnvironment={parentEnvironment}
branchEnvironments={branchEnvironments}
currentEnvironment={currentEnvironment}
/>
</PopoverContent>
</div>
</Popover>
);
}
/**
* Inner content of the branches popover (list, empty states, "Manage branches" footer). Shared by
* the `Branches` hover submenu and the side-menu Preview popover.
*/
export function BranchesPopoverContent({
parentEnvironment,
branchEnvironments,
currentEnvironment,
}: {
parentEnvironment: SideMenuEnvironment;
branchEnvironments: SideMenuEnvironment[];
currentEnvironment: SideMenuEnvironment;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { urlForEnvironment } = useEnvironmentSwitcher();
const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null);
const state =
branchEnvironments.length === 0
? "no-branches"
: activeBranches.length === 0
? "no-active-branches"
: "has-branches";
// Show the archived-branch item only in the submenu it belongs to: both Development and Preview
// render this, so without the parent check an archived dev branch leaks into Preview (and vice-versa).
const currentBranchIsArchived =
environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id;
const envTextClassName = environmentTextClassName(parentEnvironment);
return (
<>
<div className="flex flex-col gap-1 p-1">
{parentEnvironment.type === "DEVELOPMENT" ? (
<PopoverMenuItem
to={branchesDevPath(organization, project, environment)}
title="Manage dev branches"
icon={<Cog8ToothIcon className={cn(ENV_POPOVER_ITEM_ICON, "text-text-dimmed")} />}
leadingIconClassName="text-text-dimmed"
className={ENV_POPOVER_ITEM_LABEL}
/>
) : (
<PopoverMenuItem
to={branchesPath(organization, project, environment)}
title="Manage preview branches"
icon={<Cog8ToothIcon className={cn(ENV_POPOVER_ITEM_ICON, "text-text-dimmed")} />}
leadingIconClassName="text-text-dimmed"
className={ENV_POPOVER_ITEM_LABEL}
/>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
{currentBranchIsArchived && (
<PopoverMenuItem
key={environment.id}
to={urlForEnvironment(environment)}
title={
<>
<span className={cn("block w-full", envTextClassName, ENV_POPOVER_ITEM_LABEL)}>
{environment.branchName}
</span>
<Badge variant="extra-small">Archived</Badge>
</>
}
icon={
<BranchEnvironmentIconSmall
className={cn(ENV_POPOVER_ITEM_ICON, "shrink-0", envTextClassName)}
/>
}
isSelected={environment.id === currentEnvironment.id}
/>
)}
{state === "has-branches" ? (
<>
{branchEnvironments
.filter((env) => env.archivedAt === null)
.map((env) => (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={
<span className={cn("block w-full", envTextClassName, ENV_POPOVER_ITEM_LABEL)}>
{env.branchName ?? DEFAULT_DEV_BRANCH}
</span>
}
icon={
<BranchEnvironmentIconSmall
className={cn(ENV_POPOVER_ITEM_ICON, "shrink-0", envTextClassName)}
/>
}
isSelected={env.id === currentEnvironment.id}
/>
))}
</>
) : state === "no-branches" ? (
<div className="flex max-w-sm flex-col gap-1 p-2">
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall className={cn("size-4", envTextClassName)} />
<Header2>Create your first branch</Header2>
</div>
<Paragraph spacing variant="small">
Branches are a way to test new features in isolation before merging them into the main
environment.
</Paragraph>
<Paragraph 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>
</div>
) : (
<div className="flex max-w-sm flex-col gap-1 p-2">
<Paragraph variant="extra-small">All branches are archived.</Paragraph>
</div>
)}
</div>
</>
);
}
@@ -1,25 +1,27 @@
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import { Fragment, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { BookIcon } from "~/assets/icons/BookIcon";
import { BulbIcon } from "~/assets/icons/BulbIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
import { StarIcon } from "~/assets/icons/StarIcon";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { cn } from "~/utils/cn";
import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
import { AskAIRoot } from "../AskAI";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
import { Button } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { SimpleTooltip } from "../primitives/Tooltip";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuItem, SideMenuItemButton } from "./SideMenuItem";
export function HelpAndFeedback({
disableShortcut = false,
@@ -49,135 +51,161 @@ export function HelpAndFeedback({
<motion.div
layout="position"
transition={{ duration: 0.2, ease: "easeInOut" }}
className={isCollapsed ? undefined : "flex-1"}
className={isCollapsed ? undefined : "min-w-0 flex-1"}
>
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-1.75 pr-2 transition-colors hover:bg-background-hover focus-custom",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
<span className="flex items-center gap-1.5 overflow-hidden">
<QuestionMarkIcon className="size-5 min-w-5 shrink-0 text-success" />
<span
{/* AskAIRoot hosts the Ask AI dialog + ⌘I shortcut outside the popover, so both survive the
popover closing; the popover just renders the trigger. */}
<AskAIRoot>
{(openAskAI) => (
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
"group flex h-8 items-center gap-1.5 rounded pl-1.75 pr-2 hover:bg-background-hover focus-custom",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<QuestionMarkIcon className="size-5 min-w-5 shrink-0 text-success" />
{/*
Width + opacity follow --sm-label-opacity so the label tracks a drag both
directions (no CSS transition — it would lag the per-frame writes).
*/}
<span
className="min-w-0 overflow-hidden whitespace-nowrap text-[0.90625rem] font-medium tracking-[-0.01em] text-text-dimmed group-hover:text-text-bright"
style={{
maxWidth: "calc(var(--sm-label-opacity, 1) * 150px)",
opacity: "var(--sm-label-opacity, 1)",
}}
>
Help & Feedback
</span>
</span>
{/*
Hover chevron, only when expanded. Its 16px width follows --sm-label-opacity so
an invisible chevron never holds width mid-drag and clips the help icon.
*/}
{!isCollapsed && (
<span
className="overflow-hidden opacity-0 group-hover:opacity-100"
style={{ maxWidth: "calc(var(--sm-label-opacity, 1) * 16px)" }}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed group-hover:text-text-bright" />
</span>
)}
</PopoverTrigger>
}
content={
<span className="flex items-center gap-1">
Help & Feedback
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</span>
</span>
<ShortcutKey
className={cn(
"size-4 flex-none transition-all duration-150",
isCollapsed ? "hidden" : ""
}
side="right"
sideOffset={8}
delayDuration={isCollapsed ? 0 : 500}
buttonClassName="h-8! w-full"
asChild
tabbable
disableHoverableContent
/>
<PopoverContent
className="min-w-56 divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
>
<Fragment>
{openAskAI !== undefined && (
<div className="flex flex-col gap-1 p-1">
<SideMenuItemButton
icon={AISparkleIcon}
name="Ask AI"
data-action="ask-ai"
trailing={
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "i" }} variant="medium" />
}
onClick={() => {
setHelpMenuOpen(false);
openAskAI();
}}
/>
</div>
)}
shortcut={{ key: "h" }}
variant="medium/bright"
/>
</PopoverTrigger>
}
content={
<span className="flex items-center gap-1">
Help & Feedback
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</span>
}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="h-8! w-full"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-56 divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
>
<Fragment>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Documentation"
icon={BookIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Status"
icon={RadarPulseIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<SideMenuItem
name="Suggest a feature"
icon={BulbIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://feedback.trigger.dev/"
data-action="suggest-a-feature"
target="_blank"
/>
<Shortcuts />
<Feedback
button={
<Button
variant="small-menu-item"
className="pl-2"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="pr-1 text-text-dimmed group-hover/button:text-text-bright"
data-action="contact-us"
fullWidth
textAlignLeft
>
Contact us
</Button>
}
/>
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">What's new</Paragraph>
{changelogs.map((entry) => (
<SideMenuItem
key={entry.id}
name={entry.title}
icon={GrayDotIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to={sanitizeHttpUrl(entry.actionUrl) ?? "https://trigger.dev/changelog"}
target="_blank"
/>
))}
<SideMenuItem
name="Full changelog"
icon={StarIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to="https://trigger.dev/changelog"
data-action="full-changelog"
target="_blank"
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Documentation"
icon={BookIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Status"
icon={RadarPulseIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<SideMenuItem
name="Suggest a feature"
icon={BulbIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://feedback.trigger.dev/"
data-action="suggest-a-feature"
target="_blank"
/>
<Shortcuts />
<Feedback
button={
<SideMenuItemButton
icon={EnvelopeIcon}
name="Contact us…"
data-action="contact-us"
/>
}
/>
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">What's new</Paragraph>
{changelogs.map((entry) => (
<SideMenuItem
key={entry.id}
name={entry.title}
icon={GrayDotIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to={sanitizeHttpUrl(entry.actionUrl) ?? "https://trigger.dev/changelog"}
target="_blank"
/>
))}
<SideMenuItem
name="Full changelog"
icon={StarIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-text-dimmed"
activeIconColor="text-text-dimmed"
to="https://trigger.dev/changelog"
data-action="full-changelog"
target="_blank"
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
)}
</AskAIRoot>
</motion.div>
);
}
@@ -121,7 +121,7 @@ export function NotificationPanel({
return (
<Popover>
<div className={isCollapsed ? "p-1" : "p-2"}>
<div className={isCollapsed ? "p-1" : "p-2 pt-0"}>
{isCollapsed ? (
<SimpleTooltip
asChild
@@ -1,5 +1,6 @@
import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { BellIcon } from "~/assets/icons/BellIcon";
import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
import { UsageIcon } from "~/assets/icons/UsageIcon";
@@ -34,7 +35,6 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Paragraph } from "../primitives/Paragraph";
import { Badge } from "../primitives/Badge";
import { useHasAdminAccess } from "~/hooks/useUser";
import { AskAI } from "../AskAI";
export type BuildInfo = {
appVersion: string | undefined;
@@ -79,11 +79,19 @@ export function OrganizationSettingsSideMenu({
<span className="text-text-bright">Back to app</span>
</LinkButton>
</div>
<div className="mb-6 flex grow flex-col gap-4 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="mb-6 flex grow flex-col gap-4 overflow-y-auto pl-2.5 pr-0 pt-2 scrollbar-gutter-stable scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Organization" />
</div>
<SideMenuItem
name="Settings"
icon={SlidersIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationSettingsPath(organization)}
data-action="settings"
/>
{isManagedCloud && (
<>
<SideMenuItem
@@ -130,7 +138,7 @@ export function OrganizationSettingsSideMenu({
{featureFlags.hasPrivateConnections && (
<SideMenuItem
name="Private Connections"
icon={LinkIcon}
icon={ChainLinkIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3PrivateConnectionsPath(organization)}
@@ -155,21 +163,8 @@ export function OrganizationSettingsSideMenu({
inactiveIconColor="text-text-dimmed"
to={organizationSsoPath(organization)}
data-action="sso"
badge={
currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : (
<Badge variant="extra-small">Enterprise</Badge>
)
}
/>
)}
<SideMenuItem
name="Settings"
icon={SlidersIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
@@ -241,7 +236,6 @@ export function OrganizationSettingsSideMenu({
</div>
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback organizationId={organization.id} />
<AskAI />
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -40,15 +40,8 @@ export function SideMenuHeader({
<h2 className="text-xs whitespace-nowrap">
{visiblePart}
{fadingPart && (
<motion.span
initial={false}
animate={{
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{fadingPart}
</motion.span>
// --sm-label-opacity morphs "Project" → "Proj" as the menu narrows (unset elsewhere → 1).
<span style={{ opacity: "var(--sm-label-opacity, 1)" }}>{fadingPart}</span>
)}
</h2>
{children !== undefined ? (
@@ -1,4 +1,9 @@
import { type AnchorHTMLAttributes, type ReactNode } from "react";
import {
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
forwardRef,
type ReactNode,
} from "react";
import { Link } from "@remix-run/react";
import { motion } from "framer-motion";
import { usePathName } from "~/hooks/usePathName";
@@ -14,6 +19,7 @@ export function SideMenuItem({
trailingIcon,
trailingIconClassName,
name,
nameClassName,
to,
badge,
target,
@@ -30,18 +36,14 @@ export function SideMenuItem({
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
name: string;
nameClassName?: string;
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
action?: ReactNode;
disableIconHover?: boolean;
/**
* Visually indented variant — same item, just pushed further from
* the left edge so it reads as a child of the row above. Used for
* grouped sub-items like the Tasks > (Agents / Standard / Scheduled)
* cluster. The indent is only applied when the side menu is expanded.
*/
/** Indented variant for grouped sub-items; only applied when the menu is expanded. */
indented?: boolean;
"data-action"?: string;
}) {
@@ -56,7 +58,7 @@ export function SideMenuItem({
target={target}
data-action={dataAction}
className={cn(
"group/menulink flex h-8 items-center gap-2 overflow-hidden rounded pl-1.75 pr-2",
"group/menulink flex h-8 items-center gap-2 overflow-hidden rounded pl-1.75 pr-2 focus-custom",
isIndented ? "min-w-0 flex-1" : "w-full",
isActive
? "bg-tertiary text-text-bright"
@@ -75,32 +77,39 @@ export function SideMenuItem({
)}
/>
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
className="min-w-0 flex-1 overflow-hidden"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<span className="select-none truncate text-[0.90625rem] font-medium tracking-[-0.01em]">
{name}
</span>
{badge && !isCollapsed && (
<motion.div
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
opacity: 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
{/*
Label opacity follows --sm-label-opacity so it fades as the menu narrows (unset
elsewhere → 1, fully visible).
*/}
<div
className="flex w-full min-w-0 items-center justify-between"
style={{ opacity: "var(--sm-label-opacity, 1)" }}
>
<span
className={cn(
"select-none overflow-hidden whitespace-nowrap text-[0.90625rem] font-medium tracking-[-0.01em]",
nameClassName
)}
>
{badge}
</motion.div>
)}
{trailingIcon && !isCollapsed && (
<Icon icon={trailingIcon} className={cn("ml-1 size-4 shrink-0", trailingIconClassName)} />
)}
{name}
</span>
{badge && !isCollapsed && (
<div className="ml-1 flex shrink-0 items-center gap-1">{badge}</div>
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
</div>
</motion.div>
</Link>
);
@@ -125,9 +134,11 @@ export function SideMenuItem({
buttonClassName="h-8! block w-full"
hidden={!isCollapsed}
asChild
tabbable
disableHoverableContent
/>
{!isCollapsed && (
// Fades with the labels via --sm-label-opacity (unset → fully visible).
<div
className={cn(
"absolute bottom-1 right-1 top-1 flex aspect-square items-center justify-center rounded",
@@ -135,6 +146,7 @@ export function SideMenuItem({
? "group-hover/menuitem:bg-tertiary"
: "group-hover/menuitem:bg-background-hover"
)}
style={{ opacity: "var(--sm-label-opacity, 1)" }}
>
{action}
</div>
@@ -152,7 +164,35 @@ export function SideMenuItem({
buttonClassName="h-8! block w-full"
hidden={!isCollapsed}
asChild
tabbable
disableHoverableContent
/>
);
}
/** Button styled to match {@link SideMenuItem}, for entries that open a dialog rather than navigate. */
export const SideMenuItemButton = forwardRef<
HTMLButtonElement,
{ icon: RenderIcon; name: string; trailing?: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>
>(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) {
return (
<button
ref={ref}
type={type ?? "button"}
className={cn(
"group/menuitem flex h-8 w-full items-center gap-2 overflow-hidden rounded pl-1.75 pr-2 text-left text-text-dimmed hover:bg-background-hover hover:text-text-bright focus-custom",
className
)}
{...props}
>
<Icon
icon={icon}
className="size-5 shrink-0 text-text-dimmed group-hover/menuitem:text-text-bright"
/>
<span className="min-w-0 flex-1 select-none truncate text-[0.90625rem] font-medium tracking-[-0.01em]">
{name}
</span>
{trailing && <span className="flex shrink-0 items-center gap-1">{trailing}</span>}
</button>
);
});
@@ -1,5 +1,5 @@
import { AnimatePresence, motion } from "framer-motion";
import React, { useCallback, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon";
type Props = {
@@ -14,9 +14,7 @@ type Props = {
headerAction?: React.ReactNode;
};
/** A collapsible section for the side menu
* The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state.
*/
/** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */
export function SideMenuSection({
title,
initialCollapsed = false,
@@ -27,6 +25,7 @@ export function SideMenuSection({
headerAction,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
const contentRef = useRef<HTMLDivElement>(null);
const handleToggle = useCallback(() => {
const newIsCollapsed = !isCollapsed;
@@ -34,22 +33,37 @@ export function SideMenuSection({
onCollapseToggle?.(newIsCollapsed);
}, [isCollapsed, onCollapseToggle]);
// Collapsed items stay in the DOM (height 0) for the animation, so `inert` removes them from the
// tab order and a11y tree (it doesn't affect layout). Set the DOM property directly — React 18's
// `inert` prop handling is unreliable.
useEffect(() => {
if (contentRef.current) {
contentRef.current.inert = isCollapsed;
}
}, [isCollapsed]);
return (
<div className="w-full overflow-hidden">
{/* Header container - stays in DOM to preserve height */}
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-background-hover"
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
{/*
Header fades out as the menu narrows via --sm-label-opacity (falls back to 1 unset). Hover
background and text color snap (no transition), matching the nav items.
*/}
<button
type="button"
// A real button for native keyboard toggle + focus ring. Out of the tab order when the
// menu is collapsed (the header is hidden and can't be toggled).
className="group/section flex w-full cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 hover:bg-background-hover focus-custom"
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
tabIndex={isSideMenuCollapsed ? -1 : undefined}
aria-expanded={!isCollapsed}
style={{
opacity: "var(--sm-label-opacity, 1)",
cursor: isSideMenuCollapsed ? "default" : "pointer",
}}
>
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
<div className="flex items-center gap-1 text-text-dimmed group-hover/section:text-text-bright">
<h2 className="whitespace-nowrap text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
@@ -60,19 +74,18 @@ export function SideMenuSection({
</motion.div>
</div>
{headerAction && <div className="flex items-center">{headerAction}</div>}
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
</button>
{/*
Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded.
*/}
<div
className="absolute left-2 right-2 top-1 h-px bg-surface-control"
initial={false}
animate={{
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
style={{ opacity: isCollapsed ? 0 : "var(--sm-collapse, 0)" }}
/>
</div>
<AnimatePresence initial={false}>
<motion.div
ref={contentRef}
className="w-full"
initial={isCollapsed ? "collapsed" : "expanded"}
animate={isCollapsed ? "collapsed" : "expanded"}
@@ -10,7 +10,6 @@ import {
} from "@heroicons/react/20/solid";
import type { Prisma } from "@trigger.dev/database";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { cn } from "~/utils/cn";
export const AvatarType = z.enum(["icon", "letters", "image"]);
@@ -45,7 +44,7 @@ export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avat
const parsed = AvatarData.safeParse(json);
if (!parsed.success) {
logger.error("Invalid org avatar", { json, error: parsed.error });
console.error("Invalid org avatar", { json, error: parsed.error });
return defaultAvatar;
}
@@ -60,9 +60,43 @@ const variants = {
},
};
const SECURE_MASK = "••••••••••••••••";
/**
* Builds the masked display string, optionally revealing the first/last few
* characters in cleartext so users can confirm a copied value. A custom mask
* string (when `secure` is a string) is always shown as-is.
*/
function maskValue(
value: string,
secure: boolean | string,
revealStart: number,
revealEnd: number
) {
if (typeof secure === "string") {
return secure;
}
const start = Math.max(0, revealStart);
const end = Math.max(0, revealEnd);
// Nothing to reveal, or revealing would leak the whole value: fully mask.
if ((start === 0 && end === 0) || start + end >= value.length) {
return SECURE_MASK;
}
const revealedStart = start > 0 ? value.slice(0, start) : "";
const revealedEnd = end > 0 ? value.slice(-end) : "";
return `${revealedStart}${SECURE_MASK}${revealedEnd}`;
}
type ClipboardFieldProps = {
value: string;
secure?: boolean | string;
/** When masked, reveal this many of the first characters in cleartext. */
secureRevealStart?: number;
/** When masked, reveal this many of the last characters in cleartext. */
secureRevealEnd?: number;
variant: keyof typeof variants;
className?: string;
icon?: React.ReactNode;
@@ -73,6 +107,8 @@ type ClipboardFieldProps = {
export function ClipboardField({
value,
secure = false,
secureRevealStart = 0,
secureRevealEnd = 0,
variant,
className,
icon,
@@ -87,6 +123,8 @@ export function ClipboardField({
setIsSecure(secure !== undefined && secure);
}, [secure]);
const maskedValue = maskValue(value, secure, secureRevealStart, secureRevealEnd);
return (
<span className={cn(container, fullWidth ? "w-full" : "max-w-fit", className)}>
{icon && (
@@ -100,7 +138,7 @@ export function ClipboardField({
<input
type="text"
ref={inputIcon}
value={isSecure ? (typeof secure === "string" ? secure : "••••••••••••••••") : value}
value={isSecure ? maskedValue : value}
readOnly={true}
className={cn(
"shrink grow select-all overflow-x-auto",
@@ -11,12 +11,19 @@ export function CopyableText({
className,
asChild,
variant,
hideTooltip,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
variant?: "icon-right" | "text-below";
/**
* Hide the "Copy"/"Copied" hint tooltip. Use when this is rendered inside another
* Radix tooltip (e.g. the admin debug panel): the nested tooltip would otherwise
* fire Radix's global "one tooltip open at a time" close and dismiss the parent.
*/
hideTooltip?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
@@ -24,6 +31,24 @@ export function CopyableText({
const resolvedVariant = variant ?? "icon-right";
if (resolvedVariant === "icon-right") {
const iconButton = (
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
);
return (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
@@ -38,29 +63,17 @@ export function CopyableText({
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
{hideTooltip ? (
iconButton
) : (
<SimpleTooltip
button={iconButton}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
)}
</span>
</span>
);
@@ -1,143 +0,0 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils/cn";
const sizes = {
"secondary/small":
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-border-bright pr-2 pl-1.5",
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-border-bright px-2.5",
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
};
export type SelectProps = {
size?: keyof typeof sizes;
width?: "content" | "full";
};
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
const sizeClassName = sizes[size];
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:focus-custom disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown
className={cn(
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md border border-grid-bright bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"space-y-0.5 px-1 py-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"-ml-1 -mr-1 mb-1 bg-background-deep py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:mt-0",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
contentClassName?: string;
};
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
({ className, children, contentClassName, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-hidden transition data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-background-hover focus:bg-background-hover/50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -63,6 +63,7 @@ function SimpleTooltip({
buttonClassName,
buttonStyle,
asChild = false,
tabbable = false,
sideOffset,
open,
onOpenChange,
@@ -78,6 +79,9 @@ function SimpleTooltip({
buttonClassName?: string;
buttonStyle?: React.CSSProperties;
asChild?: boolean;
/** Set when the trigger wraps an interactive element that should stay tabbable; default removes
* it from the tab order (decorative tooltips add no tab stops). */
tabbable?: boolean;
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -88,7 +92,7 @@ function SimpleTooltip({
<Tooltip open={open} onOpenChange={onOpenChange} delayDuration={delayDuration}>
<TooltipTrigger
type={asChild ? undefined : "button"}
tabIndex={-1}
tabIndex={tabbable ? undefined : -1}
className={cn(!asChild && "h-fit", buttonClassName)}
style={buttonStyle}
asChild={asChild}
@@ -0,0 +1,117 @@
import { useLocation, useNavigation, useRevalidator } from "@remix-run/react";
import { type MutableRefObject, useEffect } from "react";
import { Button } from "~/components/primitives/Buttons";
import { PulsingDot } from "~/components/primitives/PulsingDot";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
import { useRunsLiveReload } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload";
import { TaskRunsTable } from "./TaskRunsTable";
/**
* Compact "N new runs" button, shown in a task page's header to the left of the
* time filter when the live-reload hook has detected newer runs.
*/
export function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) {
return (
<span className="flex duration-150 animate-in fade-in-0">
<Button
variant="secondary/small"
className="text-text-bright"
onClick={onClick}
LeadingIcon={<PulsingDot className="h-2 w-2" />}
tooltip="Refresh to see new runs"
aria-label="New runs created. Refresh to see new runs."
>
{count >= 100 ? "99+ new runs" : `${count} new ${count === 1 ? "run" : "runs"}`}
</Button>
</span>
);
}
/**
* Runs table with live updating, shared by the standard and scheduled task
* landing pages. Mirrors the Runs list page: active rows are patched in place
* (status/timing/cost). The "N new runs" count is surfaced to the top-bar
* button via `onNewRunsCountChange` (count drives visibility) and
* `showNewRunsRef` (the latest click action), since the button lives outside
* this deferred boundary. The task lives in the route path rather than a
* `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task.
*/
export function TaskRunsList({
list,
taskSlug,
onNewRunsCountChange,
showNewRunsRef,
}: {
list: NextRunList;
taskSlug: string;
onNewRunsCountChange: (count: number) => void;
showNewRunsRef: MutableRefObject<() => void>;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const navigation = useNavigation();
const location = useLocation();
const { has, replace } = useSearchParams();
const revalidator = useRevalidator();
// Loading a new version of this same page (time filter / pagination change).
const isLoading =
navigation.state === "loading" &&
navigation.location !== undefined &&
navigation.location.pathname === location.pathname &&
navigation.location.search !== location.search;
const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload(
{
runs: list.runs,
hasAnyRuns: list.hasAnyRuns,
isLoading,
organizationSlug: organization.slug,
projectSlug: project.slug,
environmentSlug: environment.slug,
taskSlug,
}
);
const onClickShowNewRuns = () => {
const isPaginated = has("cursor") || has("direction");
dismissNewRuns();
if (isPaginated) {
replace({ cursor: undefined, direction: undefined });
return;
}
revalidator.revalidate();
};
// Surface the banner to the top-bar button rendered by the page: keep the
// ref's action current, mirror the count up, and clear it when this boundary
// unmounts (e.g. the table re-suspends on a filter change).
useEffect(() => {
showNewRunsRef.current = onClickShowNewRuns;
}, [onClickShowNewRuns, showNewRunsRef]);
useEffect(() => {
onNewRunsCountChange(newRunsCount);
}, [newRunsCount, onNewRunsCountChange]);
useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]);
return (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TaskRunsTable
total={visibleRuns.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={visibleRuns}
childrenStatusesBasePath={childrenStatusesBasePath}
isLoading={isLoading}
variant="dimmed"
showTopBorder={false}
stickyHeader
/>
</div>
);
}
+88 -4
View File
@@ -25,6 +25,7 @@ import {
assertSplitRealtimeInterlock,
} from "./v3/runOpsMigration/splitMode.server";
import { computeRunOpsSplitReadEnabled } from "./v3/runOpsMigration/runOpsSplitReadGate";
import { assertControlPlaneCoresidencyAdvisory } from "./v3/runOpsMigration/controlPlaneCoresidencySentinel.server";
import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server";
import type { Span } from "@opentelemetry/api";
import { context, trace } from "@opentelemetry/api";
@@ -188,13 +189,20 @@ export type RunOpsTopology = {
export type SelectRunOpsTopologyConfig = {
splitEnabled: boolean;
legacyUrl?: string;
legacyReplicaUrl?: string;
newUrl?: string;
newReplicaUrl?: string;
// When true, legacy reuses the control-plane client instead of opening its own pool. Defaults to false.
legacySharesControlPlane?: boolean;
};
export type RunOpsClientBuilders = {
controlPlane: RunOpsClients;
buildNewWriter: (url: string, clientType: string) => RunOpsPrismaClient;
buildNewReplica: (url: string, clientType: string) => RunOpsPrismaClient;
// Legacy builders return the same PrismaClient/PrismaReplicaClient types as the control plane (no
// RunOpsPrismaClient double-cast needed): the legacy DB carries the full control-plane schema.
buildLegacyWriter: (url: string, clientType: string) => PrismaClient;
buildLegacyReplica: (url: string, clientType: string) => PrismaReplicaClient;
};
// Pure run-ops client selector. No env, no isSplitEnabled() — those
@@ -220,7 +228,17 @@ export function selectRunOpsTopology(
return { newRunOps: cpFallback, legacyRunOps: controlPlane, controlPlane };
}
const legacyRunOps = controlPlane;
// Same-DB legacy reuses the control-plane pool; only build a separate pool once the DSNs diverge.
let legacyRunOps: RunOpsClients;
if (config.legacySharesControlPlane) {
legacyRunOps = controlPlane;
} else {
const legacyWriter = builders.buildLegacyWriter(config.legacyUrl, "run-ops-legacy-writer");
const legacyReplica: PrismaReplicaClient = config.legacyReplicaUrl
? builders.buildLegacyReplica(config.legacyReplicaUrl, "run-ops-legacy-reader")
: legacyWriter;
legacyRunOps = { writer: legacyWriter, replica: legacyReplica };
}
const newWriter = builders.buildNewWriter(config.newUrl, "run-ops-new-writer");
const newReplica: RunOpsPrismaClient = config.newReplicaUrl
@@ -246,12 +264,32 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
// Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on.
const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.RUN_OPS_LEGACY_DATABASE_URL;
// Alias legacy onto the control-plane pool when both roles resolve to the same DB (replica URLs
// fall back to their writer, matching how the clients themselves fall back).
const cpWriterUrl = env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL;
const cpReplicaUrl = env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL;
const legacySharesControlPlane =
sameDatabaseTarget(env.RUN_OPS_LEGACY_DATABASE_URL, cpWriterUrl) &&
sameDatabaseTarget(
env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL ?? env.RUN_OPS_LEGACY_DATABASE_URL,
cpReplicaUrl ?? cpWriterUrl
);
// Only meaningful for an independent legacy pool; a shared pool routes reads through $replica.
if (splitEnabled && !legacySharesControlPlane && !env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL) {
logger.warn(
"RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is unset while split is enabled; legacy reads will hit the legacy primary"
);
}
return selectRunOpsTopology(
{
splitEnabled,
legacyUrl: env.RUN_OPS_LEGACY_DATABASE_URL,
legacyReplicaUrl: env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL,
newUrl,
newReplicaUrl: env.RUN_OPS_DATABASE_READ_REPLICA_URL,
legacySharesControlPlane,
},
{
controlPlane: { writer: prisma, replica: $replica },
@@ -268,6 +306,18 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
tagDatasourceRunOps("replica", buildRunOpsReplicaClient({ url, clientType }))
)
),
// Legacy client shares the exact control-plane wrapper stack (the legacy DB carries the full
// control-plane schema); markReadReplicaClient only on a real replica URL, as with the NEW replica.
buildLegacyWriter: (url, clientType) =>
captureInfrastructureErrors(
tagDatasource("writer", buildWriterClient({ url, clientType }))
),
buildLegacyReplica: (url, clientType) =>
markReadReplicaClient(
captureInfrastructureErrors(
tagDatasource("replica", buildReplicaClient({ url, clientType }))
)
),
}
);
});
@@ -281,8 +331,17 @@ export const runOpsNewPrisma: PrismaClient = runOpsTopology.newRunOps
.writer as unknown as PrismaClient;
export const runOpsNewReplica: PrismaReplicaClient = runOpsTopology.newRunOps
.replica as unknown as PrismaReplicaClient;
// Track 2: under split-on these point at the INDEPENDENT legacy client (its own DSN); under split-off
// or missing URLs they still alias the control-plane client, so single-DB installs are unchanged.
export const runOpsLegacyPrisma: PrismaClient = runOpsTopology.legacyRunOps.writer;
export const runOpsLegacyReplica: PrismaReplicaClient = runOpsTopology.legacyRunOps.replica;
// Branded legacy handles typed as RunOpsPrismaClient for the run-store boundary — same underlying
// legacy writer/replica as runOpsLegacyPrisma/runOpsLegacyReplica above, but carrying the run-ops
// brand so the guard classifies provably-legacy access as `runops`, not `cp`.
export const runOpsLegacyPrismaClient: RunOpsPrismaClient = runOpsTopology.legacyRunOps
.writer as unknown as RunOpsPrismaClient;
export const runOpsLegacyReplicaClient: RunOpsPrismaClient = runOpsTopology.legacyRunOps
.replica as unknown as RunOpsPrismaClient;
export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
newReplica: runOpsNewReplicaClient,
@@ -295,8 +354,8 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
// Boot-time interlock: if the flag is on but the distinct-DB sentinel does not
// confirm two physically-distinct run-ops DBs, refuse to enable split (data-loss
// interlock). Async, so it cannot live in the synchronous singleton factory —
// call it from the eager-boot path before any run-ops routing is wired.
// interlock). Async, so it cannot live in the synchronous singleton factory — called
// fire-and-forget from the eager-boot path (routing is wired synchronously at module load).
export async function assertRunOpsSplitSentinel(): Promise<void> {
if (!env.RUN_OPS_SPLIT_ENABLED) return;
// Realtime interlock (synchronous): Electric replicates only from the control-plane
@@ -312,6 +371,9 @@ export async function assertRunOpsSplitSentinel(): Promise<void> {
"RUN_OPS_SPLIT_ENABLED is on but the distinct-DB sentinel did not confirm two physically-distinct run-ops DBs; refusing to enable split (data-loss interlock)."
);
}
// Advisory-only (T2.3): observe legacy vs control-plane co-residency. Emits a metric + log and only
// throws when RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT is on AND co-residency is positively confirmed.
await assertControlPlaneCoresidencyAdvisory();
}
function getClient() {
@@ -662,7 +724,10 @@ function buildRunOpsReplicaClient({
clientType: string;
}): RunOpsPrismaClient {
const replicaUrl = extendQueryParams(url, {
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
// The new run-ops replica connects unpooled, so allow capping it independently of the writer.
connection_limit: (
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT
).toString(),
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
application_name: env.SERVICE_NAME,
@@ -705,6 +770,25 @@ function buildRunOpsReplicaClient({
return client;
}
// True when two DSNs point at the same database (host/port/dbname/user), ignoring query params and
// password. Parse failure or a missing URL returns false, so an unrecognized DSN just isn't aliased.
export function sameDatabaseTarget(a: string | undefined, b: string | undefined): boolean {
if (!a || !b) return false;
try {
const ua = new URL(a);
const ub = new URL(b);
const port = (u: URL) => u.port || "5432";
return (
ua.hostname.toLowerCase() === ub.hostname.toLowerCase() &&
port(ua) === port(ub) &&
ua.pathname === ub.pathname &&
ua.username === ub.username
);
} catch {
return false;
}
}
function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record<string, string>) {
const url = new URL(hrefOrUrl);
const query = url.searchParams;
+7 -14
View File
@@ -7,7 +7,6 @@ import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { PassThrough } from "stream";
import * as Worker from "~/services/worker.server";
import { initMollifierDrainerWorker } from "~/v3/mollifierDrainerWorker.server";
import { initMollifierStaleSweepWorker } from "~/v3/mollifierStaleSweepWorker.server";
import { initBillingLimitWorker } from "~/v3/billingLimitWorker.server";
@@ -19,7 +18,6 @@ import { assertRunOpsSplitSentinel, Prisma } from "./db.server";
import { env } from "./env.server";
import { eventLoopMonitor } from "./eventLoopMonitor.server";
import { logger } from "./services/logger.server";
import { resourceMonitor } from "./services/resourceMonitor.server";
import { singleton } from "./utils/singleton";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
import {
@@ -56,6 +54,12 @@ export default function handleRequest(
) {
const url = new URL(request.url);
// Stale documents reference /build asset hashes that 404 after a deploy —
// always revalidate HTML. Route-set headers win.
if (!responseHeaders.has("Cache-Control")) {
responseHeaders.set("Cache-Control", "no-cache");
}
if (url.pathname.startsWith("/login")) {
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
responseHeaders.set("Content-Security-Policy", "frame-ancestors 'self'");
@@ -227,10 +231,6 @@ export const handleError = wrapHandleErrorWithSentry((error, { request }) => {
}
});
Worker.init().catch((error) => {
logError(error);
});
initMollifierDrainerWorker();
initMollifierStaleSweepWorker();
initBillingLimitWorker();
@@ -241,10 +241,6 @@ bootstrap().catch((error) => {
function logError(error: unknown, request?: Request) {
console.error(error);
if (error instanceof Error && error.message.startsWith("There are locked jobs present")) {
console.log("⚠️ graphile-worker migration issue detected!");
}
}
process.on("uncaughtException", (error, origin) => {
@@ -304,6 +300,7 @@ singleton("SentryTenantContextProcessor", () => {
export { apiRateLimiter } from "./services/apiRateLimit.server";
export { engineRateLimiter } from "./services/engineRateLimit.server";
export { otlpRateLimiter } from "./services/otlpRateLimit.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
export { tenantContextMiddleware } from "./services/tenantContextResolver.server";
export { socketIo } from "./v3/handleSocketIo.server";
@@ -318,7 +315,3 @@ if (remoteBuildsEnabled()) {
} else {
console.log("🏗️ Local builds enabled");
}
if (env.RESOURCE_MONITOR_ENABLED === "1") {
resourceMonitor.startMonitoring(1000);
}
+170 -123
View File
@@ -85,6 +85,29 @@ const S2EnvSchema = z.preprocess(
])
);
// Previously published secret values must never be accepted, including when
// an existing deployment or external secret manager still supplies one.
const INSECURE_SECRET_VALUES = [
"managed-secret",
"2818143646516f6fffd707b36f334bbb",
"44da78b7bbb0dfe709cf38931d25dcdd",
"f686147ab967943ebbe9ed3b496e465a",
"447c29678f9eaf289e9c4b70d3dd8a7f",
];
// Escape hatch for deployments that can't rotate a published default yet (e.g.
// ENCRYPTION_KEY protects existing data). Read raw: a refine can't see the
// sibling parsed flag.
const allowInsecureDefaultSecrets = ["true", "1"].includes(
(process.env.ALLOW_INSECURE_DEFAULT_SECRETS ?? "").toLowerCase().trim()
);
const isNotInsecureSecret = (value: string) =>
allowInsecureDefaultSecrets || !INSECURE_SECRET_VALUES.includes(value);
const INSECURE_SECRET_MESSAGE =
"must not be a known-insecure published default; set a strong, unique value. If you cannot rotate it yet (e.g. it protects existing encrypted data or active sessions), set ALLOW_INSECURE_DEFAULT_SECRETS=1 to boot while you migrate.";
const EnvironmentSchema = z
.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -113,6 +136,8 @@ const EnvironmentSchema = z
// agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides
// (org featureFlags) win regardless.
DASHBOARD_AGENT_ENABLED: z.string().default("0"),
// Gates the create-org management API endpoint (default off).
ORG_CREATION_API_ENABLED: z.string().default("0"),
// "1" gives admins/impersonators an everywhere-preview (default off),
// separate from the per-org rollout flag above.
DASHBOARD_AGENT_ADMIN_PREVIEW: z.string().default("0"),
@@ -138,8 +163,10 @@ const EnvironmentSchema = z
.string()
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid")
.optional(),
// The LEGACY run-ops DB (the control-plane DB during the transition). When unset, legacy
// run-ops reuses the existing DATABASE_URL (legacy run-ops == control-plane DB initially).
// The LEGACY run-ops DB. Now a CONNECTED DSN (Track 2): when split is on and this is set it builds
// an INDEPENDENT legacy Prisma client, no longer an alias of the control-plane client (nor merely
// the sentinel's probe target). Unset -> legacy reuses the control-plane client / DATABASE_URL, so
// single-DB and self-host installs boot byte-identical.
RUN_OPS_LEGACY_DATABASE_URL: z
.string()
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_URL is invalid")
@@ -151,6 +178,24 @@ const EnvironmentSchema = z
.string()
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_READ_REPLICA_URL is invalid")
.optional(),
// The LEGACY run-ops DB read replica (Track 2). Unset -> the legacy replica handle falls back to the
// legacy WRITER (as $replica does with no CP replica). Set in production so legacy reads hit the reader.
RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL: z
.string()
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is invalid")
.optional(),
// Optional cap for the unpooled new run-ops read replica. Unset falls back to DATABASE_CONNECTION_LIMIT.
RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT: z.coerce.number().int().optional(),
// Direct DSN for applying the full @trigger.dev/database migrations to the LEGACY run-ops DB, keeping
// its schema current after the control plane moves off it. Direct, not pooled — migrations never run
// over a pooler. Optional; unset -> the entrypoint's legacy migrate step is skipped.
RUN_OPS_LEGACY_DIRECT_URL: z
.string()
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DIRECT_URL is invalid")
.optional(),
// Advisory control-plane co-residency sentinel enforcement (Track 2, T2.3). Default OFF; the advisory
// arm always emits its metric, this only turns a still-co-resident pair into a hard boot failure.
RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT: BoolEnv.default(false),
// --- Control-plane datasource repoint. Additive-only. ---
// Optional control-plane DB. Unset (self-host/single-DB) -> getClient()/getReplicaClient() fall back to
// DATABASE_URL/DATABASE_READ_REPLICA_URL, so boot is byte-identical. When set, these point at the
@@ -166,14 +211,15 @@ const EnvironmentSchema = z
// Control-plane cache relax knobs. Unset -> defaults (DEFAULT_CP_CACHE_TTL_MS / _MAX_ENTRIES).
CONTROL_PLANE_CACHE_TTL_MS: z.coerce.number().int().optional(),
CONTROL_PLANE_CACHE_MAX_ENTRIES: z.coerce.number().int().optional(),
SESSION_SECRET: z.string(),
MAGIC_LINK_SECRET: z.string(),
SESSION_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
MAGIC_LINK_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
ENCRYPTION_KEY: z
.string()
.refine(
(val) => Buffer.from(val, "utf8").length === 32,
"ENCRYPTION_KEY must be exactly 32 bytes"
),
)
.refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
WHITELISTED_EMAILS: z
.string()
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
@@ -224,9 +270,6 @@ const EnvironmentSchema = z
PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(),
PLAIN_CUSTOMER_CARDS_KEY: z.string().optional(),
PLAIN_CUSTOMER_CARDS_HEADERS: z.string().optional(),
WORKER_SCHEMA: z.string().default("graphile_worker"),
WORKER_CONCURRENCY: z.coerce.number().int().default(10),
WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
// How often each replica reloads the global flags snapshot from the DB.
// Sets kill/ramp propagation latency.
GLOBAL_FLAGS_RELOAD_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
@@ -381,6 +424,8 @@ const EnvironmentSchema = z
// Master switch for the native realtime backend; off = Electric serves everything, publishes no-op.
REALTIME_BACKEND_NATIVE_ENABLED: z.string().default("0"),
// Default backend when an org has no `realtimeBackend` override and no global flag row is set.
REALTIME_BACKEND_DEFAULT: z.enum(["electric", "native", "shadow"]).default("electric"),
// Live long-poll backstop hold (ms); matches Electric's ~20s cadence.
REALTIME_BACKEND_NATIVE_LIVE_POLL_TIMEOUT_MS: z.coerce.number().int().default(20_000),
// Jitter ratio on the live-poll hold (0.15 = ±15%) to avoid synchronized refetch herds.
@@ -526,9 +571,22 @@ const EnvironmentSchema = z
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
//v3
PROVIDER_SECRET: z.string().default("provider-secret"),
COORDINATOR_SECRET: z.string().default("coordinator-secret"),
// Per-IP rate limit for the unauthenticated OTLP ingestion endpoints
// (/otel/*). Bounds unauthenticated request rates. Opt-in
// (disabled by default): because it keys on the source IP, it is only
// safe to enable when each client presents a distinct IP through a proxy
// that appends the real client IP to X-Forwarded-For. Enabling it where
// many clients share one egress IP (e.g. behind NAT or a shared proxy)
// would collapse that traffic into a single bucket and could throttle
// legitimate telemetry. Set OTLP_RATE_LIMIT_ENABLED=1 to enable, then tune
// OTLP_RATE_LIMIT_MAX / OTLP_RATE_LIMIT_WINDOW for expected volume.
OTLP_RATE_LIMIT_ENABLED: z.string().default("0"),
OTLP_RATE_LIMIT_WINDOW: z
.string()
.regex(/^\d+ ?(?:ms|s|m|h|d)$/)
.default("1m"),
OTLP_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(3000),
DEPOT_TOKEN: z.string().optional(),
DEPOT_ORG_ID: z.string().optional(),
DEPOT_REGION: z.string().default("us-east-1"),
@@ -616,16 +674,6 @@ const EnvironmentSchema = z
// log-only mode before enforcement.
DEPRECATE_V3_CLI_DEPLOYS_ENABLED: z.string().default("0"),
// Master switch for the v3 engine (RunEngineVersion.V1) shutdown. When
// enabled it: rejects triggers that resolve to V1 (single, batch, schedule,
// replay, triggerAndWait) with a graceful error pointing at the v4 migration
// guide; closes the legacy `trigger dev` websocket used by v3 CLIs; and turns
// the V1 run-lifecycle background jobs (heartbeat timeout, TTL expiry, retry,
// resume, scheduled fires) into no-ops so abandoned V1 runs stop generating
// database load. v4 (V2) is never affected (every gate also checks the run is
// V1). Defaults to off so self-hosted instances still on V1 keep working.
DEPRECATE_V3_ENABLED: z.string().default("0"),
// Verify the deploy image exists before promoting. Disable for out-of-band/air-gapped push. ECR only.
DEPLOY_IMAGE_VERIFICATION_ENABLED: BoolEnv.default(true),
@@ -659,13 +707,19 @@ const EnvironmentSchema = z
EVENTS_MEMORY_PRESSURE_THRESHOLD: z.coerce.number().int().default(5000),
EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000),
EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"),
SHARED_QUEUE_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10),
SHARED_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(100),
SHARED_QUEUE_CONSUMER_NEXT_TICK_INTERVAL_MS: z.coerce.number().int().default(100),
SHARED_QUEUE_CONSUMER_EMIT_RESUME_DEPENDENCY_TIMEOUT_MS: z.coerce.number().int().default(1000),
SHARED_QUEUE_CONSUMER_RESOLVE_PAYLOADS_BATCH_SIZE: z.coerce.number().int().default(25),
MANAGED_WORKER_SECRET: z.string().default("managed-secret"),
MANAGED_WORKER_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
// Allow booting with a known-insecure published default secret. Temporary
// bridge for deployments that can't rotate yet; rotate as soon as possible.
ALLOW_INSECURE_DEFAULT_SECRETS: BoolEnv.default(false),
// Tenant scoping on worker actions is header-driven (folded into the engine snapshot read) and
// needs no flag. This is only the no-header fallback: when "1", a worker action on a run created
// after WORKLOAD_TOKEN_CUTOFF without a verified env header is rejected; runs on or before the
// cutoff pass (grandfathered). Default off = no run-row read, byte-for-byte today's behavior.
WORKLOAD_CREATED_AT_GATE_ENABLED: z.string().default("0"),
WORKLOAD_TOKEN_CUTOFF: z.string().datetime().optional(),
// Development OTEL environment variables
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
@@ -783,50 +837,9 @@ const EnvironmentSchema = z
LOOPS_API_KEY: z.string().optional(),
ATTIO_API_KEY: z.string().optional(),
MARQS_DISABLE_REBALANCING: BoolEnv.default(false),
MARQS_VISIBILITY_TIMEOUT_MS: z.coerce
.number()
.int()
.default(60 * 1000 * 15),
MARQS_SHARED_QUEUE_LIMIT: z.coerce.number().int().default(1000),
MARQS_MAXIMUM_QUEUE_PER_ENV_COUNT: z.coerce.number().int().default(50),
MARQS_DEV_QUEUE_LIMIT: z.coerce.number().int().default(1000),
MARQS_MAXIMUM_NACK_COUNT: z.coerce.number().int().default(64),
MARQS_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75),
MARQS_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3),
MARQS_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25),
MARQS_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0),
MARQS_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(),
MARQS_SHARED_WORKER_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(250),
MARQS_SHARED_WORKER_QUEUE_MAX_MESSAGE_COUNT: z.coerce.number().int().default(10),
MARQS_SHARED_WORKER_QUEUE_EAGER_DEQUEUE_ENABLED: z.string().default("0"),
MARQS_WORKER_ENABLED: z.string().default("0"),
MARQS_WORKER_COUNT: z.coerce.number().int().default(2),
MARQS_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
MARQS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(5),
MARQS_WORKER_POLL_INTERVAL_MS: z.coerce.number().int().default(100),
MARQS_WORKER_IMMEDIATE_POLL_INTERVAL_MS: z.coerce.number().int().default(100),
MARQS_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
MARQS_SHARED_WORKER_QUEUE_COOLOFF_COUNT_THRESHOLD: z.coerce.number().int().default(10),
MARQS_SHARED_WORKER_QUEUE_COOLOFF_PERIOD_MS: z.coerce.number().int().default(5_000),
PROD_TASK_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
VERBOSE_GRAPHILE_LOGGING: z.string().default("false"),
V2_MARQS_ENABLED: z.string().default("0"),
V2_MARQS_CONSUMER_POOL_ENABLED: z.string().default("0"),
V2_MARQS_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10),
V2_MARQS_CONSUMER_POLL_INTERVAL_MS: z.coerce.number().int().default(1000),
V2_MARQS_QUEUE_SELECTION_COUNT: z.coerce.number().int().default(36),
V2_MARQS_VISIBILITY_TIMEOUT_MS: z.coerce
.number()
.int()
.default(60 * 1000 * 15),
V2_MARQS_DEFAULT_ENV_CONCURRENCY: z.coerce.number().int().default(100),
V2_MARQS_VERBOSE: z.string().default("0"),
V3_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"),
V2_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"),
/* Usage settings */
USAGE_EVENT_URL: z.string().optional(),
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
@@ -834,7 +847,6 @@ const EnvironmentSchema = z
CENTS_PER_RUN: z.coerce.number().default(0),
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
@@ -1166,55 +1178,6 @@ const EnvironmentSchema = z
/** The CLI should connect to this for dev runs */
DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"),
LEGACY_RUN_ENGINE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(1),
LEGACY_RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
LEGACY_RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
LEGACY_RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
LEGACY_RUN_ENGINE_WORKER_LOG_LEVEL: z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
LEGACY_RUN_ENGINE_WORKER_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
LEGACY_RUN_ENGINE_WORKER_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
LEGACY_RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
LEGACY_RUN_ENGINE_WORKER_REDIS_PORT: z.coerce
.number()
.optional()
.transform(
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
),
LEGACY_RUN_ENGINE_WORKER_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
LEGACY_RUN_ENGINE_WORKER_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
LEGACY_RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
LEGACY_RUN_ENGINE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_SIZE: z.coerce.number().int().default(100),
LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_STAGGER_MS: z.coerce.number().int().default(1_000),
LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_DISABLED: z.string().default("0"),
COMMON_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
COMMON_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
COMMON_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
@@ -1399,6 +1362,9 @@ const EnvironmentSchema = z
// claim TTL), how long a waiter blocks before timing out, and the
// waiter poll interval.
TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS: z.coerce.number().int().positive().default(30),
// Pipeline floor: the claim never shrinks below this even for a short customer key TTL, so it
// can't expire mid-pipeline and let a loser re-claim (cross-DB duplicate under the split).
TRIGGER_MOLLIFIER_CLAIM_MIN_TTL_SECONDS: z.coerce.number().int().positive().default(5),
TRIGGER_MOLLIFIER_CLAIM_WAIT_MS: z.coerce.number().int().positive().default(5_000),
TRIGGER_MOLLIFIER_CLAIM_POLL_MS: z.coerce.number().int().positive().default(25),
@@ -1727,6 +1693,14 @@ const EnvironmentSchema = z
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"),
// Connection URL for the LEGACY runs-replication source (the runs-CDC slot on the legacy runs DB, plus
// the admin recovery route). Direct, not pooled: replication can't run over a pooler. Optional; unset ->
// falls back to DATABASE_URL, so nothing changes today.
RUN_REPLICATION_LEGACY_DATABASE_URL: z
.string()
.refine(isValidDatabaseUrl, "RUN_REPLICATION_LEGACY_DATABASE_URL is invalid")
.optional(),
// --- Run-ops DB split — second replication source (the NEW dedicated run-ops DB). ---
// Cloud-only; only consulted when isSplitEnabled() is true. Self-host never sets these.
// Connection URL for the run-ops DB used by the runs-replication source. Required when the split is
@@ -1753,6 +1727,10 @@ const EnvironmentSchema = z
RUN_OPS_MINT_ENABLED: BoolEnv.default(false),
RUN_OPS_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000),
// Deterministic wall-clock cutover after a runOpsMintKind flip. Must exceed the sum
// of RUN_OPS_MINT_FLAG_CACHE_TTL_MS and the control-plane cache TTL so every process
// (stale or fresh) resolves to the same kind for the whole window. See mintFlipGrace.ts.
RUN_OPS_MINT_FLIP_GRACE_MS: z.coerce.number().int().default(90_000),
// Session replication (Postgres → ClickHouse sessions_v1). Shares Redis
// with the runs replicator for leader locking but has its own slot and
@@ -1763,6 +1741,12 @@ const EnvironmentSchema = z
SESSION_REPLICATION_PUBLICATION_NAME: z
.string()
.default("sessions_to_clickhouse_v1_publication"),
// Connection URL for the sessions-replication slot. Direct, not pooled: replication can't run over a
// pooler. Optional; unset -> falls back to DATABASE_URL, so nothing changes today.
SESSION_REPLICATION_DATABASE_URL: z
.string()
.refine(isValidDatabaseUrl, "SESSION_REPLICATION_DATABASE_URL is invalid")
.optional(),
SESSION_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(1),
SESSION_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
SESSION_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
@@ -1788,6 +1772,11 @@ const EnvironmentSchema = z
// Clickhouse
CLICKHOUSE_URL: z.string(),
// Optional read replica endpoint. Read-only clients (logs, query, admin, runsList,
// engine, realtime) default to this when their own URL is unset; writes always stay on
// CLICKHOUSE_URL. Events reads opt in separately via EVENTS_READER_CLICKHOUSE_URL (no
// fallback here). Must share storage with the CLICKHOUSE_URL warehouse.
CLICKHOUSE_READER_URL: z.string().optional(),
CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1850,13 +1839,13 @@ const EnvironmentSchema = z
LOGS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
@@ -1875,12 +1864,14 @@ const EnvironmentSchema = z
ADMIN_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
// Events read replica (traces/spans/logs). No CLICKHOUSE_READER_URL fallback by design: this write-capable client opts in explicitly.
EVENTS_READER_CLICKHOUSE_URL: z.string().optional(),
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1893,7 +1884,7 @@ const EnvironmentSchema = z
RUN_ENGINE_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(5),
@@ -1905,7 +1896,7 @@ const EnvironmentSchema = z
REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce
.number()
@@ -1916,6 +1907,20 @@ const EnvironmentSchema = z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
// Dedicated ClickHouse pool for the runs list (dashboard + API). Lets us point
// the highest-traffic read path at a read replica without moving ingest/replication
// writes off CLICKHOUSE_URL. Falls back to CLICKHOUSE_URL when unset.
RUNS_LIST_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
RUNS_LIST_CLICKHOUSE_LOG_LEVEL: z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
@@ -1933,10 +1938,18 @@ const EnvironmentSchema = z
.enum(["postgres", "clickhouse", "clickhouse_v2"])
.default("postgres"),
EVENT_REPOSITORY_DEBUG_LOGS_DISABLED: BoolEnv.default(false),
EVENT_REPOSITORY_POSTGRES_WRITES_DISABLED: BoolEnv.default(false),
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
// OTLP ingest transform worker pool (opt-in). When enabled, decode/convert/enrich run in a
// worker_threads pool instead of the request event loop; the single consolidated insert path
// is unchanged.
OTEL_TRANSFORM_WORKER_POOL_ENABLED: BoolEnv.default(false),
OTEL_TRANSFORM_WORKER_POOL_SIZE: z.coerce.number().int().optional(),
OTEL_TRANSFORM_WORKER_PATH: z.string().optional(),
// Organization data stores registry
ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS: z.coerce
.number()
@@ -2072,9 +2085,22 @@ const EnvironmentSchema = z
// Force RBAC to not use the plugin
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
// Per-process pool sizes for an RBAC plugin that owns its own database
// client (the fallback queries through Prisma and ignores these). Writes
// are rare role mutations; reads run on the per-request auth hot path.
RBAC_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2),
RBAC_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5),
// Force SSO to not use the plugin (contributors without the cloud
// plugin installed can opt in to a clean OSS-only experience).
SSO_FORCE_FALLBACK: BoolEnv.default(false),
// Per-process pool sizes for an SSO plugin that owns its own database
// client (the fallback queries through Prisma and ignores these). Writes
// are rare config mutations and webhook processing; reads run on the
// login path.
SSO_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2),
SSO_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5),
// Emit a console.log when the SSO fallback is selected because no
// plugin is installed. Default off so OSS deployments stay quiet.
SSO_LOG_FALLBACK: BoolEnv.default(false),
@@ -2115,3 +2141,24 @@ const EnvironmentSchema = z
export type Environment = z.infer<typeof EnvironmentSchema>;
export const env = EnvironmentSchema.parse(process.env);
if (env.ALLOW_INSECURE_DEFAULT_SECRETS) {
const insecure = (
[
["SESSION_SECRET", env.SESSION_SECRET],
["MAGIC_LINK_SECRET", env.MAGIC_LINK_SECRET],
["ENCRYPTION_KEY", env.ENCRYPTION_KEY],
["MANAGED_WORKER_SECRET", env.MANAGED_WORKER_SECRET],
] as const
)
.filter(([, value]) => INSECURE_SECRET_VALUES.includes(value))
.map(([name]) => name);
if (insecure.length > 0) {
console.warn(
`⚠️ ALLOW_INSECURE_DEFAULT_SECRETS is enabled and these secrets still use a known-insecure published default: ${insecure.join(
", "
)}. This is insecure - rotate them as soon as you can.`
);
}
}
-1
View File
@@ -130,7 +130,6 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
id: true,
slug: true,
title: true,
v2Enabled: true,
isActivated: true,
deletedAt: true,
members: {

Some files were not shown because too many files have changed in this diff Show More