Compare commits

...

42 Commits

Author SHA1 Message Date
github-actions[bot] e36b75785e chore: release v4.4.2 (#3127)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
# trigger.dev v4.4.2

## Summary
2 new features, 2 improvements, 8 bug fixes.

## Improvements
- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))
- Add PAYLOAD_TOO_LARGE error to handle graceful recovery of sending
batch trigger items with payloads that exceed the maximum payload size
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))

## Bug fixes
- Fix slow batch queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- fix(sdk): batch triggerAndWait variants now return correct
run.taskIdentifier instead of unknown
([#3080](https://github.com/triggerdotdev/trigger.dev/pull/3080))

## Server changes

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

- Two-level tenant dispatch architecture for batch queue processing.
Replaces the
single master queue with a two-level index: a dispatch index (tenant →
shard)
and per-tenant queue indexes (tenant → queues). This enables O(1) tenant
selection and fair scheduling across tenants regardless of queue count.
Improves batch queue processing performance.
([#3133](https://github.com/triggerdotdev/trigger.dev/pull/3133))
- Add input streams with API routes for sending data to running tasks,
SSE reading, and waitpoint creation. Includes Redis cache for fast
`.send()` to `.wait()` bridging, dashboard span support for input stream
operations, and s2-lite support with configurable S2 endpoint, access
token skipping, and S2-Basin headers for self-hosted deployments. Adds
s2-lite to Docker Compose for local development.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))
- Speed up batch queue processing by disabling cooloff and increasing
the batch queue processing concurrency limits on the cloud:
  
  - Pro plan: increase to 50 from 10.
  - Hobby plan: increase to 10 from 5.
- Free plan: increase to 5 from 1.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- Move batch queue global rate limiter from FairQueue claim phase to
BatchQueue worker queue consumer for accurate per-item rate limiting.
Add worker queue depth cap to prevent unbounded growth that could cause
visibility timeouts.
([#3166](https://github.com/triggerdotdev/trigger.dev/pull/3166))
- Fix a race condition in the waitpoint system where a run could be
blocked by a completed waitpoint but never be resumed because of a
PostgreSQL MVCC issue. This was most likely to occur when creating a
waitpoint via `wait.forToken()` at the same moment as completing the
token with `wait.completeToken()`. Other types of waitpoints (timed,
child runs) were not affected.
([#3075](https://github.com/triggerdotdev/trigger.dev/pull/3075))
- Fix metrics dashboard chart series colors going out of sync and
widgets not reloading stale data when scrolled back into view
([#3126](https://github.com/triggerdotdev/trigger.dev/pull/3126))
- Gracefully handle oversized batch items instead of aborting the
stream.
  
When an NDJSON batch item exceeds the maximum size, the parser now emits
an error marker instead of throwing, allowing the batch to seal
normally. The oversized item becomes a pre-failed run with
`PAYLOAD_TOO_LARGE` error code, while other items in the batch process
successfully. This prevents `batchTriggerAndWait` from seeing connection
errors and retrying with exponential backoff.
  
Also fixes the NDJSON parser not consuming the remainder of an oversized
line split across multiple chunks, which caused "Invalid JSON" errors on
subsequent lines.
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))
- Require the user is an admin during an impersonation session.
Previously only the impersonation cookie was checked; now the real
user's admin flag is verified on every request. If admin has been
revoked, the session falls back to the real user's ID.
([#3078](https://github.com/triggerdotdev/trigger.dev/pull/3078))

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

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

### Patch Changes

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

## trigger.dev@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/build@4.4.2`
    -   `@trigger.dev/core@4.4.2`
    -   `@trigger.dev/schema-to-json@4.4.2`

## @trigger.dev/python@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.4.2`
    -   `@trigger.dev/build@4.4.2`
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/react-hooks@4.4.2

### Patch Changes

- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))

Upgrade S2 SDK from 0.17 to 0.22 with support for custom endpoints
(s2-lite) via the new `endpoints` configuration, `AppendRecord.string()`
API, and `maxInflightBytes` session option.

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

## @trigger.dev/redis-worker@4.4.2

### Patch Changes

- Fix slow batch queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/rsc@4.4.2

### Patch Changes

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

## @trigger.dev/schema-to-json@4.4.2

### Patch Changes

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

## @trigger.dev/sdk@4.4.2

### Patch Changes

- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))

Upgrade S2 SDK from 0.17 to 0.22 with support for custom endpoints
(s2-lite) via the new `endpoints` configuration, `AppendRecord.string()`
API, and `maxInflightBytes` session option.

- fix(sdk): batch triggerAndWait variants now return correct
run.taskIdentifier instead of unknown
([#3080](https://github.com/triggerdotdev/trigger.dev/pull/3080))

- Add PAYLOAD_TOO_LARGE error to handle graceful recovery of sending
batch trigger items with payloads that exceed the maximum payload size
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))

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

## @trigger.dev/core@4.4.2

</details>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 09:47:24 +00:00
Eric Allam dee6f1d09e fix(batch): move batch queue global rate limiter to worker consumer level (#3166)
The global rate limiter was being applied at the FairQueue claim phase,
consuming 1 token per queue-claim-attempt rather than per item
processed.
  With many small queues (each batch is its own queue), consumers burned
  through tokens on empty or single-item queues, causing aggressive
  throttling well below the intended items/sec limit.

  Changes:
- Move rate limiter from FairQueue claim phase to BatchQueue worker
queue
    consumer loop (before blockingPop), so each token = 1 item processed
  - Replace the FairQueue rate limiter with a worker queue depth cap to
    prevent unbounded growth that could cause visibility timeouts
- Add BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH env var (optional, disabled by
default)
2026-03-03 14:44:27 +00:00
James Ritchie b192b71b93 Feat(webapp): add create custom dashboard button to metrics page (#3095)
Adds a "Create custom dashboard" button to the top right of the metrics
dashboard

 
<img width="3546" height="1934" alt="CleanShot 2026-02-19 at 11 25
12@2x"
src="https://github.com/user-attachments/assets/0bb46ade-47c9-4396-b62a-f4801d7d90b4"
/>
2026-03-02 18:23:02 +00:00
Eric Allam a9163dfd9d chore(docker): Pin goose in Dockerfile to v3.26.0 (#3163)
The latest goose requires go version 1.25:
https://github.com/pressly/goose/releases/tag/v3.27.0
2026-03-02 17:09:46 +00:00
Eric Allam 540e1c86a4 feat: Input Streams - Bidirectional task communication (#3146)
Input streams enable sending typed data to executing tasks from external
callers — backends, frontends, or other tasks. This unlocks interactive
use cases like approval UIs, cancel buttons, chat interfaces, and
human-in-the-loop AI workflows where the task needs to receive data
while running.

Three consumption patterns inside a task:

* `.wait()` — Suspend the task until data arrives (process freed, most
efficient)
* `.once()` — Wait for the next message (process stays alive)
* `.on()` — Subscribe to a continuous stream of messages

One send pattern from outside:

* `.send(runId, data)` — Send typed data to a specific run's input
stream

## User-facing API

### Define a typed input stream

```ts
import { streams, task } from "@trigger.dev/sdk";

const approval = streams.input<{ approved: boolean; reviewer: string }>({ id: "approval" });
```

### Consume inside a task

```ts
export const myTask = task({
  id: "my-task",
  run: async () => {
    // Pattern 1: Suspend until data arrives (most efficient — frees the process)
    const result = await approval.wait({ timeout: "5m" });

    // Pattern 2: Wait for next message (process stays alive)
    const data = await approval.once().unwrap();

    // Pattern 3: Subscribe to multiple messages
    approval.on((data) => { /* handle each message */ });
  },
});
```

### Send from outside

```ts
// From a backend (using secret API key)
await approval.send(runId, { approved: true, reviewer: "alice" });

// From a frontend (using public JWT token from trigger response)
const { send } = useInputStreamSend("approval", runId, { accessToken });
send({ approved: true, reviewer: "alice" });
```

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-02 16:49:54 +00:00
Eric Allam 2af5c860de chore(repo): Dispatch new-release to www.trigger.dev repo to generate version changelog draft PR (#3162) 2026-03-02 16:47:34 +00:00
James Ritchie a09038b066 Feature(webapp): new User and Project onboarding questions (#3109)
- New User onboarding questions added and stored in a new
`onboardingData` col
- Keeps the same Org creation screen and stores the data in the same
format in same DB column
- New Org onboarding questions addded and stored in a new
`onboardingData` col


https://github.com/user-attachments/assets/244e4bae-f74d-4ed4-a545-92c9b927e98b

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-03-02 14:45:14 +00:00
Eric Allam 2135dc56d6 chore(claude): Improve claude code instructions (#3161)
Also includes a claude.md audit workflow for PRs
2026-03-02 12:42:05 +00:00
Saadi Myftija 4a0fb2fc06 ci: pin vouch action version (#3160)
Pins vouch actions to `c6d80ead49839655b61b422700b7a3bc9d0804a9`
(v1.4.2) in favor of security practices. We were previously using the
`@main` tag.

Also removes the checkout steps as they're not needed in these
workflows.
2026-03-02 10:35:42 +01:00
James Ritchie e1f8134f44 fix(webapp): Removes the collapsible option from the query inspector to avoid firefox bug (#3155)
There's a bug in react-window-splitter on Firefox. When trying to expand
the inspector panel in the query editor, it checks if the main panel has
space but gets an object instead of a number for the auto-sized
query-main panel. This causes the expand calculation to fail and it
snaps it back to collapsed.

I've removed this behavior for now as it's not an important feature.
2026-02-28 20:09:13 +00:00
Oskar Otwinowski 10d6f01843 feat(vercel): Vercel SDK fixes and correct env vars behavior for staging envs (#3149) 2026-02-28 07:41:19 +00:00
Iss 24b92d3b68 docs: added runtime error note for supabase edge function (#3140) 2026-02-27 10:59:04 -05:00
Eric Allam 8003923598 feat(server): Gracefully handle oversized batch items instead of aborting the stream (#3137)
Gracefully handle oversized batch items instead of aborting the stream.

When an NDJSON batch item exceeds the maximum size, the parser now emits
an error marker instead of throwing, allowing the batch to seal
normally. The oversized item becomes a pre-failed run with
`PAYLOAD_TOO_LARGE` error code, while other items in the batch process
successfully. This prevents `batchTriggerAndWait` from seeing connection
errors and retrying with exponential backoff.

Also fixes the NDJSON parser not consuming the remainder of an oversized
line split across multiple chunks, which caused "Invalid JSON" errors on
subsequent lines.
2026-02-27 10:11:42 +00:00
Eric Allam cff45664fc fix: legacy master queue drain should never re-add entries (#3142) 2026-02-27 10:11:31 +00:00
Iss 51b6c3a580 docs: added note about Prisma 7.x for TASK_RUN_STALLED_EXECUTING error (#3138) 2026-02-26 16:01:31 -05:00
James Ritchie d5a27f08ed Fix(webapp): change "metrics" to "dashboard" (#3136)
<img width="249" height="245" alt="CleanShot 2026-02-26 at 16 44 46"
src="https://github.com/user-attachments/assets/2e38b60c-0fe4-4b88-b9b9-71df82943ace"
/>
2026-02-26 16:56:07 +00:00
Matt Aitken 719a44da01 Better explanation of batch processing concurrency (#3135) 2026-02-26 14:27:21 +00:00
Iss 92dfeb37b3 docs: Add workaround for Homebrew Bun ENOENT error to Bun guide (#3125) 2026-02-26 08:25:17 -05:00
Eric Allam b1e78a6590 feat(batch-queue): two-level tenant dispatch for fair queue (#3133)
Replace flat master queue index with two-level tenant dispatch to fix
noisy neighbor problem. When a tenant has many queues at capacity, the
scheduler now iterates tenants (Level 1) not queues, then fetches
per-tenant queues (Level 2) only for eligible tenants.

Single-deploy migration: new enqueues write to dispatch indexes only,
consumer drains old master queue alongside new dispatch path until
empty.
2026-02-26 13:07:27 +00:00
Eric Allam 5612383684 chore(repo): Improve formatting of server entries in release notes (#3134) 2026-02-26 11:59:47 +00:00
Iss 4451fcb84c docs: Query page output dot notation and metadata availability (#3132)
Clarifies in the Query docs that run metadata is not available on the
Query page and that the output column is JSON, so dot notation (e.g.
output.externalId) should be used for selecting and filtering. Adds an
example that filters by an output field in WHERE
2026-02-25 17:22:08 -05:00
Iss 863dbe8d60 docs: document waitpoint token API endpoints (#3130)
Adds REST API documentation for the 5 waitpoint token endpoints
(`/api/v1/waitpoints/tokens`), including create, list, retrieve,
complete, and HTTP callback. Also adds the `publicAccessToken` security
scheme used by the complete endpoint.

<!-- mintlify-editor-comments:start -->
Mintlify
---
0 threads from 0 users in Mintlify

- No unresolved comments
<!-- mintlify-editor-comments:end -->

<!-- mintlify-comment-->

<a
href="https://dashboard.mintlify.com/trigger/trigger/editor/docs%2Fdocument-waitpoint-endpoints?source=pr_comment"
target="_blank" rel="noopener noreferrer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-light.svg"><img
src="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-light.svg"
alt="Open in Mintlify Editor"></picture></a>

<!-- /mintlify-comment -->
2026-02-25 12:52:14 -05:00
Eric Allam 39dd91b098 fix(engine) prevent MVCC race in blockRunWithWaitpoint pending check (#3075)
Split the CTE in blockRunWithWaitpoint so the pending waitpoint check
is a separate SQL statement. In READ COMMITTED isolation, each statement
gets its own snapshot, so a separate SELECT sees the latest committed
state from concurrent completeWaitpoint calls.

Previously, the CTE did INSERT + pending check in one statement (one
snapshot). If completeWaitpoint committed between the CTE start and
the SELECT, the SELECT would still see PENDING due to the stale
snapshot. Neither side would enqueue continueRunIfUnblocked, leaving
the run stuck forever.
2026-02-25 17:41:50 +00:00
Matt Aitken cf6b6e7063 Fix realtime connections pricing tier from 100 to 1000 (#3131)
Closes #

##  Checklist

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

---

## Testing

Verified the pricing definition displays the correct tier amount on the
plan selection page.

---

## Changelog

Fixed incorrect pricing tier for additional realtime connections from
$10/month per 100 to $10/month per 1000.

---

## Screenshots

N/A

💯

https://claude.ai/code/session_015QrZZJHPWta3QCBnhX2Pff

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-25 17:34:02 +00:00
Eric Allam bed3789c31 fix(batch-queue): speed up batch queue processing by disabling cooloff and fixing retry race (#3079)
Fix slow fair queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.

Removed cooloff entirely from the batch queue
2026-02-25 17:33:01 +00:00
Eric Allam a482153365 feat(webapp): require the user is an admin during an impersonation session (#3078) 2026-02-25 16:07:10 +00:00
Eric Allam fe193418d0 chore(repo) auto-link server change entries to their PRs via GitHub API (#3129) 2026-02-25 16:02:32 +00:00
Matt Aitken e9fb8e3b52 Query fixes: stale widget fix, multiple series colors mismatch (#3126)
- Fix for series color assignment being out of sync with the graph
(ensures added series colors match their graph representation)
- Reload widgets when returning to screen if props changed (prevents
stale widgets after filtering and scrolling)
2026-02-25 15:50:17 +00:00
Eric Allam c05b30adfe chore(repo): fix enhanced release pr description to filter out dependency only updates (#3128) 2026-02-25 15:50:06 +00:00
Eric Allam f37bdaac84 fix(sdk): batch triggerAndWait variants now return correct run.taskIdentifier instead of unknown (#3080)
Fixes #2942
2026-02-25 15:37:04 +00:00
Eric Allam 3c0644a3b8 feat: unified GitHub release, server change tracking, and enhanced release PR (#3085)
- Add .server-changes/ convention for tracking server-only changes
- Create scripts/enhance-release-pr.mjs to deduplicate and categorize
changeset PR body
- Create scripts/generate-github-release.mjs to format unified GitHub
release body
- Change release.yml to create one unified GitHub release instead of
per-package releases
- Add update-release job to patch Docker image link after images are
pushed to GHCR
- Update changesets-pr.yml to trigger on .server-changes, enhance PR
body, and clean up consumed files
- Document server changes in CLAUDE.md, CONTRIBUTING.md, CHANGESETS.md,
and RELEASE.md
2026-02-25 13:54:19 +00:00
Eric Allam 19733c8338 docs(queues): Cover new queue limits and TTL system (#3030)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3030"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-25 11:18:44 +00:00
Matt Aitken 9ba608d2cf TRQL function tests and fixes (#3076)
What changed
- Fixed some functions like dateAdd, toString, ifNotFinite
- Removed all functions that accept lambdas as they're not supported
(yet)
- Added tests for all TRQL functions that use ClickHouse
2026-02-24 19:44:07 +00:00
Iss 89c73ed8ba docs: document run result and batch API endpoints (#3121)
Adds OpenAPI specs and sidebar pages for four previously undocumented
public endpoints: retrieve
run result, per-task batch trigger, retrieve batch, and retrieve batch
results.
2026-02-24 13:30:16 -05:00
Iss 97bf89873e docs: document undocumented run API endpoints (#3120)
Adds API reference pages for three previously undocumented run
endpoints: retrieve run events, retrieve run trace, and add tags to a
run.
2026-02-24 10:49:39 -05:00
Iss b60788df82 docs: note that onCancel only runs during active execution (#3119)
Adds a warning to the onCancel docs clarifying that the hook only fires
when a run is actively executing
2026-02-23 21:24:38 +00:00
Eric Allam 6409fea6ac fix(engine): allow disabling the ttl system consumers independently from the whole system (#3115) 2026-02-23 16:39:30 +00:00
Eric Allam ae46e3f7c8 feat(server): New TTL system, enforce max queue length limits, lazy waitpoint creation (#2980)
This PR implements a new run TTL system and queue size limits to prevent
unbounded queue growth which should help prevent situations where queues
enter a "death spiral" where the queue will never be able to catch up.

The main/correct way to battle this situation is to enforce a maximum
TTL on all runs (e.g. up to 14 days) where runs that have been queued
for that maximum TTL will get auto-expired, making room for newer runs
to execute. This required creating a new TTL system that can handle
higher workloads and is now deeply integrated into the RunQueue. When
runs are enqueued with a TTL, they are added to their normal queue as
well as to the TTL queue. When runs are dequeued, they are removed from
both their normal queue and the TTL queue. If runs are dequeued by the
TTL system, they are removed from their normal queue. Both these
dequeues happen automatically so there is no race condition.

The TTL expiration system is also made reliable by expiring runs via a
Redis worker, which is enqueued to atomically inside the TTL dequeue lua
script.

### Optional associated waitpoints

Additionally, this PR implements an optimization where runs that aren't
triggered with a dependent parent run will no longer create an
associated waitpoint. Associated waitpoints are then lazily created if a
dependent run wants to wait for the child run post-facto (via debounce
or idempotency), which is a rare situation but is possible. This means
fewer waitpoint creations but also fewer waitpoint completions for runs
with no dependencies.

### Environment Queue Limits

Prevents any single queue growing too large by enforcing queue size
limits at trigger time.

- Queue size checks happen at trigger time - runs are rejected if queue
would exceed limit
- Dashboard UI shows queue limits on both the Queues page and a new
Limits page
- In-memory caching for queue size checks to reduce Redis load

### Batch trigger fixes

Currently when a batch item cannot be created for whatever reason (e.g.
queue limits) the run will never get created, which means a stalled run
if using `batchTriggerAndWait`. We've updated the system to handle this
differently: now when a batch item cannot be triggered and converted
into a run, we will eventually (after retrying 8 times up to 30s) we
will create a "pre-failed" run with the error details, correctly
resolving the batchTriggerAndWait.
2026-02-23 15:57:32 +00:00
Oskar Otwinowski 69dc7bcde8 feat(webapp): Vercel / Slack integrations improvements (#3108)
##  Checklist

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

---

## Testing

Slack + GitHub + Vercel + Builds + Deployments

---

## Changelog

Settings changes:
- Split general from integrations
- Add new Slack section to org level integrations
Vercel improvements:
- bugfix for TRIGGER_SECRET_KEY collision
- onboarding improvements for connecting to projects
- new loops event
Slack improvements:
- nicer alerts
Webhook/Email alerts:
- rich events with Github & Vercel integration data

---

## Screenshots


<img width="2550" height="652" alt="Screenshot 2026-02-20 at 21 53 34"
src="https://github.com/user-attachments/assets/8d7c9f1d-5fe9-4516-8fb3-885460b4207f"
/>
<img width="843" height="710" alt="Screenshot 2026-02-23 at 10 55 54"
src="https://github.com/user-attachments/assets/8ea72c1f-431b-493c-b9a9-8076cce12262"
/>
<img width="765" height="466" alt="Screenshot 2026-02-20 at 21 52 46"
src="https://github.com/user-attachments/assets/157fafb8-b7bf-499d-8953-c2aed5e44ce0"
/>
<img width="691" height="261" alt="Screenshot 2026-02-20 at 22 04 24"
src="https://github.com/user-attachments/assets/3aea7369-2008-4af8-a9c0-5fbfa2cc381d"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 48 49"
src="https://github.com/user-attachments/assets/dc10c14e-cd15-445a-b5be-d694d29d20e5"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 49 04"
src="https://github.com/user-attachments/assets/1ef591fd-fd00-430a-9649-8b18cff9586d"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 32 56"
src="https://github.com/user-attachments/assets/c5c8f318-d193-4dd4-86f7-1cc4bbcc4e0c"
/>
<img width="422" height="187" alt="Screenshot 2026-02-20 at 21 57 41"
src="https://github.com/user-attachments/assets/37865cb6-4c0d-40ef-9c60-7b057d546c61"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 33 06"
src="https://github.com/user-attachments/assets/e9180e8e-e611-4734-9232-80c62ff863ad"
/>

💯
2026-02-23 13:48:09 +00:00
Matt Aitken 3a7054628f Fix: show the deprecation panel if it's an old project and v3 (#3113)
Without doing an expensive query we can’t tell if it’s definitely a v3
projects – like getting run counts.
So let’s just assume if the project hasn’t been upgraded to v4 (by
running dev/deploy CLI with v4) AND the project is older than the v4
release then it’s v3.
2026-02-23 11:10:37 +00:00
Eric Allam 676525279a docs: otel metrics (#3096) 2026-02-20 16:51:48 +00:00
James Ritchie 79f8cdef72 Fix(webapp): logs button + logs table row link fix (#3107)
Small fixes and improvements to the logs page:

- Clicking the Run ID didn't open inspector
- Swapped the "open link in tab" icon with Runs icon
- Prevent tooltip hovering on Level info

<img width="350" height="206" alt="CleanShot 2026-02-20 at 10 00 37@2x"
src="https://github.com/user-attachments/assets/3e82f24a-c0a1-4c01-a8e9-9e06a8af982a"
/>
2026-02-20 16:44:55 +00:00
258 changed files with 19612 additions and 2676 deletions
+13
View File
@@ -0,0 +1,13 @@
---
paths:
- "internal-packages/database/**"
---
# Database Migration Safety
- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file).
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY.
- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file.
- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes.
- Never drop columns or tables without explicit approval.
- New code should target `RunEngineVersion.V2` only.
+14
View File
@@ -0,0 +1,14 @@
---
paths:
- "docs/**"
---
# Documentation Writing Rules
- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional).
- After creating a new page, add it to `docs.json` navigation under the correct group.
- Use Mintlify components: `<Note>`, `<Warning>`, `<Info>`, `<Tip>`, `<CodeGroup>`, `<Expandable>`, `<Steps>`/`<Step>`.
- Code examples should be complete and runnable where possible.
- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`.
- Keep paragraphs short. Use headers to break up content.
- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`).
+33
View File
@@ -0,0 +1,33 @@
---
paths:
- "apps/webapp/app/v3/**"
---
# Legacy V1 Engine Code in `app/v3/`
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.
## V1-Only Files - Never Modify
- `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)
## V1/V2 Branching Pattern
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
When editing these shared services, only modify V2 code paths.
## 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`
+12
View File
@@ -0,0 +1,12 @@
---
paths:
- "packages/**"
---
# Public Package Rules
- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add`
- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval.
- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`).
- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes.
- Test changes using `references/hello-world` reference project.
+23
View File
@@ -0,0 +1,23 @@
---
paths:
- "apps/**"
---
# Server App Changes
When modifying server apps (webapp, supervisor, coordinator, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset:
```bash
cat > .server-changes/descriptive-name.md << 'EOF'
---
area: webapp
type: fix
---
Brief description of what changed and why.
EOF
```
- **area**: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- **type**: `feature` | `fix` | `improvement` | `breaking`
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
+30 -9
View File
@@ -7,6 +7,7 @@ on:
paths:
- "packages/**"
- ".changeset/**"
- ".server-changes/**"
- "package.json"
- "pnpm-lock.yaml"
@@ -50,7 +51,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update PR title with version
- name: Update PR title and enhance body
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -61,6 +62,15 @@ jobs:
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
# Enhance the PR body with a clean, deduplicated summary
RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body')
ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION")
if [ -n "$ENHANCED_BODY" ]; then
gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \
-X PATCH \
-f body="$ENHANCED_BODY"
fi
fi
update-lockfile:
@@ -88,15 +98,26 @@ jobs:
- name: Install and update lockfile
run: pnpm install --no-frozen-lockfile
- name: Commit and push lockfile
- name: Clean up consumed .server-changes/ files
run: |
set -e
if git diff --quiet pnpm-lock.yaml; then
echo "No lockfile changes"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore: update lockfile for release"
shopt -s nullglob
files=(.server-changes/*.md)
for f in "${files[@]}"; do
if [ "$(basename "$f")" != "README.md" ]; then
git rm --ignore-unmatch "$f"
fi
done
- name: Commit and push lockfile + server-changes cleanup
run: |
set -e
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
if ! git diff --cached --quiet; then
git commit -m "chore: update lockfile and clean up .server-changes/ for release"
git push origin changeset-release/main
else
echo "No changes to commit"
fi
+69
View File
@@ -0,0 +1,69 @@
name: 📝 CLAUDE.md Audit
on:
pull_request:
types: [opened, ready_for_review, synchronize]
paths-ignore:
- "docs/**"
- ".changeset/**"
- ".server-changes/**"
- "**/*.md"
- "references/**"
concurrency:
group: claude-md-audit-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
audit:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
use_sticky_comment: true
claude_args: |
--max-turns 15
--allowedTools "Read,Glob,Grep,Bash(git diff:*)"
prompt: |
You are reviewing a PR to check whether any CLAUDE.md files or .claude/rules/ files need updating.
## Your task
1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR.
2. For each changed directory, check if there's a CLAUDE.md in that directory or a parent directory.
3. Determine if any CLAUDE.md or .claude/rules/ file should be updated based on the changes. Consider:
- New files/directories that aren't covered by existing documentation
- Changed architecture or patterns that contradict current CLAUDE.md guidance
- New dependencies, services, or infrastructure that Claude should know about
- Renamed or moved files that are referenced in CLAUDE.md
- Changes to build commands, test patterns, or development workflows
## Response format
If NO updates are needed, respond with exactly:
✅ CLAUDE.md files look current for this PR.
If updates ARE needed, respond with a short list:
📝 **CLAUDE.md updates suggested:**
- `path/to/CLAUDE.md`: [what should be added/changed]
- `.claude/rules/file.md`: [what should be added/changed]
Keep suggestions specific and brief. Only flag things that would actually mislead Claude in future sessions.
Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns).
Do NOT suggest creating new CLAUDE.md files - only updates to existing ones.
+70 -1
View File
@@ -111,7 +111,7 @@ jobs:
uses: changesets/action@v1
with:
publish: pnpm run changeset:release
createGithubReleases: true
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -122,6 +122,19 @@ jobs:
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
- name: Create unified GitHub release
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
VERSION="${{ steps.get_version.outputs.package_version }}"
node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md
gh release create "v${VERSION}" \
--title "trigger.dev v${VERSION}" \
--notes-file /tmp/release-body.md \
--target main
- name: Create and push Docker tag
if: steps.changesets.outputs.published == 'true'
run: |
@@ -140,6 +153,62 @@ jobs:
with:
image_tag: v${{ needs.release.outputs.published_package_version }}
# After Docker images are published, update the GitHub release with the exact GHCR tag URL.
# The GHCR package version ID is only known after the image is pushed, so we query for it here.
update-release:
name: 🔗 Update release Docker link
needs: [release, publish-docker]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
packages: read
steps:
- name: Update GitHub release with Docker image link
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
VERSION="${{ needs.release.outputs.published_package_version }}"
TAG="v${VERSION}"
# Query GHCR for the version ID matching this tag
VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \
/orgs/triggerdotdev/packages/container/trigger.dev/versions \
--jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \
| head -1)
if [ -z "$VERSION_ID" ]; then
echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update"
exit 0
fi
DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}"
GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev"
# Get current release body and replace the generic link with the tag-specific one.
# Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that
# already have a version ID appended (idempotent on re-runs).
gh release view "${TAG}" --json body --jq '.body' > /tmp/release-body.md
sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md
gh release edit "${TAG}" --notes-file /tmp/release-body.md
# Dispatch changelog entry creation to the marketing site repo.
# Runs after update-release so the GitHub release body already has the exact Docker image URL.
dispatch-changelog:
name: 📝 Dispatch changelog PR
needs: [release, update-release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
steps:
- uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CROSS_REPO_PAT }}
repository: triggerdotdev/trigger.dev-site-v3
event-type: new-release
client-payload: '{"version": "${{ needs.release.outputs.published_package_version }}"}'
# 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
+1 -2
View File
@@ -13,8 +13,7 @@ jobs:
check-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mitchellh/vouch/action/check-pr@main
- uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
+1 -2
View File
@@ -16,8 +16,7 @@ jobs:
contains(github.event.comment.body, 'denounce') ||
contains(github.event.comment.body, 'unvouch')
steps:
- uses: actions/checkout@v4
- uses: mitchellh/vouch/action/manage-by-issue@main
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
comment-id: ${{ github.event.comment.id }}
issue-id: ${{ github.event.issue.number }}
+2 -1
View File
@@ -67,4 +67,5 @@ apps/**/public/build
**/.claude/settings.local.json
.mcp.log
.mcp.json
.cursor/debug.log
.cursor/debug.log
ailogger-output.log
View File
+81
View File
@@ -0,0 +1,81 @@
# Server Changes
This directory tracks changes to server-only components (webapp, supervisor, coordinator, etc.) that are not captured by changesets. Changesets only track published npm packages — server changes would otherwise go undocumented.
## When to add a file
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file.
**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it.
**Package-only PRs**: Just add a changeset as usual.
## File format
Create a markdown file with a descriptive name:
```
.server-changes/fix-batch-queue-stalls.md
```
With this format:
```markdown
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
```
### Fields
- **area** (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- **type** (required): `feature` | `fix` | `improvement` | `breaking`
### Description
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
## Lifecycle
1. Engineer adds a `.server-changes/` file in their PR
2. Files accumulate on `main` as PRs merge
3. The changeset release PR includes these in its summary
4. After the release merges, CI cleans up the consumed files
## Examples
**New feature:**
```markdown
---
area: webapp
type: feature
---
TRQL query language and the Query page
```
**Bug fix:**
```markdown
---
area: webapp
type: fix
---
Fix schedule limit counting for orgs with custom limits
```
**Improvement:**
```markdown
---
area: webapp
type: improvement
---
Use the replica for API auth queries to reduce primary load
```
+34 -9
View File
@@ -1,24 +1,49 @@
# Changesets
# Changesets and Server Changes
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm.
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention.
## Adding a changeset
## Adding a changeset (package changes)
To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
## Release instructions (local only)
## Adding a server change (server-only changes)
Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md)
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset:
1. Run `pnpm run changeset:version`
2. Run `pnpm run changeset:release`
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
EOF
```
- `area`: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- `type`: `feature` | `fix` | `improvement` | `breaking`
For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed.
See `.server-changes/README.md` for full documentation.
## When to add which
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
## Release instructions (CI)
Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly:
- Anytime new changesets are added in a commit in the `main` branch, the [release.yml](./.github/workflows/release.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
- When the version PR is merged into `main`, the release.yml workflow will automatically run `pnpm run changeset:release` to build and release packages to npm.
- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries.
- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch — the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main.
- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release.
## Pre-release instructions
+61 -202
View File
@@ -1,73 +1,47 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas.
## Build and Development Commands
This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`.
### Essential Commands
```bash
# Start Docker services (PostgreSQL, Redis, Electric)
pnpm run docker
# Run database migrations
pnpm run db:migrate
# Seed the database (required for reference projects)
pnpm run db:seed
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
pnpm run db:migrate # Run database migrations
pnpm run db:seed # Seed the database (required for reference projects)
# Build packages (required before running)
pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk
# Run webapp in development mode (http://localhost:3030)
pnpm run dev --filter webapp
# Build and watch for changes (CLI and packages)
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
pnpm run dev --filter webapp # Run webapp (http://localhost:3030)
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages
```
### Testing
## Testing
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
```bash
# Run all tests for a package
pnpm run test --filter webapp
# Run a single test file (preferred - cd into directory first)
pnpm run test --filter webapp # All tests for a package
cd internal-packages/run-engine
pnpm run test ./src/engine/tests/ttl.test.ts --run
# May need to build dependencies first
pnpm run build --filter @internal/run-engine
pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file
pnpm run build --filter @internal/run-engine # May need to build deps first
```
Test files go next to source files (e.g., `MyService.ts` `MyService.test.ts`).
Test files go next to source files (e.g., `MyService.ts` -> `MyService.test.ts`).
#### Testcontainers for Redis/PostgreSQL
### Testcontainers for Redis/PostgreSQL
```typescript
import { redisTest, postgresTest, containerTest } from "@internal/testcontainers";
// Redis only
redisTest("should use redis", async ({ redisOptions }) => {
/* ... */
});
// PostgreSQL only
postgresTest("should use postgres", async ({ prisma }) => {
/* ... */
});
// Both Redis and PostgreSQL
containerTest("should use both", async ({ prisma, redisOptions }) => {
/* ... */
});
redisTest("should use redis", async ({ redisOptions }) => { /* ... */ });
postgresTest("should use postgres", async ({ prisma }) => { /* ... */ });
containerTest("should use both", async ({ prisma, redisOptions }) => { /* ... */ });
```
### Changesets
## Changesets and Server Changes
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
@@ -77,227 +51,112 @@ pnpm run changeset:add
- Default to **patch** for bug fixes and minor changes
- Confirm with maintainers before selecting **minor** (new features)
- **Never** select major (breaking changes) without explicit approval
- **Never** select major without explicit approval
When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
## Architecture Overview
### Request Flow
User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state)
### Apps
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, and Docker image. Uses Express server.
- **apps/supervisor**: Node.js app handling task execution, interfacing with Docker/Kubernetes.
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server.
- **apps/supervisor**: Manages task execution containers (Docker/Kubernetes).
### Public Packages
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK
- **packages/cli-v3** (`trigger.dev`): CLI package
- **packages/core** (`@trigger.dev/core`): Shared code between SDK and webapp. Import subpaths only (never root).
- **packages/build**: Build extensions and types
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks
- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images
- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root).
- **packages/build** (`@trigger.dev/build`): Build extensions and types
- **packages/react-hooks**: React hooks for realtime and triggering
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Custom Redis-based background job system
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Redis-based background job system
### Internal Packages
- **internal-packages/database** (`@trigger.dev/database`): Prisma 6.14.0 client and schema
- **internal-packages/clickhouse** (`@internal/clickhouse`): ClickHouse client and schema migrations
- **internal-packages/run-engine** (`@internal/run-engine`): "Run Engine 2.0" - run lifecycle management
- **internal-packages/redis** (`@internal/redis`): Redis client creation utilities
- **internal-packages/testcontainers** (`@internal/testcontainers`): Test helpers for Redis/PostgreSQL containers
- **internal-packages/zodworker** (`@internal/zodworker`): Graphile-worker wrapper (being replaced by redis-worker)
- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL)
- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries
- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management
- **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/zodworker**: Graphile-worker wrapper (DEPRECATED - use redis-worker)
### Legacy V1 Engine Code
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.
### Documentation
Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions.
### Reference Projects
The `references/` directory contains test workspaces for developing and testing new SDK and platform features. Use these projects (e.g., `references/hello-world`) to manually test changes to the CLI, SDK, core packages, and webapp before submitting PRs.
## Webapp Development
### Key Locations
- Trigger API: `apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts`
- Batch trigger: `apps/webapp/app/routes/api.v1.tasks.batch.ts`
- Prisma setup: `apps/webapp/app/db.server.ts`
- Run engine config: `apps/webapp/app/v3/runEngine.server.ts`
- Services: `apps/webapp/app/v3/services/**/*.server.ts`
- Presenters: `apps/webapp/app/v3/presenters/**/*.server.ts`
- OTEL endpoints: `apps/webapp/app/routes/otel.v1.logs.ts`, `otel.v1.traces.ts`
### Environment Variables
Access via `env` export from `apps/webapp/app/env.server.ts`, never `process.env` directly.
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead. Example pattern:
- `realtimeClient.server.ts` (testable service)
- `realtimeClientGlobal.server.ts` (configuration)
### Legacy vs Run Engine 2.0
The codebase is transitioning from the "legacy run engine" (spread across codebase) to "Run Engine 2.0" (`@internal/run-engine`). Focus on Run Engine 2.0 for new work.
The `references/` directory contains test workspaces for testing SDK and platform features. Use `references/hello-world` to manually test changes before submitting PRs.
## Docker Image Guidelines
When updating Docker image references in `docker/Dockerfile` or other container files:
When updating Docker image references:
- **Always use multiplatform/index digests**, not architecture-specific digests
- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments
- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH"
- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant
## Database Migrations (PostgreSQL)
1. Edit `internal-packages/database/prisma/schema.prisma`
2. Create migration:
```bash
cd internal-packages/database
pnpm run db:migrate:dev:create --name "add_new_column"
```
3. **Important**: Generated migration includes extraneous changes. Remove lines related to:
- `_BackgroundWorkerToBackgroundWorkerFile`
- `_BackgroundWorkerToTaskQueue`
- `_TaskRunToTaskRunTag`
- `_WaitpointRunConnections`
- `_completedWaitpoints`
- `SecretStore_key_idx`
- Various `TaskRun` indexes unless you added them
4. Apply migration:
```bash
pnpm run db:migrate:deploy && pnpm run generate
```
### Index Migration Rules
- Indexes **must use CONCURRENTLY** to avoid table locks
- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes
## ClickHouse Migrations
ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`.
1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`)
2. Use Goose markers:
```sql
-- +goose Up
ALTER TABLE trigger_dev.your_table
ADD COLUMN new_column String DEFAULT '';
-- +goose Down
ALTER TABLE trigger_dev.your_table
DROP COLUMN new_column;
```
Follow naming conventions in `internal-packages/clickhouse/README.md`:
- `raw_` prefix for input tables
- `_v1`, `_v2` suffixes for versioning
- `_mv_v1` suffix for materialized views
- Architecture-specific digests cause CI failures on different build environments
- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant
## Writing Trigger.dev Tasks
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
```typescript
import { task } from "@trigger.dev/sdk";
// Every task must be exported
export const myTask = task({
id: "my-task", // Unique ID
id: "my-task",
run: async (payload: { message: string }) => {
// Task logic - no timeouts
// Task logic
},
});
```
### SDK Documentation Rules
The `rules/` directory contains versioned documentation for writing Trigger.dev tasks, distributed to users via the SDK installer. Current version is defined in `rules/manifest.json`.
- `rules/4.3.0/` - Latest: batch trigger v2 (1,000 items, 3MB payloads), debouncing
- `rules/4.1.0/` - Realtime streams v2, updated config
- `rules/4.0.0/` - Base v4 SDK documentation
When adding new SDK features, create a new version directory with only the files that changed from the previous version. Update `manifest.json` to point unchanged files to previous versions.
### Claude Code Skill
The `.claude/skills/trigger-dev-tasks/` skill provides Claude Code with Trigger.dev task expertise. It includes:
- `SKILL.md` - Core instructions and patterns
- Reference files for basic tasks, advanced tasks, scheduled tasks, realtime, and config
Keep the skill in sync with the latest rules version when SDK features change.
The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes.
## Testing with hello-world Reference Project
First-time setup:
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
1. `pnpm run db:seed` to seed the database
2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i`
3. Authorize CLI: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
3. Authorize: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
Running:
```bash
cd references/hello-world
pnpm exec trigger dev # or with --log-level debug
```
Running: `cd references/hello-world && pnpm exec trigger dev`
## Local Task Testing Workflow
This workflow enables Claude Code to run the webapp and trigger dev simultaneously, trigger tasks, and inspect results for testing code changes.
### Step 1: Start Webapp in Background
```bash
# Run from repo root with run_in_background: true
pnpm run dev --filter webapp
```
Verify webapp is running:
```bash
curl -s http://localhost:3030/healthcheck # Should return 200
curl -s http://localhost:3030/healthcheck # Verify running
```
### Step 2: Start Trigger Dev in Background
```bash
# Run from hello-world directory with run_in_background: true
cd references/hello-world && pnpm exec trigger dev
# Wait for "Local worker ready [node]"
```
The worker will build and register tasks. Check output for "Local worker ready [node]" message.
### Step 3: Trigger and Monitor Tasks via MCP
Use the Trigger.dev MCP tools to interact with tasks:
```
# Get current worker and registered tasks
mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev")
# Trigger a task
mcp__trigger__trigger_task(
projectRef: "proj_rrkpdguyagvsoktglnod",
environment: "dev",
taskId: "hello-world",
payload: {"message": "Hello from Claude"}
)
# List runs to see status
mcp__trigger__list_runs(
projectRef: "proj_rrkpdguyagvsoktglnod",
environment: "dev",
taskIdentifier: "hello-world",
limit: 5
)
mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"})
mcp__trigger__list_runs(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskIdentifier: "hello-world", limit: 5)
```
### Step 4: Monitor Execution
- Check trigger dev output file for real-time execution logs
- Successful runs show: `Task | Run ID | Success (Xms)`
- Dashboard available at: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
### Key Project Refs
- hello-world: `proj_rrkpdguyagvsoktglnod`
Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
+33
View File
@@ -267,6 +267,39 @@ You will be prompted to select which packages to include in the changeset. Only
Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review.
## Adding server changes
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes.
Create a markdown file with a descriptive name:
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
EOF
```
**Fields:**
- `area` (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- `type` (required): `feature` | `fix` | `improvement` | `breaking`
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
**When to add which:**
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
See `.server-changes/README.md` for more details.
## Troubleshooting
### EADDRINUSE: address already in use :::3030
+23
View File
@@ -1,5 +1,28 @@
## Guide on releasing a new version
### Automated release (v4+)
Releases are fully automated via CI:
1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes).
2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files — they're deleted on the branch, so merging the PR cleans them up).
3. When ready to release, merge the changeset release PR into `main`.
4. The [release.yml](./.github/workflows/release.yml) workflow automatically:
- Publishes all packages to npm
- Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4")
- Tags and triggers Docker image builds
- After Docker images are pushed, updates the GitHub release with the exact GHCR tag link
### What engineers need to do
- **Package changes**: Add a changeset with `pnpm run changeset:add`
- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`)
- **Mixed PRs**: Just the changeset is enough
See `CHANGESETS.md` for full details on changesets and server changes.
### Legacy release (v3)
1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge.
2. Pull the changes locally into main
3. Run `pnpm i` which will update the pnpm lock file with the new versions
View File
+20
View File
@@ -0,0 +1,20 @@
# Supervisor
Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results.
## Key Directories
- `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/env.ts` - Environment configuration
## Architecture
- **WorkloadManager**: Abstracts Docker vs Kubernetes execution
- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling
- **ResourceMonitor**: Tracks CPU/memory during execution
- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup
Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp.
+58
View File
@@ -0,0 +1,58 @@
# Webapp
Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
## Key File Locations
- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts`
- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts`
- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts`
- **Prisma setup**: `app/db.server.ts`
- **Run engine config**: `app/v3/runEngine.server.ts`
- **Services**: `app/v3/services/**/*.server.ts`
- **Presenters**: `app/v3/presenters/**/*.server.ts`
## Route Convention
Routes use Remix flat-file convention with dot-separated segments:
`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger`
## Environment Variables
Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.**
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead:
- `realtimeClient.server.ts` (testable service, takes config as constructor arg)
- `realtimeClientGlobal.server.ts` (creates singleton with env config)
## Run Engine 2.0
The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.).
The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2.
## Background Workers
Background job workers use `@trigger.dev/redis-worker`:
- `app/v3/commonWorker.server.ts`
- `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
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.
@@ -889,13 +889,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
const cfg: ChartConfig = {};
sortedSeries.forEach((s, i) => {
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
const originalIndex = config.yAxisColumns.indexOf(s);
const colorIndex = originalIndex >= 0 ? originalIndex : i;
cfg[s] = {
label: s,
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
};
});
return cfg;
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
@@ -133,6 +133,7 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
});
} catch {
return String(value);
@@ -667,7 +668,7 @@ function CellValue({
if (isDateTimeType(type)) {
if (typeof value === "string") {
return <DateTimeAccurate date={value} showTooltip={hovered} />;
return <DateTimeAccurate date={value} showTooltip={hovered} timeZone="UTC" />;
}
return <span>{String(value)}</span>;
}
@@ -34,31 +34,31 @@ export function BuildSettingsFields({
<>
{/* Pull env vars before build */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Pull env vars before build</Label>
<Hint>
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
}}
/>
)}
</div>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
}}
/>
)}
<Hint className="pr-6">
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
@@ -90,34 +90,34 @@ export function BuildSettingsFields({
{/* Discover new env vars */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Discover new env vars</Label>
<Hint>
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every(
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
) &&
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
}
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
: []
);
}}
/>
)}
</div>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every(
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
) &&
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
}
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
: []
);
}}
/>
)}
<Hint className="pr-6">
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
@@ -155,13 +155,7 @@ export function BuildSettingsFields({
{/* Atomic deployments */}
<div>
<div className="flex items-center justify-between">
<div>
<Label>Atomic deployments</Label>
<Hint>
When enabled, production deployments wait for Vercel deployment to complete before
promoting the Trigger.dev deployment.
</Hint>
</div>
<Label>Atomic deployments</Label>
<Switch
variant="small"
checked={atomicBuilds.includes("prod")}
@@ -170,6 +164,16 @@ export function BuildSettingsFields({
}}
/>
</div>
<Hint className="pr-6">
When enabled, production deployments wait for Vercel deployment to complete before
promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
Production Domains" option in your Vercel project settings to perform staged
deployments.{" "}
<TextLink href="https://trigger.dev/docs/vercel-integration#atomic-deployments" target="_blank">
Learn more
</TextLink>
.
</Hint>
</div>
</>
);
@@ -0,0 +1,22 @@
import { VercelLogo } from "./VercelLogo";
import { LinkButton } from "~/components/primitives/Buttons";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
return (
<SimpleTooltip
button={
<LinkButton
variant="minimal/small"
LeadingIcon={<VercelLogo className="size-3.5" />}
iconSpacing="gap-x-1"
to={vercelDeploymentUrl}
className="pl-1"
>
Vercel
</LinkButton>
}
content="View on Vercel"
/>
);
}
@@ -44,7 +44,7 @@ import {
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { useEffect, useState, useCallback, useRef } from "react";
import { usePostHogTracking } from "~/hooks/usePostHog";
@@ -102,6 +102,7 @@ export function VercelOnboardingModal({
hasOrgIntegration,
nextUrl,
onDataReload,
vercelManageAccessUrl,
}: {
isOpen: boolean;
onClose: () => void;
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
hasOrgIntegration: boolean;
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
vercelManageAccessUrl?: string;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
@@ -122,7 +124,8 @@ export function VercelOnboardingModal({
const completeOnboardingFetcher = useFetcher();
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
const [searchParams] = useSearchParams();
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
const origin = searchParams.get("origin");
const fromMarketplaceContext = origin === "marketplace";
const availableProjects = onboardingData?.availableProjects || [];
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
@@ -543,8 +546,15 @@ export function VercelOnboardingModal({
if (!isGitHubConnectedForOnboarding) {
setState("github-connection");
capture("vercel onboarding github step viewed", {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: "github-connection",
organization_slug: organizationSlug,
project_slug: projectSlug,
github_app_installed: gitHubAppInstallations.length > 0,
});
}
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]);
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -639,7 +649,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
@@ -669,7 +679,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({
<FormButtons
confirmButton={
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
<div className="flex items-center gap-2">
{vercelManageAccessUrl && !origin && (
<LinkButton
to={vercelManageAccessUrl}
variant="tertiary/medium"
target="_self"
>
Manage access
</LinkButton>
)}
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
</div>
}
cancelButton={
<Button
@@ -779,6 +800,20 @@ export function VercelOnboardingModal({
))}
</Select>
<Callout variant="info">
<p className="text-xs">
If you skip this step, the{" "}
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code>{" "}
will not be installed for the staging environment in Vercel. You can configure this later in
project settings.
</p>
</Callout>
<Paragraph className="text-xs text-text-dimmed">
Make sure the staging branch in your Vercel project's Git settings matches the staging branch
configured in your GitHub integration.
</Paragraph>
<div className="flex items-center justify-between gap-2">
<Button
variant="tertiary/medium"
@@ -813,6 +848,7 @@ export function VercelOnboardingModal({
<Header3>Pull Environment Variables</Header3>
<Paragraph className="text-sm">
Select which environment variables to pull from Vercel now. This is a one-time pull.
Later on environment variables can be pulled before each build.
</Paragraph>
<div className="flex gap-4 text-sm">
@@ -1057,7 +1093,7 @@ export function VercelOnboardingModal({
</Callout>
{(() => {
const baseSettingsPath = v3ProjectSettingsPath(
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
@@ -1081,6 +1117,7 @@ export function VercelOnboardingModal({
)}
variant="secondary/medium"
LeadingIcon={OctoKitty}
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
>
Install GitHub app
</LinkButton>
@@ -1110,6 +1147,7 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github completed");
setState("completed");
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
@@ -1123,6 +1161,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
if (fromMarketplaceContext && nextUrl) {
const validUrl = safeRedirectUrl(nextUrl);
@@ -1141,6 +1180,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
}}
>
@@ -186,7 +186,7 @@ function DetailsTab({
<CopyableText value={log.runId} copyValue={log.runId} asChild />
<LinkButton
to={runPath}
variant="tertiary/small"
variant="secondary/small"
shortcut={{ key: "v" }}
className="mt-2"
>
@@ -26,6 +26,7 @@ import {
TableRow,
type TableVariant,
} from "../primitives/Table";
import { RunsIcon } from "~/assets/icons/RunsIcon";
type LogsTableProps = {
logs: LogEntry[];
@@ -124,6 +125,7 @@ export function LogsTable({
<TableHeaderCell
className="min-w-24 whitespace-nowrap"
tooltip={<LogLevelTooltipInfo />}
disableTooltipHoverableContent
>
Level
</TableHeaderCell>
@@ -165,7 +167,7 @@ export function LogsTable({
>
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
</TableCell>
<TableCell className="min-w-24">
<TableCell className="min-w-24" onClick={handleRowClick} hasAction>
<TruncatedCopyableValue value={log.runId} />
</TableCell>
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
@@ -185,9 +187,11 @@ export function LogsTable({
<LinkButton
to={runPath}
variant="minimal/small"
TrailingIcon={ArrowTopRightOnSquareIcon}
TrailingIcon={RunsIcon}
trailingIconClassName="text-text-bright"
className="h-[1.375rem] pl-1.5 pr-2"
>
View run
<span className="text-[0.6875rem] text-text-bright">View run</span>
</LinkButton>
}
/>
@@ -26,6 +26,46 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../pri
import { v3BillingPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
function useCreateDashboard({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const isFreePlan = plan?.v3Subscription?.isPaying === false;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
return {
isOpen,
setIsOpen,
isAtLimit,
canUpgrade: !!canUpgrade,
isFreePlan,
formAction,
limits,
organization,
};
}
export function CreateDashboardButton({
organization,
project,
@@ -37,29 +77,12 @@ export function CreateDashboardButton({
environment: SideMenuEnvironment;
isCollapsed: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
// Close dialog when form submission starts (redirect is happening)
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
const dashboard = useCreateDashboard({ organization, project, environment });
if (isCollapsed) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
@@ -77,15 +100,47 @@ export function CreateDashboardButton({
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAtLimit ? (
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={limits}
canUpgrade={!!canUpgrade}
isFreePlan={plan?.v3Subscription?.isPaying === false}
organization={organization}
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={formAction} limits={limits} />
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
}
export function CreateDashboardPageButton({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Create custom dashboard
</Button>
</DialogTrigger>
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
@@ -105,7 +160,7 @@ function CreateDashboardUpgradeDialog({
limits: { used: number; limit: number };
canUpgrade: boolean;
isFreePlan: boolean;
organization: MatchedOrganization;
organization: { slug: string };
}) {
if (isFreePlan) {
@@ -3,15 +3,17 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
PuzzlePieceIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { SlackIcon } from "@trigger.dev/companyicons";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Integrations" />
</div>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-blue-500"
name="Vercel"
icon={VercelLogo}
activeIconColor="text-white"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
<SideMenuItem
name="Slack"
icon={SlackIcon}
activeIconColor="text-white"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
@@ -11,6 +11,7 @@ import {
Cog8ToothIcon,
CogIcon,
ExclamationTriangleIcon,
PuzzlePieceIcon,
FolderIcon,
FolderOpenIcon,
GlobeAmericasIcon,
@@ -74,7 +75,8 @@ import {
v3LogsPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
v3ProjectSettingsGeneralPath,
v3ProjectSettingsIntegrationsPath,
v3QueuesPath,
v3RunsPath,
v3SchedulesPath,
@@ -127,7 +129,7 @@ type SideMenuUser = Pick<
};
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments" | "engine"
"id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt"
>;
export type SideMenuEnvironment = MatchedEnvironment;
@@ -451,7 +453,7 @@ export function SideMenu({
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuSection
title="Insights"
title="Observability"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
@@ -482,7 +484,7 @@ export function SideMenu({
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Metrics"
name="Dashboards"
icon={ChartBarIcon}
activeIconColor="text-metrics"
inactiveIconColor="text-metrics"
@@ -589,13 +591,34 @@ export function SideMenu({
data-action="limits"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
<SideMenuSection
title="Project settings"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"project-settings"
)}
onCollapseToggle={handleSectionToggle("project-settings")}
>
<SideMenuItem
name="Project settings"
name="General"
icon={Cog8ToothIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
data-action="project-settings-general"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
data-action="project-settings-integrations"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
@@ -611,6 +634,7 @@ export function SideMenu({
<V3DeprecationPanel
isCollapsed={isCollapsed}
isV3={isV3Project}
projectCreatedAt={project.createdAt}
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
@@ -641,15 +665,21 @@ export function SideMenu({
function V3DeprecationPanel({
isCollapsed,
isV3,
projectCreatedAt,
hasIncident,
isManagedCloud,
}: {
isCollapsed: boolean;
isV3: boolean;
projectCreatedAt: Date;
hasIncident: boolean;
isManagedCloud: boolean;
}) {
if (!isManagedCloud || !isV3 || hasIncident) {
// Only show for projects created before v4 was released
const V4_RELEASE_DATE = new Date("2025-09-01");
const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE;
if (!isManagedCloud || !isLikelyV3 || hasIncident) {
return null;
}
@@ -1,7 +1,7 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
@@ -0,0 +1,375 @@
import * as Ariakit from "@ariakit/react";
import {
XMarkIcon,
PlusIcon,
CubeIcon,
MagnifyingGlassIcon,
ChevronDownIcon,
} from "@heroicons/react/20/solid";
import { useCallback, useMemo, useRef, useState } from "react";
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
import { cn } from "~/utils/cn";
import { matchSorter } from "match-sorter";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
const pillColors = [
"bg-green-800/40 border-green-600/50",
"bg-teal-800/40 border-teal-600/50",
"bg-blue-800/40 border-blue-600/50",
"bg-indigo-800/40 border-indigo-600/50",
"bg-violet-800/40 border-violet-600/50",
"bg-purple-800/40 border-purple-600/50",
"bg-fuchsia-800/40 border-fuchsia-600/50",
"bg-pink-800/40 border-pink-600/50",
"bg-rose-800/40 border-rose-600/50",
"bg-orange-800/40 border-orange-600/50",
"bg-amber-800/40 border-amber-600/50",
"bg-yellow-800/40 border-yellow-600/50",
"bg-lime-800/40 border-lime-600/50",
"bg-emerald-800/40 border-emerald-600/50",
"bg-cyan-800/40 border-cyan-600/50",
"bg-sky-800/40 border-sky-600/50",
];
function getPillColor(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return pillColors[Math.abs(hash) % pillColors.length];
}
export const TECHNOLOGY_OPTIONS = [
"Airflow",
"Angular",
"Anthropic",
"Astro",
"Auth0",
"AWS",
"AWS SQS",
"Azure",
"BigQuery",
"BullMQ",
"Bun",
"Cassandra",
"Celery",
"ClickHouse",
"Clerk",
"Cloudflare",
"CockroachDB",
"Cohere",
"Convex",
"Databricks",
"Datadog",
"DeepSeek",
"Deno",
"DigitalOcean",
"Django",
"Docker",
"Drizzle",
"DynamoDB",
"Elasticsearch",
"Electron",
"Elevenlabs",
"Expo",
"Express",
"FastAPI",
"Fastify",
"Firebase",
"Flask",
"Fly.io",
"Gatsby",
"GCP",
"Go",
"Google Cloud Tasks",
"Google Gemini",
"GraphQL",
"Groq",
"Heroku",
"Hono",
"htmx",
"Hugging Face",
"Inngest",
"Kafka",
"Kubernetes",
"LangChain",
"Laravel",
"LlamaIndex",
"MariaDB",
"Midjourney",
"Mistral",
"MongoDB",
"Mongoose",
"MySQL",
"Neo4j",
"Neon",
"Nest.js",
"Netlify",
"Next.js",
"Node.js",
"Nuxt",
"Ollama",
"OpenAI",
"Perplexity",
"PHP",
"Pinecone",
"PlanetScale",
"Python",
"PostHog",
"PostgreSQL",
"Prisma",
"Pulumi",
"RabbitMQ",
"Railway",
"React",
"React Native",
"Redis",
"Redshift",
"Remix",
"Render",
"Replicate",
"Resend",
"Ruby on Rails",
"Rust",
"SendGrid",
"Sentry",
"Sidekiq",
"Snowflake",
"Solid.js",
"Spring Boot",
"SQLite",
"Stability AI",
"Stripe",
"Supabase",
"Svelte",
"SvelteKit",
"Tailwind CSS",
"Temporal",
"Terraform",
"Together AI",
"tRPC",
"Turso",
"Twilio",
"TypeORM",
"Upstash",
"Vercel",
"Vercel AI SDK",
"Vite",
"Vue",
"Weaviate",
] as const;
type TechnologyPickerProps = {
value: string[];
onChange: (value: string[]) => void;
customValues: string[];
onCustomValuesChange: (values: string[]) => void;
};
export function TechnologyPicker({
value,
onChange,
customValues,
onCustomValuesChange,
}: TechnologyPickerProps) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [otherInputValue, setOtherInputValue] = useState("");
const [showOtherInput, setShowOtherInput] = useState(false);
const otherInputRef = useRef<HTMLInputElement>(null);
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
const filteredOptions = useMemo(() => {
if (!searchValue) return TECHNOLOGY_OPTIONS;
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
}, [searchValue]);
const toggleOption = useCallback(
(option: string) => {
if (value.includes(option)) {
onChange(value.filter((v) => v !== option));
} else {
onChange([...value, option]);
}
},
[value, onChange]
);
const removeItem = useCallback(
(item: string) => {
if (value.includes(item)) {
onChange(value.filter((v) => v !== item));
} else {
onCustomValuesChange(customValues.filter((v) => v !== item));
}
},
[value, onChange, customValues, onCustomValuesChange]
);
const addCustomValue = useCallback(() => {
const trimmed = otherInputValue.trim();
if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) {
onCustomValuesChange([...customValues, trimmed]);
setOtherInputValue("");
}
}, [otherInputValue, customValues, onCustomValuesChange, value]);
const handleOtherKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addCustomValue();
}
},
[addCustomValue]
);
return (
<div className="flex flex-col gap-2">
{allSelected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allSelected.map((item) => (
<span
key={item}
className={cn(
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
getPillColor(item)
)}
>
{item}
<button
type="button"
onClick={() => removeItem(item)}
aria-label={`Remove ${item}`}
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
>
<XMarkIcon className="size-3.5" />
</button>
</span>
))}
</div>
)}
<Ariakit.ComboboxProvider
resetValueOnHide
setValue={(val) => {
setSearchValue(val);
}}
>
<Ariakit.SelectProvider
open={open}
setOpen={setOpen}
value={value}
setValue={(v) => {
if (Array.isArray(v)) {
onChange(v);
}
}}
virtualFocus
>
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-charcoal-750 pl-2 pr-2.5 text-sm text-text-dimmed ring-charcoal-600 transition focus-custom hover:bg-charcoal-650 hover:ring-1">
<div className="flex grow items-center">
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
<span>Select your technologies</span>
</div>
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
</Ariakit.Select>
<Ariakit.SelectPopover
gutter={5}
unmountOnHide
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,var(--popover-anchor-width))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(400px,var(--popover-available-height))]"
)}
>
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
<Ariakit.Combobox
autoSelect
placeholder="Search technologies…"
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
/>
</div>
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
{filteredOptions.map((option) => (
<Ariakit.ComboboxItem
key={option}
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
onClick={(e) => {
e.preventDefault();
toggleOption(option);
}}
>
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
<CheckboxIndicator checked={value.includes(option)} />
<span className="grow truncate text-text-bright">{option}</span>
</div>
</Ariakit.ComboboxItem>
))}
{filteredOptions.length === 0 && searchValue && (
<div className="px-3 py-2 text-xs text-text-dimmed">
No matches for &ldquo;{searchValue}&rdquo;
</div>
)}
</Ariakit.ComboboxList>
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright px-1 py-1">
{showOtherInput ? (
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-charcoal-650">
<input
ref={otherInputRef}
type="text"
value={otherInputValue}
onChange={(e) => setOtherInputValue(e.target.value)}
onKeyDown={handleOtherKeyDown}
placeholder="Type and press Enter to add"
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0"
autoFocus
/>
<ShortcutKey
shortcut={{ key: "Enter" }}
variant="small"
className={cn(
"mr-1.5 transition-opacity duration-150",
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
)}
/>
<button
type="button"
onClick={() => {
setOtherInputValue("");
setShowOtherInput(false);
}}
className="flex items-center text-text-dimmed hover:text-text-bright"
>
<XMarkIcon className="size-4" />
</button>
</div>
) : (
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
onClick={() => {
setShowOtherInput(true);
setTimeout(() => otherInputRef.current?.focus(), 0);
}}
>
<PlusIcon className="size-4 flex-none" />
<span>Other (not listed)</span>
</button>
)}
</div>
</Ariakit.SelectPopover>
</Ariakit.SelectProvider>
</Ariakit.ComboboxProvider>
</div>
);
}
@@ -1,8 +1,10 @@
import {
BoltIcon,
BuildingOffice2Icon,
CodeBracketSquareIcon,
FaceSmileIcon,
FireIcon,
GlobeAltIcon,
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
@@ -25,7 +27,8 @@ export const AvatarData = z.discriminatedUnion("type", [
}),
z.object({
type: z.literal(AvatarType.enum.image),
url: z.string().url(),
url: z.string(),
lastIconHex: z.string().optional(),
}),
]);
@@ -85,6 +88,7 @@ export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGS
"hero:fire": FireIcon,
"hero:star": StarIcon,
"hero:face-smile": FaceSmileIcon,
"hero:bolt": BoltIcon,
};
export const defaultAvatarColors = [
@@ -179,9 +183,21 @@ function AvatarIcon({
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
if (!avatar.url) {
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
</span>
);
}
return (
<span className="grid place-items-center" style={styleFromSize(size)}>
<img src={avatar.url} alt="Organization avatar" className="size-6" />
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<img
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
/>
</span>
);
}
@@ -0,0 +1,24 @@
import { cn } from "~/utils/cn";
export function CheckboxIndicator({ checked }: { checked: boolean }) {
return (
<div
className={cn(
"flex size-4 flex-none items-center justify-center rounded border",
checked ? "border-indigo-500 bg-indigo-600" : "border-charcoal-600 bg-charcoal-700"
)}
>
{checked && (
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 6L5 8.5L9.5 3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
}
@@ -338,9 +338,9 @@ export function SelectTrigger({
/>
}
>
<div className="flex grow items-center gap-0.5">
{icon && <div className="-ml-1 flex-none">{icon}</div>}
<div className="truncate">{content}</div>
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
{icon && <div className="flex-none">{icon}</div>}
<div className="min-w-0 truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
@@ -443,21 +443,33 @@ export function SelectList(props: SelectListProps) {
export interface SelectItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
checkPosition?: "left" | "right";
shortcut?: ShortcutDefinition;
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
import { CheckboxIndicator } from "./CheckboxIndicator";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
checkPosition = "right",
shortcut,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
const ref = React.useRef<HTMLDivElement>(null);
const select = Ariakit.useSelectContext();
const selectValue = select?.useState("value");
const isChecked = React.useMemo(() => {
if (!props.value || selectValue == null) return false;
if (Array.isArray(selectValue)) return selectValue.includes(props.value);
return selectValue === props.value;
}, [selectValue, props.value]);
useShortcutKeys({
shortcut: shortcut,
@@ -484,10 +496,16 @@ export function SelectItem({
)}
ref={ref}
>
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
<div
className={cn(
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
checkPosition === "left" ? "gap-2" : "gap-1"
)}
>
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
{icon}
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
{checkPosition === "right" && checkIcon}
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
@@ -176,10 +176,22 @@ type TableCellBasicProps = {
type TableHeaderCellProps = TableCellBasicProps & {
hiddenLabel?: boolean;
tooltip?: ReactNode;
disableTooltipHoverableContent?: boolean;
};
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
(
{
className,
alignment = "left",
children,
colSpan,
hiddenLabel = false,
tooltip,
disableTooltipHoverableContent = false,
},
ref
) => {
const { variant } = useContext(TableContext);
let alignmentClassName = "text-left";
switch (alignment) {
@@ -222,6 +234,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
disableHoverableContent={disableTooltipHoverableContent}
/>
</div>
) : (
@@ -69,21 +69,26 @@ export function ToastUI({
width: toastWidth,
}}
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
<div
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
>
{variant === "success" ? (
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
) : (
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
)}
<div className="flex flex-col">
{title && <Header2 className="pt-0">{title}</Header2>}
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
<Paragraph
variant={title ? "small/dimmed" : "small/bright"}
className={title ? "pb-1 pt-0.5" : ""}
>
{message}
</Paragraph>
<Action action={action} toastId={t} className="my-2" />
</div>
<button
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
className={cn("-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright", title && "-mt-1")}
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="size-4" />
@@ -77,12 +77,12 @@ export function ChartLegendCompound({
const currentTotal = useMemo((): number | null => {
if (!activePayload?.length) return grandTotal;
// Collect all series values from the hovered data point, preserving nulls
const rawValues = activePayload
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
.map((item) => item.value);
// Use the full data row so the total covers ALL dataKeys, not just visibleSeries
const dataRow = activePayload[0]?.payload;
if (!dataRow) return grandTotal;
const rawValues = dataKeys.map((key) => dataRow[key]);
// Filter to non-null values only
const values = rawValues
.filter((v): v is number => v != null)
.map((v) => Number(v) || 0);
@@ -91,7 +91,6 @@ export function ChartLegendCompound({
if (values.length === 0) return null;
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
@@ -116,24 +115,24 @@ export function ChartLegendCompound({
const currentData = useMemo((): Record<string, number | null> => {
if (!activePayload?.length) return totals;
// If we have activePayload data from hovering over a bar/line
const hoverData = activePayload.reduce(
(acc, item) => {
if (item.dataKey && item.value !== undefined) {
// Preserve null for gap-filled points instead of coercing to 0
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
}
return acc;
},
{} as Record<string, number | null>
);
// Use the full data row so ALL dataKeys are resolved from the hovered point,
// not just the visibleSeries present in activePayload.
const dataRow = activePayload[0]?.payload;
if (!dataRow) return totals;
const hoverData: Record<string, number | null> = {};
for (const key of dataKeys) {
const value = dataRow[key];
if (value !== undefined) {
hoverData[key] = value != null ? Number(value) || 0 : null;
}
}
// Return a merged object - totals for keys not in the hover data
return {
...totals,
...hoverData,
};
}, [activePayload, totals]);
}, [activePayload, totals, dataKeys]);
// Prepare legend items with capped display
const legendItems = useMemo(() => {
@@ -945,11 +945,9 @@ export function QueryEditor({
<ResizableHandle id="query-handle" />
<ResizablePanel
id="query-help"
min="200px"
collapsible
collapsedSize="20px"
min="380px"
default="400px"
max="500px"
max="800px"
className="w-full"
>
<QueryHelpSidebar
+24 -1
View File
@@ -39,6 +39,7 @@ const S2EnvSchema = z.preprocess(
S2_ENABLED: z.literal("1"),
S2_ACCESS_TOKEN: z.string(),
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
S2_DEPLOYMENT_STREAMS_LOCAL: z.string().default("0"),
}),
z.object({
S2_ENABLED: z.literal("0"),
@@ -547,6 +548,9 @@ const EnvironmentSchema = z
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
QUEUE_SIZE_CACHE_TTL_MS: z.coerce.number().int().optional().default(1_000), // 1 second
QUEUE_SIZE_CACHE_MAX_SIZE: z.coerce.number().int().optional().default(5_000),
QUEUE_SIZE_CACHE_ENABLED: z.coerce.number().int().optional().default(1),
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
@@ -556,7 +560,7 @@ const EnvironmentSchema = z
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(5),
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
@@ -603,6 +607,20 @@ const EnvironmentSchema = z
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(),
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(),
// TTL System settings for automatic run expiration
RUN_ENGINE_TTL_SYSTEM_DISABLED: BoolEnv.default(false),
RUN_ENGINE_TTL_SYSTEM_SHARD_COUNT: z.coerce.number().int().optional(),
RUN_ENGINE_TTL_SYSTEM_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
RUN_ENGINE_TTL_SYSTEM_BATCH_SIZE: z.coerce.number().int().default(100),
RUN_ENGINE_TTL_WORKER_CONCURRENCY: z.coerce.number().int().default(1),
RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE: z.coerce.number().int().default(50),
RUN_ENGINE_TTL_CONSUMERS_DISABLED: BoolEnv.default(false),
RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS: z.coerce.number().int().default(5_000),
/** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL
* will use this as their TTL, and runs with a TTL larger than this will be clamped. */
RUN_ENGINE_DEFAULT_MAX_TTL: z.string().optional(),
RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000),
RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000),
RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10),
@@ -975,6 +993,9 @@ const EnvironmentSchema = z
// Global rate limit: max items processed per second across all consumers
// If not set, no global rate limiting is applied
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
// Max items in the worker queue before claiming pauses (protects visibility timeouts)
// If not set, no depth limit is applied
BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH: z.coerce.number().int().positive().optional(),
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
@@ -1327,6 +1348,8 @@ const EnvironmentSchema = z
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
.number()
.int()
+24
View File
@@ -0,0 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { extractDomain, faviconUrl } from "~/utils/favicon";
function resolve(input: string, size: number): string | null {
const domain = extractDomain(input);
return domain && domain.includes(".") ? faviconUrl(domain, size) : null;
}
export function useFaviconUrl(urlInput: string, size: number = 64) {
const [url, setUrl] = useState<string | null>(() => resolve(urlInput, size));
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setUrl(resolve(urlInput, size));
}, 400);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [urlInput, size]);
return url;
}
@@ -1,6 +1,7 @@
import type {
Organization,
OrgMember,
Prisma,
Project,
RuntimeEnvironment,
User,
@@ -22,8 +23,12 @@ export async function createOrganization(
title,
userId,
companySize,
onboardingData,
avatar,
}: Pick<Organization, "title" | "companySize"> & {
userId: User["id"];
onboardingData?: Prisma.InputJsonValue;
avatar?: Prisma.InputJsonValue;
},
attemptCount = 0
): Promise<Organization> {
@@ -47,6 +52,8 @@ export async function createOrganization(
title,
userId,
companySize,
onboardingData,
avatar,
},
attemptCount + 1
);
@@ -59,6 +66,8 @@ export async function createOrganization(
title,
slug: uniqueOrgSlug,
companySize,
onboardingData: onboardingData ?? undefined,
avatar: avatar ?? undefined,
maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
members: {
create: {
+6 -3
View File
@@ -1,8 +1,8 @@
import { nanoid, customAlphabet } from "nanoid";
import slug from "slug";
import { $replica, prisma } from "~/db.server";
import type { Project } from "@trigger.dev/database";
import { Organization, createEnvironment } from "./organization.server";
import type { Prisma, Project } from "@trigger.dev/database";
import { type Organization, createEnvironment } from "./organization.server";
import { env } from "~/env.server";
import { projectCreated } from "~/services/platform.v3.server";
export type { Project } from "@trigger.dev/database";
@@ -14,6 +14,7 @@ type Options = {
name: string;
userId: string;
version: "v2" | "v3";
onboardingData?: Prisma.InputJsonValue;
};
export class ExceededProjectLimitError extends Error {
@@ -24,7 +25,7 @@ export class ExceededProjectLimitError extends Error {
}
export async function createProject(
{ organizationSlug, name, userId, version }: Options,
{ organizationSlug, name, userId, version, onboardingData }: Options,
attemptCount = 0
): Promise<Project & { organization: Organization }> {
//check the user has permissions to do this
@@ -84,6 +85,7 @@ export async function createProject(
name,
userId,
version,
onboardingData,
},
attemptCount + 1
);
@@ -100,6 +102,7 @@ export async function createProject(
},
externalRef: `proj_${externalRefGenerator()}`,
version: version === "v3" ? "V3" : "V2",
onboardingData,
},
include: {
organization: {
+3 -1
View File
@@ -332,13 +332,15 @@ export function updateUser({
email,
marketingEmails,
referralSource,
onboardingData,
}: Pick<User, "id" | "name" | "email"> & {
marketingEmails?: boolean;
referralSource?: string;
onboardingData?: Prisma.InputJsonValue;
}) {
return prisma.user.update({
where: { id },
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
data: { name, email, marketingEmails, referralSource, onboardingData, confirmedBasicDetails: true },
});
}
@@ -27,6 +27,11 @@ import {
envTypeToVercelTarget,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import {
callVercelWithRecovery,
wrapVercelCallWithRecovery,
VercelSchemas,
} from "./vercelSdkRecovery.server";
// ---------------------------------------------------------------------------
// Pure helpers
@@ -314,17 +319,21 @@ export class VercelIntegrationRepository {
teamId: string | null
): ResultAsync<string, VercelApiError> {
if (teamId) {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.teams.getTeam({ teamId }),
VercelSchemas.getTeam,
"Failed to fetch Vercel team",
{ teamId }
{ teamId },
toVercelApiError
).map((response) => response.slug);
}
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.user.getAuthUser(),
VercelSchemas.getAuthUser,
"Failed to fetch Vercel user",
{}
{},
toVercelApiError
).map((response) => response?.user.username ?? "unknown");
}
@@ -333,10 +342,11 @@ export class VercelIntegrationRepository {
): ResultAsync<{ isValid: boolean }, VercelApiError> {
return this.getVercelClient(integration)
.andThen((client) =>
ResultAsync.fromPromise(
callVercelWithRecovery(
client.user.getAuthUser(),
toVercelApiError
)
VercelSchemas.getAuthUser,
{ context: "validateVercelToken" }
).mapErr(toVercelApiError)
)
.map(() => ({ isValid: true }))
.orElse((error) =>
@@ -420,13 +430,15 @@ export class VercelIntegrationRepository {
projectId: string,
teamId?: string | null
): ResultAsync<VercelCustomEnvironment[], VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.environment.getV9ProjectsIdOrNameCustomEnvironments({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.getCustomEnvironments,
"Failed to fetch Vercel custom environments",
{ projectId, teamId }
{ projectId, teamId },
toVercelApiError
).map((response) => (response.environments || []).map(toVercelCustomEnvironment));
}
@@ -435,13 +447,15 @@ export class VercelIntegrationRepository {
projectId: string,
teamId?: string | null,
): ResultAsync<VercelEnvironmentVariable[], VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variables",
{ projectId, teamId }
{ projectId, teamId },
toVercelApiError
).map((response) => {
// Warn if response is paginated (more data exists that we're not fetching)
if (
@@ -467,13 +481,15 @@ export class VercelIntegrationRepository {
/** If provided, only include keys that pass this filter */
shouldIncludeKey?: (key: string) => boolean
): ResultAsync<VercelEnvironmentVariableValue[], VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variable values",
{ projectId, teamId, target }
{ projectId, teamId, target },
toVercelApiError
).andThen((response) => {
// Apply all filters BEFORE decryption to avoid unnecessary API calls
const filteredEnvs = extractVercelEnvs(response).filter((env) => {
@@ -510,13 +526,14 @@ export class VercelIntegrationRepository {
// Encrypted vars: fetch decrypted value via individual endpoint
// (list endpoint's decrypt param is deprecated)
const result = await ResultAsync.fromPromise(
const result = await callVercelWithRecovery(
client.projects.getProjectEnv({
idOrName: projectId,
id: env.id,
...(teamId && { teamId }),
}),
(error) => error
VercelSchemas.getProjectEnv,
{ context: "resolveEnvVarValue" }
);
if (result.isErr()) {
@@ -552,13 +569,15 @@ export class VercelIntegrationRepository {
isSecret: boolean;
target: string[];
}>, VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variables",
{ teamId, projectId }
{ teamId, projectId },
toVercelApiError
).map((response) => {
const envVars = response.data || [];
return envVars
@@ -593,13 +612,15 @@ export class VercelIntegrationRepository {
}>,
VercelApiError
> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variable values",
{ teamId, projectId }
{ teamId, projectId },
toVercelApiError
).andThen((listResponse) => {
const envVars = listResponse.data || [];
if (envVars.length === 0) {
@@ -635,12 +656,13 @@ export class VercelIntegrationRepository {
}
// Try to get the decrypted value for this shared env var
const getResult = await ResultAsync.fromPromise(
const getResult = await callVercelWithRecovery(
client.environment.getSharedEnvVar({
id: envId,
teamId,
}),
(error) => error
VercelSchemas.getSharedEnvVar,
{ context: "getSharedEnvVar" }
);
if (getResult.isOk()) {
@@ -655,47 +677,12 @@ export class VercelIntegrationRepository {
};
}
// Workaround: Vercel SDK may throw ResponseValidationError even when the API response
// is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
const error = getResult.error;
let errorValue: string | undefined;
if (error && typeof error === "object" && "rawValue" in error) {
const rawValue = (error as any).rawValue;
if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
errorValue = rawValue.value as string | undefined;
}
}
const fallbackValue = errorValue || listValue;
if (fallbackValue) {
logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
teamId,
envId,
envKey,
error: error instanceof Error ? error.message : String(error),
hasErrorRawValue: !!errorValue,
hasListValue: !!listValue,
valueLength: fallbackValue.length,
});
return {
key: envKey,
value: fallbackValue,
target: normalizeTarget(env.target),
type,
isSecret,
applyToAllCustomEnvironments: applyToAllCustomEnvs,
};
}
logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
logger.warn("Failed to get decrypted value for shared env var", {
teamId,
projectId,
envId,
envKey,
error: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
hasRawValue: error && typeof error === "object" && "rawValue" in error,
error: getResult.error instanceof Error ? getResult.error.message : String(getResult.error),
});
return null;
})
@@ -723,11 +710,18 @@ export class VercelIntegrationRepository {
let from: string | undefined;
do {
const response = await client.projects.getProjects({
...(teamId && { teamId }),
limit: "100",
...(from && { from }),
});
const response = await callVercelWithRecovery(
client.projects.getProjects({
...(teamId && { teamId }),
limit: "100",
...(from && { from }),
}),
VercelSchemas.getProjects,
{ context: "getVercelProjects" }
).match(
(val) => val,
(err) => { throw err; }
);
const projects = Array.isArray(response)
? response
@@ -975,6 +969,13 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] as string[] };
}
await this.removeAllVercelEnvVarsByKey({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
key: "TRIGGER_SECRET_KEY",
});
const result = await this.batchUpsertVercelEnvVars({
client,
vercelProjectId: params.vercelProjectId,
@@ -1081,6 +1082,111 @@ export class VercelIntegrationRepository {
);
}
static upsertEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
value: string;
customEnvironmentId: string;
type: "sensitive" | "encrypted" | "plain";
}): ResultAsync<void, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const { vercelProjectId, teamId, key, value, customEnvironmentId, type } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "upsertEnvVarForCustomEnvironment" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
const existingEnv = envs.find((env) => {
if (env.key !== key) return false;
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
});
if (existingEnv && existingEnv.id) {
await client.projects.editProjectEnv({
idOrName: vercelProjectId,
id: existingEnv.id,
...(teamId && { teamId }),
requestBody: {
value,
type,
},
});
} else {
await client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {
key,
value,
type,
customEnvironmentIds: [customEnvironmentId],
} as any,
});
}
})(),
(error) => toVercelApiError(error)
)
);
}
static removeEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
customEnvironmentId: string;
}): ResultAsync<void, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const { vercelProjectId, teamId, key, customEnvironmentId } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "removeEnvVarForCustomEnvironment" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
const existingEnv = envs.find((env) => {
if (env.key !== key) return false;
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
});
if (existingEnv && existingEnv.id) {
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: [existingEnv.id] },
});
}
})(),
(error) => toVercelApiError(error)
)
);
}
static pullEnvVarsFromVercel(params: {
projectId: string;
vercelProjectId: string;
@@ -1409,10 +1515,17 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] };
}
const existingEnvs = await client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
});
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "batchUpsertVercelEnvVars" }
).match(
(val) => val,
(err) => { throw err; }
);
const existingEnvsList = extractVercelEnvs(existingEnvs);
@@ -1526,6 +1639,42 @@ export class VercelIntegrationRepository {
return { created, updated, errors };
}
private static async removeAllVercelEnvVarsByKey(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
key: string;
}): Promise<void> {
const { client, vercelProjectId, teamId, key } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "removeAllVercelEnvVarsByKey" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
const idsToRemove = envs
.filter((env) => env.key === key && env.id)
.map((env) => env.id!);
if (idsToRemove.length === 0) {
return;
}
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: idsToRemove },
});
}
private static async upsertVercelEnvVar(params: {
client: Vercel;
vercelProjectId: string;
@@ -1537,10 +1686,17 @@ export class VercelIntegrationRepository {
}): Promise<void> {
const { client, vercelProjectId, teamId, key, value, target, type } = params;
const existingEnvs = await client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
});
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "upsertVercelEnvVar" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
@@ -1584,14 +1740,16 @@ export class VercelIntegrationRepository {
teamId?: string | null
): ResultAsync<boolean | null, VercelApiError> {
// Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying.
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.updateProject({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {},
}),
VercelSchemas.updateProject,
"Failed to get Vercel project autoAssignCustomDomains",
{ vercelProjectId, teamId }
{ vercelProjectId, teamId },
toVercelApiError
).map((project) => project.autoAssignCustomDomains ?? null);
}
@@ -0,0 +1,195 @@
import { z } from "zod";
import { ResultAsync, okAsync, errAsync } from "neverthrow";
import { logger } from "~/services/logger.server";
import type { VercelApiError } from "./vercelIntegration.server";
// ---------------------------------------------------------------------------
// Recovery utilities for Vercel SDK validation errors
// ---------------------------------------------------------------------------
//
// The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod
// schemas. When the API returns valid data but a field doesn't match the SDK's
// type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is
// thrown — even though the response contains all the data we need.
//
// Error hierarchy:
// VercelError.body → raw HTTP body text (HTTP errors — never recover)
// ResponseValidationError.rawValue → parsed JSON that failed validation
// SDKValidationError.rawValue → same pattern, different base class
//
// Recovery: gate on validation error type → extract rawValue → validate → return.
// ---------------------------------------------------------------------------
/**
* Only attempt recovery for SDK validation errors — not HTTP errors (401/403).
*
* ResponseValidationError and SDKValidationError both carry `rawValue` with the
* parsed JSON that failed schema validation. VercelError (HTTP errors) carries
* `body` instead — we must NOT recover from those since the response is an error
* payload, not the data we asked for.
*/
function isValidationError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
if (!(error instanceof Error)) return false;
return (
error.constructor.name === "ResponseValidationError" ||
error.constructor.name === "SDKValidationError" ||
"rawValue" in error
);
}
function extractRawValue(error: unknown): unknown | undefined {
if (!error || typeof error !== "object") return undefined;
if ("rawValue" in error) {
return (error as { rawValue: unknown }).rawValue;
}
return undefined;
}
/**
* Attempt to recover usable data from a Vercel SDK error.
*
* Returns the validated data on success, or `undefined` if recovery fails.
*/
export function recoverFromVercelSdkError<T>(
error: unknown,
schema: z.ZodType<any>,
options?: { context?: string }
): T | undefined {
if (!isValidationError(error)) return undefined;
const raw = extractRawValue(error);
if (raw === undefined) return undefined;
const result = schema.safeParse(raw);
if (!result.success) return undefined;
logger.warn("Recovered data from Vercel SDK validation error", {
context: options?.context,
errorMessage: error instanceof Error ? error.message : String(error),
errorType: error?.constructor?.name,
});
return result.data;
}
/**
* Wrap a Vercel SDK promise with automatic recovery on validation errors.
*
* On success: returns the SDK result as-is.
* On error: attempts recovery via rawValue + schema validation (validation errors only).
*/
export function callVercelWithRecovery<T>(
sdkCall: Promise<T>,
schema: z.ZodType<any>,
options?: { context?: string }
): ResultAsync<T, unknown> {
return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
const recovered = recoverFromVercelSdkError<T>(error, schema, options);
if (recovered !== undefined) {
return okAsync(recovered);
}
return errAsync(error);
});
}
/**
* Drop-in replacement for `wrapVercelCall` with SDK error recovery.
*
* Wraps a Vercel SDK promise in ResultAsync with structured error logging,
* attempting to recover from validation errors before treating as failure.
*/
export function wrapVercelCallWithRecovery<T>(
promise: Promise<T>,
schema: z.ZodType<any>,
message: string,
context: Record<string, unknown>,
toError: (error: unknown) => VercelApiError
): ResultAsync<T, VercelApiError> {
return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
const apiError = toError(error);
logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
return apiError;
});
}
// ---------------------------------------------------------------------------
// Minimal Zod schemas — validate only the fields we actually use.
// All use .passthrough() to preserve extra fields from the API response.
// ---------------------------------------------------------------------------
export const VercelSchemas = {
getTeam: z.object({ slug: z.string() }).passthrough(),
getAuthUser: z
.object({ user: z.object({ username: z.string() }).passthrough() })
.passthrough(),
getCustomEnvironments: z
.object({
environments: z
.array(
z
.object({
id: z.string(),
slug: z.string(),
description: z.string().optional(),
branchMatcher: z.unknown().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough(),
filterProjectEnvs: z
.union([
z
.object({
envs: z.array(z.record(z.unknown())),
pagination: z.unknown().optional(),
})
.passthrough(),
z.array(z.record(z.unknown())),
])
.transform((val) => (Array.isArray(val) ? { envs: val } : val)),
getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(),
getProjects: z.union([
z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
z
.object({
projects: z.array(
z.object({ id: z.string(), name: z.string() }).passthrough()
),
pagination: z.unknown().optional(),
})
.passthrough(),
]),
listSharedEnvVariable: z
.object({
data: z
.array(
z
.object({
id: z.string().optional(),
key: z.string().optional(),
type: z.string().optional(),
target: z.unknown().optional(),
value: z.string().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough(),
getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(),
updateProject: z
.object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() })
.passthrough(),
} as const;
@@ -112,6 +112,7 @@ export class OrganizationsPresenter {
organization,
project: {
...fullProject,
createdAt: fullProject.createdAt,
environments: sortEnvironments(
fullProject.environments.filter((env) => {
if (env.type !== "DEVELOPMENT") return true;
@@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import {
VercelProjectIntegrationDataSchema,
buildVercelDeploymentUrl,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
const pageSize = 20;
@@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
let vercelDeploymentUrl: string | null = null;
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
vercelDeploymentUrl = buildVercelDeploymentUrl(
vercelTeamSlug,
vercelProjectName,
deployment.integrationDeploymentId
);
}
return {
@@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
import { processGitMetadata } from "./BranchesPresenter.server";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { S2 } from "@s2-dev/streamstore";
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
@@ -161,6 +162,51 @@ export class DeploymentPresenter {
});
const gitMetadata = processGitMetadata(deployment.git);
// Look up Vercel integration data to construct a deployment URL
let vercelDeploymentUrl: string | undefined;
const vercelProjectIntegration =
await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: project.id,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
select: {
integrationData: true,
},
});
if (vercelProjectIntegration) {
const parsed = VercelProjectIntegrationDataSchema.safeParse(
vercelProjectIntegration.integrationData
);
if (parsed.success && parsed.data.vercelTeamSlug) {
const integrationDeployment =
await this.#prismaClient.integrationDeployment.findFirst({
where: {
deploymentId: deployment.id,
integrationName: "vercel",
},
select: {
integrationDeploymentId: true,
},
orderBy: {
createdAt: "desc",
},
});
if (integrationDeployment) {
const vercelId = integrationDeployment.integrationDeploymentId;
vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
}
}
}
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
@@ -171,7 +217,7 @@ export class DeploymentPresenter {
let eventStream = undefined;
if (
env.S2_ENABLED === "1" &&
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
(buildServerMetadata || gitMetadata?.source === "trigger_github_app" || env.S2_DEPLOYMENT_STREAMS_LOCAL === "1")
) {
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
@@ -227,6 +273,7 @@ export class DeploymentPresenter {
type: deployment.type,
git: gitMetadata,
triggeredVia: deployment.triggeredVia,
vercelDeploymentUrl,
},
};
}
@@ -243,9 +290,9 @@ export class DeploymentPresenter {
return cachedToken;
}
const { access_token: accessToken } = await s2.accessTokens.issue({
const { accessToken } = await s2.accessTokens.issue({
id: `${projectRef}-${new Date().getTime()}`,
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
scope: {
ops: ["read"],
basins: {
@@ -1,6 +1,7 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { marqs } from "~/v3/marqs/index.server";
import { engine } from "~/v3/runEngine.server";
import { getQueueSizeLimit } from "~/v3/utils/queueLimits.server";
import { BasePresenter } from "./basePresenter.server";
export type Environment = {
@@ -9,6 +10,7 @@ export type Environment = {
concurrencyLimit: number;
burstFactor: number;
runsEnabled: boolean;
queueSizeLimit: number | null;
};
export class EnvironmentQueuePresenter extends BasePresenter {
@@ -30,6 +32,8 @@ export class EnvironmentQueuePresenter extends BasePresenter {
},
select: {
runsEnabled: true,
maximumDevQueueSize: true,
maximumDeployedQueueSize: true,
},
});
@@ -37,12 +41,15 @@ export class EnvironmentQueuePresenter extends BasePresenter {
throw new Error("Organization not found");
}
const queueSizeLimit = getQueueSizeLimit(environment.type, organization);
return {
running,
queued,
concurrencyLimit: environment.maximumConcurrencyLimit,
burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
queueSizeLimit,
};
}
}
@@ -1,4 +1,5 @@
import { Ratelimit } from "@upstash/ratelimit";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
@@ -12,6 +13,8 @@ import { BasePresenter } from "./basePresenter.server";
import { singleton } from "~/utils/singleton";
import { logger } from "~/services/logger.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import { engine } from "~/v3/runEngine.server";
import { getQueueSizeLimit, getQueueSizeLimitSource } from "~/v3/utils/queueLimits.server";
// Create a singleton Redis client for rate limit queries
const rateLimitRedisClient = singleton("rateLimitQueryRedisClient", () =>
@@ -66,8 +69,7 @@ export type LimitsResult = {
logRetentionDays: QuotaInfo | null;
realtimeConnections: QuotaInfo | null;
batchProcessingConcurrency: QuotaInfo;
devQueueSize: QuotaInfo;
deployedQueueSize: QuotaInfo;
queueSize: QuotaInfo;
metricDashboards: QuotaInfo | null;
metricWidgetsPerDashboard: QuotaInfo | null;
queryPeriodDays: QuotaInfo | null;
@@ -87,11 +89,13 @@ export class LimitsPresenter extends BasePresenter {
organizationId,
projectId,
environmentId,
environmentType,
environmentApiKey,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
environmentApiKey: string;
}): Promise<LimitsResult> {
// Get organization with all limit-related fields
@@ -175,6 +179,30 @@ export class LimitsPresenter extends BasePresenter {
batchRateLimitConfig
);
// Get current queue size for this environment
// We need the runtime environment fields for the engine query
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
where: { id: environmentId },
select: {
id: true,
maximumConcurrencyLimit: true,
concurrencyLimitBurstFactor: true,
},
});
let currentQueueSize = 0;
if (runtimeEnv) {
const engineEnv = {
id: runtimeEnv.id,
type: environmentType,
maximumConcurrencyLimit: runtimeEnv.maximumConcurrencyLimit,
concurrencyLimitBurstFactor: runtimeEnv.concurrencyLimitBurstFactor,
organization: { id: organizationId },
project: { id: projectId },
};
currentQueueSize = (await engine.lengthOfEnvQueue(engineEnv)) ?? 0;
}
// Get plan-level limits
const schedulesLimit = limits?.schedules?.number ?? null;
const teamMembersLimit = limits?.teamMembers?.number ?? null;
@@ -217,95 +245,90 @@ export class LimitsPresenter extends BasePresenter {
schedules:
schedulesLimit !== null
? {
name: "Schedules",
description: "Maximum number of schedules per project",
limit: schedulesLimit,
currentUsage: scheduleCount,
source: "plan",
canExceed: limits?.schedules?.canExceed,
isUpgradable: true,
}
name: "Schedules",
description: "Maximum number of schedules per project",
limit: schedulesLimit,
currentUsage: scheduleCount,
source: "plan",
canExceed: limits?.schedules?.canExceed,
isUpgradable: true,
}
: null,
teamMembers:
teamMembersLimit !== null
? {
name: "Team members",
description: "Maximum number of team members in this organization",
limit: teamMembersLimit,
currentUsage: organization._count.members,
source: "plan",
canExceed: limits?.teamMembers?.canExceed,
isUpgradable: true,
}
name: "Team members",
description: "Maximum number of team members in this organization",
limit: teamMembersLimit,
currentUsage: organization._count.members,
source: "plan",
canExceed: limits?.teamMembers?.canExceed,
isUpgradable: true,
}
: null,
alerts:
alertsLimit !== null
? {
name: "Alert channels",
description: "Maximum number of alert channels per project",
limit: alertsLimit,
currentUsage: alertChannelCount,
source: "plan",
canExceed: limits?.alerts?.canExceed,
isUpgradable: true,
}
name: "Alert channels",
description: "Maximum number of alert channels per project",
limit: alertsLimit,
currentUsage: alertChannelCount,
source: "plan",
canExceed: limits?.alerts?.canExceed,
isUpgradable: true,
}
: null,
branches:
branchesLimit !== null
? {
name: "Preview branches",
description: "Maximum number of active preview branches per project",
limit: branchesLimit,
currentUsage: activeBranchCount,
source: "plan",
canExceed: limits?.branches?.canExceed,
isUpgradable: true,
}
name: "Preview branches",
description: "Maximum number of active preview branches per project",
limit: branchesLimit,
currentUsage: activeBranchCount,
source: "plan",
canExceed: limits?.branches?.canExceed,
isUpgradable: true,
}
: null,
logRetentionDays:
logRetentionDaysLimit !== null
? {
name: "Log retention",
description: "Number of days logs are retained",
limit: logRetentionDaysLimit,
currentUsage: 0, // Not applicable - this is a duration, not a count
source: "plan",
}
name: "Log retention",
description: "Number of days logs are retained",
limit: logRetentionDaysLimit,
currentUsage: 0, // Not applicable - this is a duration, not a count
source: "plan",
}
: null,
realtimeConnections:
realtimeConnectionsLimit !== null
? {
name: "Realtime connections",
description: "Maximum concurrent Realtime connections",
limit: realtimeConnectionsLimit,
currentUsage: 0, // Would need to query realtime service for this
source: "plan",
canExceed: limits?.realtimeConcurrentConnections?.canExceed,
isUpgradable: true,
}
name: "Realtime connections",
description: "Maximum concurrent Realtime connections",
limit: realtimeConnectionsLimit,
currentUsage: 0, // Would need to query realtime service for this
source: "plan",
canExceed: limits?.realtimeConcurrentConnections?.canExceed,
isUpgradable: true,
}
: null,
batchProcessingConcurrency: {
name: "Batch processing concurrency",
description: "Controls how many batch items can be processed simultaneously.",
name: "Batch trigger processing concurrency",
description:
"When you send a batch trigger, we convert it into individual runs in parallel. This is the maximum number of batches being converted into runs at once. It does not limit how many batch runs can be executing.",
limit: batchConcurrencyConfig.processingConcurrency,
currentUsage: 0,
source: batchConcurrencySource,
canExceed: true,
isUpgradable: true,
},
devQueueSize: {
name: "Dev queue size",
description: "Maximum pending runs in development environments",
limit: organization.maximumDevQueueSize ?? null,
currentUsage: 0, // Would need to query Redis for this
source: organization.maximumDevQueueSize ? "override" : "default",
},
deployedQueueSize: {
name: "Deployed queue size",
description: "Maximum pending runs in deployed environments",
limit: organization.maximumDeployedQueueSize ?? null,
currentUsage: 0, // Would need to query Redis for this
source: organization.maximumDeployedQueueSize ? "override" : "default",
queueSize: {
name: "Max queued runs",
description: "Maximum pending runs per individual queue in this environment",
limit: getQueueSizeLimit(environmentType, organization),
currentUsage: currentQueueSize,
source: getQueueSizeLimitSource(environmentType, organization),
isUpgradable: true,
},
metricDashboards:
metricDashboardsLimit !== null
@@ -509,8 +509,7 @@ export class SpanPresenter extends BasePresenter {
taskIdentifier: true,
spanId: true,
createdAt: true,
number: true,
taskVersion: true,
status: true,
},
where: {
parentSpanId: spanId,
@@ -630,6 +629,41 @@ export class SpanPresenter extends BasePresenter {
},
};
}
case "input-stream": {
if (!span.entity.id) {
logger.error(`SpanPresenter: No input stream id`, {
spanId,
inputStreamId: span.entity.id,
});
return { ...data, entity: null };
}
const [runId, streamId] = span.entity.id.split(":");
if (!runId || !streamId) {
logger.error(`SpanPresenter: Invalid input stream id`, {
spanId,
inputStreamId: span.entity.id,
});
return { ...data, entity: null };
}
// Translate user-facing stream ID to internal S2 stream name
const s2StreamKey = `$trigger.input:${streamId}`;
return {
...data,
entity: {
type: "realtime-stream" as const,
object: {
runId,
streamKey: s2StreamKey,
displayName: streamId,
metadata: undefined,
},
},
};
}
default:
return { ...data, entity: null };
}
@@ -40,6 +40,8 @@ export type VercelSettingsResult = {
customEnvironments: VercelCustomEnvironment[];
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
autoAssignCustomDomains?: boolean | null;
/** URL to manage Vercel integration access (project sharing) on vercel.com */
vercelManageAccessUrl?: string;
};
export type VercelAvailableProject = {
@@ -242,11 +244,12 @@ export class VercelSettingsPresenter extends BasePresenter {
checkPreviewEnvironment(),
getVercelProjectIntegration(),
]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => {
const fetchCustomEnvsAndProjectSettings = async (): Promise<{
const fetchVercelData = async (): Promise<{
customEnvironments: VercelCustomEnvironment[];
autoAssignCustomDomains: boolean | null;
vercelManageAccessUrl?: string;
}> => {
if (!connectedProject || !orgIntegration) {
if (!orgIntegration) {
return { customEnvironments: [], autoAssignCustomDomains: null };
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
@@ -255,6 +258,26 @@ export class VercelSettingsPresenter extends BasePresenter {
}
const client = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
// Build manage access URL
let vercelManageAccessUrl: string | undefined;
const appSlug = env.VERCEL_INTEGRATION_APP_SLUG;
const integrationData = orgIntegration.integrationData as Record<string, unknown> | null;
const installationId =
typeof integrationData?.installationId === "string"
? integrationData.installationId
: undefined;
if (appSlug && installationId && teamId) {
const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId);
if (teamSlugResult.isOk()) {
vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`;
}
}
if (!connectedProject) {
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl };
}
const [customEnvsResult, autoAssignResult] = await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
@@ -270,13 +293,14 @@ export class VercelSettingsPresenter extends BasePresenter {
return {
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
vercelManageAccessUrl,
};
};
return fromPromise(
fetchCustomEnvsAndProjectSettings(),
fetchVercelData(),
(error) => ({ type: "other" as const, cause: error })
).map(({ customEnvironments, autoAssignCustomDomains }) => ({
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({
enabled: true,
hasOrgIntegration,
authInvalid: false,
@@ -286,6 +310,7 @@ export class VercelSettingsPresenter extends BasePresenter {
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
vercelManageAccessUrl,
} as VercelSettingsResult));
}).mapErr((error) => {
// Log the error and return a safe fallback
@@ -1,36 +1,37 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactGridLayout from "react-grid-layout";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { type WidgetData } from "~/components/metrics/QueryWidget";
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { TitleWidget } from "~/components/metrics/TitleWidget";
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import {
type LayoutItem,
type Widget,
MetricDashboardPresenter,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { z } from "zod";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import ReactGridLayout from "react-grid-layout";
import { MetricWidget } from "../resources.metric";
import { TitleWidget } from "~/components/metrics/TitleWidget";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type WidgetData } from "~/components/metrics/QueryWidget";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { QueryScopeSchema } from "~/v3/querySchemas";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { MetricWidget } from "../resources.metric";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardKey: z.string(),
@@ -82,10 +83,21 @@ export default function Page() {
possibleTasks,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>
<CreateDashboardPageButton
organization={organization}
project={project}
environment={environment}
/>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="h-full">
@@ -55,7 +55,7 @@ import {
v3BillingPath,
v3BuiltInDashboardPath,
} from "~/utils/pathBuilder";
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route";
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Type } from "lucide-react";
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { GitMetadata } from "~/components/GitMetadata";
import { VercelLink } from "~/components/integrations/VercelLink";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
@@ -197,17 +198,14 @@ export default function Page() {
const readSession = await stream.readSession(
{
seq_num: 0,
wait: 60,
as: "bytes",
start: { from: { seqNum: 0 }, clamp: true },
stop: { waitSecs: 60 },
},
{ signal: abortController.signal }
);
const decoder = new TextDecoder();
for await (const record of readSession) {
const decoded = decoder.decode(record.body);
const decoded = record.body;
const result = DeploymentEventFromString.safeParse(decoded);
if (!result.success) {
@@ -216,8 +214,8 @@ export default function Page() {
const headers: Record<string, string> = {};
if (record.headers) {
for (const [nameBytes, valueBytes] of record.headers) {
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
for (const [name, value] of record.headers) {
headers[name] = value;
}
}
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
@@ -516,6 +514,16 @@ export default function Page() {
})()}
</Property.Value>
</Property.Item>
{deployment.vercelDeploymentUrl && (
<Property.Item>
<Property.Label>Linked</Property.Label>
<Property.Value>
<div className="-ml-1 mt-0.5 flex flex-col">
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
</div>
</Property.Value>
</Property.Item>
)}
</Property.Table>
</div>
@@ -19,7 +19,7 @@ import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { VercelLink } from "~/components/integrations/VercelLink";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { GitMetadata } from "~/components/GitMetadata";
@@ -56,7 +56,6 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import {
DeploymentStatus,
deploymentStatusDescription,
@@ -76,7 +75,7 @@ import {
EnvironmentParamSchema,
docsPath,
v3DeploymentPath,
v3ProjectSettingsPath,
v3ProjectSettingsIntegrationsPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
@@ -314,20 +313,14 @@ export default function Page() {
{hasVercelIntegration && (
<TableCell isSelected={isSelected}>
{deployment.vercelDeploymentUrl ? (
<SimpleTooltip
button={
<a
href={deployment.vercelDeploymentUrl}
target="_blank"
rel="noreferrer noopener"
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
onClick={(e) => e.stopPropagation()}
>
<VercelLogo className="size-3.5" />
</a>
}
content="View on Vercel"
/>
<div
className="-ml-1 flex items-center"
onClick={(e) => e.stopPropagation()}
>
<VercelLink
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
/>
</div>
) : (
""
)}
@@ -377,7 +370,7 @@ export default function Page() {
<LinkButton
variant="minimal/small"
LeadingIcon={CogIcon}
to={v3ProjectSettingsPath(organization, project, environment)}
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
/>
</div>
)}
@@ -82,6 +82,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
environmentApiKey: environment.apiKey,
})
);
@@ -507,9 +508,8 @@ function QuotasSection({
// Include batch processing concurrency
quotaRows.push(quotas.batchProcessingConcurrency);
// Add queue size quotas if set
if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize);
if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize);
// Add queue size quota if set
if (quotas.queueSize.limit !== null) quotaRows.push(quotas.queueSize);
// Metric & query quotas
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
@@ -565,8 +565,11 @@ function QuotaRow({
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
const isPerItemQuota = quota.name === "Charts per dashboard";
const isRetentionQuota = isDurationQuota || isPerItemQuota;
const isQueueSizeQuota = quota.name === "Max queued runs";
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;
const percentage =
!isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
!hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
// Special handling for duration-based quotas (Log retention, Query period)
if (isDurationQuota) {
@@ -667,10 +670,10 @@ function QuotaRow({
alignment="right"
className={cn(
"tabular-nums",
isRetentionQuota ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
hideCurrentUsage ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
)}
>
{isRetentionQuota ? "" : formatNumber(quota.currentUsage)}
{hideCurrentUsage ? "" : formatNumber(quota.currentUsage)}
</TableCell>
<TableCell alignment="right">
<SourceBadge source={quota.source} />
@@ -0,0 +1,20 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardKey: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { organizationSlug, projectParam, envParam, dashboardKey } = ParamSchema.parse(params);
return redirect(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
dashboardKey
),
301
);
};
@@ -0,0 +1,20 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { organizationSlug, projectParam, envParam, dashboardId } = ParamSchema.parse(params);
return redirect(
v3CustomDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: dashboardId }
),
301
);
};
@@ -345,7 +345,7 @@ export default function Page() {
<BigNumber
title="Queued"
value={environment.queued}
suffix={env.paused && environment.queued > 0 ? "paused" : undefined}
suffix={env.paused ? <span className="text-warning">paused</span> : undefined}
animate
accessory={
<div className="flex items-start gap-1">
@@ -364,7 +364,7 @@ export default function Page() {
/>
</div>
}
valueClassName={cn(env.paused ? "text-warning" : undefined, "tabular-nums")}
valueClassName={env.paused ? "text-warning tabular-nums" : "tabular-nums"}
compactThreshold={1000000}
/>
<BigNumber
@@ -509,7 +509,10 @@ export default function Page() {
{queues.length > 0 ? (
queues.map((queue) => {
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
const isAtLimit = queue.running >= limit;
const isAtConcurrencyLimit = queue.running >= limit;
const isAtQueueLimit =
environment.queueSizeLimit !== null &&
queue.queued >= environment.queueSizeLimit;
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
queue.name
}`;
@@ -535,7 +538,12 @@ export default function Page() {
Paused
</Badge>
) : null}
{isAtLimit ? (
{isAtQueueLimit ? (
<Badge variant="extra-small" className="text-error">
At queue limit
</Badge>
) : null}
{isAtConcurrencyLimit ? (
<Badge variant="extra-small" className="text-warning">
At concurrency limit
</Badge>
@@ -546,7 +554,8 @@ export default function Page() {
alignment="right"
className={cn(
"w-[1%] pl-16 tabular-nums",
queue.paused ? "opacity-50" : undefined
queue.paused ? "opacity-50" : undefined,
isAtQueueLimit && "text-error"
)}
>
{queue.queued}
@@ -557,7 +566,7 @@ export default function Page() {
"w-[1%] pl-16 tabular-nums",
queue.paused ? "opacity-50" : undefined,
queue.running > 0 && "text-text-bright",
isAtLimit && "text-warning"
isAtConcurrencyLimit && "text-warning"
)}
>
{queue.running}
@@ -577,7 +586,7 @@ export default function Page() {
className={cn(
"w-[1%] pl-16",
queue.paused ? "opacity-50" : undefined,
isAtLimit && "text-warning",
isAtConcurrencyLimit && "text-warning",
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
@@ -0,0 +1,293 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { useProject } from "~/hooks/useProject";
import {
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
import { useState } from "react";
function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
z.object({
action: z.literal("rename"),
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
}),
z.object({
action: z.literal("delete"),
projectSlug: z.string().superRefine((slug, ctx) => {
if (constraints.getSlugMatch === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
if (isMatch) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The slug must match ${projectSlug}`,
});
}
}),
}),
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = params;
if (!organizationSlug || !projectParam) {
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
},
});
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
const resultOrFail = await projectSettingsService.renameProject(
projectId,
submission.value.projectName
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to rename project", {
error: resultOrFail.error,
});
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
}
}
}
return redirectWithSuccessMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to delete project", {
error: resultOrFail.error,
});
return redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project ${projectParam} could not be deleted`
);
}
}
}
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
"Project deleted"
);
}
}
};
export default function GeneralSettingsPage() {
const project = useProject();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
const [renameForm, { projectName }] = useForm({
id: "rename-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema(),
});
},
});
const isRenameLoading =
navigation.formData?.get("action") === "rename" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [deleteForm, { projectSlug }] = useForm({
id: "delete-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
}),
});
},
});
const isDeleteLoading =
navigation.formData?.get("action") === "delete" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [deleteInputValue, setDeleteInputValue] = useState("");
return (
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
<div>
<Header2 spacing>General</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<Fieldset className="mb-5">
<InputGroup fullWidth>
<Label>Project ref</Label>
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
<Hint>
This goes in your{" "}
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
</Hint>
</InputGroup>
</Fieldset>
<Form method="post" {...renameForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Project name"
icon={FolderIcon}
autoFocus
onChange={(e) => {
setHasRenameFormChanges(e.target.value !== project.name);
}}
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="rename"
variant={"secondary/small"}
disabled={isRenameLoading || !hasRenameFormChanges}
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Form method="post" {...deleteForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon={ExclamationTriangleIcon}
onChange={(e) => setDeleteInputValue(e.target.value)}
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="delete"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
leadingIconClassName="text-white"
disabled={isDeleteLoading || deleteInputValue !== project.slug}
>
Delete
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</div>
</MainHorizontallyCenteredContainer>
);
}
@@ -0,0 +1,500 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
} from "~/models/message.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "@remix-run/react";
import { type BuildSettings } from "~/v3/buildSettings";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import {
VercelSettingsPanel,
VercelOnboardingModal,
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
const projectSettingsPresenter = new ProjectSettingsPresenter();
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
organizationSlug,
projectParam,
userId
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "project_not_found": {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed loading project settings", {
error: resultOrFail.error,
});
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, please try again!",
});
}
}
}
const { gitHubApp, buildSettings } = resultOrFail.value;
return typedjson({
githubAppEnabled: gitHubApp.enabled,
buildSettings,
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
});
};
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
.string()
.trim()
.optional()
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
.refine((val) => !val || val.length <= 255, {
message: "Config file path must not exceed 255 characters",
}),
installCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Install command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Install command must not exceed 500 characters",
}),
preBuildCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Pre-build command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Pre-build command must not exceed 500 characters",
}),
useNativeBuildServer: z
.string()
.optional()
.transform((val) => val === "on"),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = params;
if (!organizationSlug || !projectParam) {
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId } = membershipResultOrFail.value;
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
installCommand: installCommand || undefined,
preBuildCommand: preBuildCommand || undefined,
triggerConfigFilePath: triggerConfigFilePath || undefined,
useNativeBuildServer: useNativeBuildServer,
});
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update build settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update build settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
};
export default function IntegrationsSettingsPage() {
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
const [searchParams, setSearchParams] = useSearchParams();
// Vercel onboarding modal state
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
setIsModalOpen(true);
// Ensure query param is present to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
}, [hasQueryParam, setSearchParams]);
const closeVercelOnboarding = useCallback(() => {
// Remove query param if present
if (hasQueryParam) {
setSearchParams((prev) => {
prev.delete("vercelOnboarding");
return prev;
});
}
// Close modal
setIsModalOpen(false);
}, [hasQueryParam, setSearchParams]);
// When query param is present, handle modal opening
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
useEffect(() => {
if (hasQueryParam && vercelIntegrationEnabled) {
// Ensure query param is present and modal is open
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data is loaded, ensure modal is open (query param takes precedence)
if (!isModalOpen) {
openVercelOnboarding();
}
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
// Load onboarding data
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
);
}
} else if (!hasQueryParam && isModalOpen) {
// Query param removed but modal is open, close modal
setIsModalOpen(false);
}
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Ensure modal stays open when query param is present (even after data reloads)
// This is a safeguard to prevent the modal from closing during form submissions
useEffect(() => {
if (hasQueryParam && !isModalOpen) {
// Query param is present but modal is closed, open it
// This ensures the modal stays open during the onboarding flow
openVercelOnboarding();
}
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
// When data finishes loading (from query param), ensure modal is open
useEffect(() => {
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded and query param is present, ensure modal is open
if (!isModalOpen) {
openVercelOnboarding();
}
}
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Track if we're waiting for data from button click (not query param)
const waitingForButtonClickRef = useRef(false);
// Handle opening modal from button click (without query param)
const handleOpenVercelModal = useCallback(() => {
// Add query param to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
// Data already loaded, open modal immediately
openVercelOnboarding();
} else {
// Need to load data first, mark that we're waiting for button click
waitingForButtonClickRef.current = true;
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
);
}
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
// When data loads from button click, open modal
useEffect(() => {
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded from button click, open modal and ensure query param is present
waitingForButtonClickRef.current = false;
openVercelOnboarding();
}
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
return (
<>
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
{githubAppEnabled && (
<React.Fragment>
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
</div>
</MainHorizontallyCenteredContainer>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
nextUrl={nextUrl ?? undefined}
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
onDataReload={(vercelEnvironmentId) => {
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : ""
}`
);
}}
/>
)}
</>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
const [buildSettingsValues, setBuildSettingsValues] = useState({
preBuildCommand: buildSettings?.preBuildCommand || "",
installCommand: buildSettings?.installCommand || "",
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
});
useEffect(() => {
const hasChanges =
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
setHasBuildSettingsChanges(hasChanges);
}, [buildSettingsValues, buildSettings]);
const [buildSettingsForm, fields] = useForm({
id: "update-build-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateBuildSettingsFormSchema,
});
},
});
const isBuildSettingsLoading =
navigation.formData?.get("action") === "update-build-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<Form method="post" {...buildSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Path to your Trigger configuration file, relative to the root directory of your repo.
</Hint>
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.error}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...conform.input(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...conform.input(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
id={fields.useNativeBuildServer.id}
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
Native build server builds do not rely on external build providers and will become the
default in the future. Version 4.2.0 or newer is required.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.error}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
);
}
@@ -1,57 +1,13 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Outlet, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { useOrganization } from "~/hooks/useOrganizations";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { useProject } from "~/hooks/useProject";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "@remix-run/react";
import { useEnvironment } from "~/hooks/useEnvironment";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import {
VercelSettingsPanel,
VercelOnboardingModal,
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { useTypedFetcher } from "remix-typedjson";
import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
@@ -62,397 +18,28 @@ export const meta: MetaFunction = () => {
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const projectSettingsPresenter = new ProjectSettingsPresenter();
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
organizationSlug,
projectParam,
userId
);
// Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding)
const url = new URL(request.url);
if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) {
const org = { slug: organizationSlug };
const project = { slug: projectParam };
const env = { slug: envParam };
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "project_not_found": {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
const basePath = url.searchParams.has("vercelOnboarding")
? v3ProjectSettingsIntegrationsPath(org, project, env)
: v3ProjectSettingsGeneralPath(org, project, env);
logger.error("Failed loading project settings", {
error: resultOrFail.error,
});
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, please try again!",
});
}
}
return redirect(`${basePath}${url.search}`);
}
const { gitHubApp, buildSettings } = resultOrFail.value;
return typedjson({
githubAppEnabled: gitHubApp.enabled,
buildSettings,
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
});
return null;
};
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
.string()
.trim()
.optional()
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
.refine((val) => !val || val.length <= 255, {
message: "Config file path must not exceed 255 characters",
}),
installCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Install command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Install command must not exceed 500 characters",
}),
preBuildCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Pre-build command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Pre-build command must not exceed 500 characters",
}),
useNativeBuildServer: z
.string()
.optional()
.transform((val) => val === "on"),
});
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
export function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
z.object({
action: z.literal("rename"),
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
}),
z.object({
action: z.literal("delete"),
projectSlug: z.string().superRefine((slug, ctx) => {
if (constraints.getSlugMatch === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
if (isMatch) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The slug must match ${projectSlug}`,
});
}
}),
}),
UpdateBuildSettingsFormSchema,
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = params;
if (!organizationSlug || !projectParam) {
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
},
});
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
const resultOrFail = await projectSettingsService.renameProject(
projectId,
submission.value.projectName
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to rename project", {
error: resultOrFail.error,
});
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
}
}
}
return redirectWithSuccessMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to delete project", {
error: resultOrFail.error,
});
return redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project ${projectParam} could not be deleted`
);
}
}
}
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
"Project deleted"
);
}
case "update-build-settings": {
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
installCommand: installCommand || undefined,
preBuildCommand: preBuildCommand || undefined,
triggerConfigFilePath: triggerConfigFilePath || undefined,
useNativeBuildServer: useNativeBuildServer,
});
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update build settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update build settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
}
default: {
submission.value satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
}
};
export default function Page() {
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
useTypedLoaderData<typeof loader>();
export default function SettingsLayout() {
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
// Vercel onboarding modal state
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
setIsModalOpen(true);
// Ensure query param is present to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
}, [hasQueryParam, setSearchParams]);
const closeVercelOnboarding = useCallback(() => {
// Remove query param if present
if (hasQueryParam) {
setSearchParams((prev) => {
prev.delete("vercelOnboarding");
return prev;
});
}
// Close modal
setIsModalOpen(false);
}, [hasQueryParam, setSearchParams]);
// When query param is present, handle modal opening
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
useEffect(() => {
if (hasQueryParam && vercelIntegrationEnabled) {
// Ensure query param is present and modal is open
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data is loaded, ensure modal is open (query param takes precedence)
if (!isModalOpen) {
openVercelOnboarding();
}
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
// Load onboarding data
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
);
}
} else if (!hasQueryParam && isModalOpen) {
// Query param removed but modal is open, close modal
setIsModalOpen(false);
}
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Ensure modal stays open when query param is present (even after data reloads)
// This is a safeguard to prevent the modal from closing during form submissions
useEffect(() => {
if (hasQueryParam && !isModalOpen) {
// Query param is present but modal is closed, open it
// This ensures the modal stays open during the onboarding flow
openVercelOnboarding();
}
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
// When data finishes loading (from query param), ensure modal is open
useEffect(() => {
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded and query param is present, ensure modal is open
if (!isModalOpen) {
openVercelOnboarding();
}
}
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Track if we're waiting for data from button click (not query param)
const waitingForButtonClickRef = useRef(false);
// Handle opening modal from button click (without query param)
const handleOpenVercelModal = useCallback(() => {
// Add query param to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
// Data already loaded, open modal immediately
openVercelOnboarding();
} else {
// Need to load data first, mark that we're waiting for button click
waitingForButtonClickRef.current = true;
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
);
}
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
// When data loads from button click, open modal
useEffect(() => {
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded from button click, open modal and ensure query param is present
waitingForButtonClickRef.current = false;
openVercelOnboarding();
}
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
const [renameForm, { projectName }] = useForm({
id: "rename-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema(),
});
},
});
const isRenameLoading =
navigation.formData?.get("action") === "rename" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [deleteForm, { projectSlug }] = useForm({
id: "delete-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
}),
});
},
});
const isDeleteLoading =
navigation.formData?.get("action") === "delete" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [deleteInputValue, setDeleteInputValue] = useState("");
return (
<PageContainer>
@@ -479,302 +66,8 @@ export default function Page() {
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
<div>
<Header2 spacing>General</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<Fieldset className="mb-5">
<InputGroup fullWidth>
<Label>Project ref</Label>
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
<Hint>
This goes in your{" "}
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
</Hint>
</InputGroup>
</Fieldset>
<Form method="post" {...renameForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Project name"
icon={FolderIcon}
autoFocus
onChange={(e) => {
setHasRenameFormChanges(e.target.value !== project.name);
}}
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="rename"
variant={"secondary/small"}
disabled={isRenameLoading || !hasRenameFormChanges}
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
{githubAppEnabled && (
<React.Fragment>
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Form method="post" {...deleteForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon={ExclamationTriangleIcon}
onChange={(e) => setDeleteInputValue(e.target.value)}
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="delete"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
leadingIconClassName="text-white"
disabled={isDeleteLoading || deleteInputValue !== project.slug}
>
Delete
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</div>
</MainHorizontallyCenteredContainer>
<Outlet />
</PageBody>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
nextUrl={nextUrl ?? undefined}
onDataReload={(vercelEnvironmentId) => {
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : ""
}`
);
}}
/>
)}
</PageContainer>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
const [buildSettingsValues, setBuildSettingsValues] = useState({
preBuildCommand: buildSettings?.preBuildCommand || "",
installCommand: buildSettings?.installCommand || "",
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
});
useEffect(() => {
const hasChanges =
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
setHasBuildSettingsChanges(hasChanges);
}, [buildSettingsValues, buildSettings]);
const [buildSettingsForm, fields] = useForm({
id: "update-build-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateBuildSettingsFormSchema,
});
},
});
const isBuildSettingsLoading =
navigation.formData?.get("action") === "update-build-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<Form method="post" {...buildSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Path to your Trigger configuration file, relative to the root directory of your repo.
</Hint>
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.error}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...conform.input(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...conform.input(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
id={fields.useNativeBuildServer.id}
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
Native build server builds do not rely on external build providers and will become the
default in the future. Version 4.2.0 or newer is required.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.error}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
);
}
@@ -5,10 +5,12 @@ import {
CheckIcon,
ExclamationTriangleIcon,
FolderIcon,
GlobeAltIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useRef, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
@@ -26,35 +28,31 @@ import {
parseAvatar,
defaultAvatarHex,
defaultAvatarColors,
type Avatar as AvatarT,
} from "~/components/primitives/Avatar";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import {
Popover,
PopoverContent,
PopoverCustomTrigger,
PopoverTrigger,
} from "~/components/primitives/Popover";
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { clearCurrentProject } from "~/services/dashboardPreferences.server";
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
import { logger } from "~/services/logger.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon";
import {
OrganizationParamsSchema,
organizationPath,
organizationSettingsPath,
rootPath,
} from "~/utils/pathBuilder";
@@ -79,6 +77,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
slug: true,
title: true,
avatar: true,
onboardingData: true,
},
});
@@ -86,8 +85,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Not found", { status: 404 });
}
const onboardingData = toRecord(organization.onboardingData);
const parsedAvatar = parseAvatar(organization.avatar, defaultAvatar);
const lastIconHex =
parsedAvatar.type === "image" && parsedAvatar.lastIconHex
? parsedAvatar.lastIconHex
: defaultAvatarHex;
return typedjson({
organization: { ...organization, avatar: parseAvatar(organization.avatar, defaultAvatar) },
organization: {
...organization,
avatar: parsedAvatar,
companyUrl: typeof onboardingData.companyUrl === "string" ? onboardingData.companyUrl : "",
lastIconHex,
},
});
};
@@ -102,6 +114,7 @@ export function createSchema(
type: AvatarType,
name: z.string().optional(),
hex: z.string().optional(),
url: z.string().optional(),
}),
z.object({
action: z.literal("rename"),
@@ -199,6 +212,43 @@ export const action: ActionFunction = async ({ request, params }) => {
}
}
case "avatar": {
const orgWhere = {
slug: organizationSlug,
members: { some: { userId: user.id } },
};
if (submission.value.type === "image") {
const url = submission.value.url ?? "";
const domain = url ? extractDomain(url) : null;
const existing = await prisma.organization.findFirst({
where: orgWhere,
select: { avatar: true, onboardingData: true },
});
const existingData = toRecord(existing?.onboardingData);
const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar);
const lastIconHex = extractLastIconHex(existingAvatar);
await prisma.organization.update({
where: orgWhere,
data: {
avatar: {
type: "image",
url: domain ? buildFaviconUrl(domain) : "",
...(lastIconHex ? { lastIconHex } : {}),
},
onboardingData: { ...existingData, companyUrl: url },
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Updated logo`
);
}
const avatar = AvatarData.safeParse(submission.value);
if (!avatar.success) {
@@ -210,14 +260,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}
await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
where: orgWhere,
data: {
avatar: avatar.data,
},
@@ -226,12 +269,13 @@ export const action: ActionFunction = async ({ request, params }) => {
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Updated icon`
`Updated logo`
);
}
}
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "An unexpected error occurred";
return json({ errors: { body: message } }, { status: 400 });
}
};
@@ -374,89 +418,164 @@ export default function Page() {
);
}
function LogoForm({ organization }: { organization: { avatar: Avatar; title: string } }) {
function LogoForm({
organization,
}: {
organization: { avatar: AvatarT; title: string; companyUrl: string; lastIconHex: string };
}) {
const navigation = useNavigation();
const isSubmitting =
navigation.state != "idle" && navigation.formData?.get("action") === "avatar";
const avatar = navigation.formData
? avatarFromFormData(navigation.formData) ?? organization.avatar
: organization.avatar;
const hex = "hex" in avatar ? avatar.hex : defaultAvatarHex;
const hex =
"hex" in avatar
? avatar.hex
: avatar.type === "image" && avatar.lastIconHex
? avatar.lastIconHex
: organization.lastIconHex;
const mode: "logo" | "icon" = avatar.type === "image" ? "logo" : "icon";
const [companyUrl, setCompanyUrl] = useState(organization.companyUrl);
const faviconPreview = useFaviconUrl(companyUrl);
const [faviconError, setFaviconError] = useState(false);
const logoFormRef = useRef<HTMLFormElement>(null);
const submit = useSubmit();
const prevFaviconRef = useRef(faviconPreview);
useEffect(() => {
if (faviconPreview === prevFaviconRef.current) return;
prevFaviconRef.current = faviconPreview;
if (mode === "logo" && logoFormRef.current) {
submit(logoFormRef.current);
}
}, [faviconPreview, mode, submit]);
const showFavicon = faviconPreview && !faviconError;
return (
<Fieldset>
<InputGroup fullWidth>
<Label>Icon</Label>
<div className="flex w-full items-end justify-between gap-2">
<div className="grid place-items-center overflow-hidden rounded-sm border border-charcoal-750 bg-background-bright">
<Avatar avatar={avatar} size={5} includePadding orgName={organization.title} />
</div>
{/* Letters */}
<Form method="post">
<Label>Logo</Label>
<div className="flex flex-col gap-3">
{/* Row 1: Logo from URL */}
<Form ref={logoFormRef} method="post" className="flex items-center gap-3">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="letters" />
<input type="hidden" name="hex" value={hex} />
<button
type="submit"
className={cn(
"box-content grid size-10 place-items-center rounded-sm border-2 bg-charcoal-775",
avatar.type === "letters"
? undefined
: "border-charcoal-775 hover:border-charcoal-600"
)}
style={{
borderColor: avatar.type === "letters" ? hex : undefined,
}}
>
<Avatar
avatar={{
type: "letters",
hex,
}}
size={2.5}
includePadding
orgName={organization.title}
/>
<input type="hidden" name="type" value="image" />
<input type="hidden" name="url" value={companyUrl} />
<button type="submit" className="flex shrink-0 items-center gap-3">
<RadioDot active={mode === "logo"} />
</button>
</Form>
{/* Icons */}
{Object.entries(avatarIcons).map(([name]) => (
<Form key={name} method="post">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="icon" />
<input type="hidden" name="name" value={name} />
<input type="hidden" name="hex" value={hex} />
<div className="flex flex-1 items-center gap-1.5">
<button
type="submit"
className={cn(
"box-content grid size-10 place-items-center rounded-sm border-2 bg-charcoal-775",
avatar.type === "icon" && avatar.name === name
? undefined
iconTileClass,
mode === "logo"
? "border-indigo-500"
: "border-charcoal-775 hover:border-charcoal-600"
)}
style={{
borderColor: avatar.type === "icon" && avatar.name === name ? hex : undefined,
}}
>
<Avatar
key={name}
avatar={{
type: "icon",
name,
hex,
}}
size={2.5}
includePadding
orgName={organization.title}
/>
{showFavicon ? (
<img
src={faviconPreview}
alt=""
width={28}
height={28}
className="rounded-sm"
onError={() => setFaviconError(true)}
onLoad={() => setFaviconError(false)}
/>
) : (
<GlobeAltIcon className="size-6 text-text-dimmed" />
)}
</button>
<Input
type="text"
value={companyUrl}
onChange={(e) => {
setCompanyUrl(e.target.value);
setFaviconError(false);
}}
onFocus={() => {
if (mode !== "logo" && logoFormRef.current) {
submit(logoFormRef.current);
}
}}
placeholder="Enter your company URL to generate a logo"
variant="medium"
containerClassName="flex-1"
/>
</div>
</Form>
{/* Row 2: Icon picker */}
<div className="flex items-center gap-3">
<Form method="post" className="shrink-0">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="letters" />
<input type="hidden" name="hex" value={hex} />
<button type="submit">
<RadioDot active={mode === "icon"} />
</button>
</Form>
))}
{/* Hex */}
<HexPopover avatar={avatar} hex={hex} />
<div className="flex flex-wrap items-center gap-1.5">
{/* Letters */}
<Form method="post">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="letters" />
<input type="hidden" name="hex" value={hex} />
<button
type="submit"
className={cn(
iconTileClass,
avatar.type !== "letters" && "border-charcoal-775 hover:border-charcoal-600"
)}
style={{
borderColor: avatar.type === "letters" ? hex : undefined,
}}
>
<Avatar
avatar={{ type: "letters", hex }}
size={2.5}
includePadding
orgName={organization.title}
/>
</button>
</Form>
{/* Icons */}
{Object.entries(avatarIcons).map(([name]) => (
<Form key={name} method="post">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="icon" />
<input type="hidden" name="name" value={name} />
<input type="hidden" name="hex" value={hex} />
<button
type="submit"
className={cn(
iconTileClass,
!(avatar.type === "icon" && avatar.name === name) &&
"border-charcoal-775 hover:border-charcoal-600"
)}
style={{
borderColor:
avatar.type === "icon" && avatar.name === name ? hex : undefined,
}}
>
<Avatar
avatar={{ type: "icon", name, hex }}
size={2.5}
includePadding
orgName={organization.title}
/>
</button>
</Form>
))}
{/* Color picker */}
<HexPopover avatar={avatar} hex={hex} />
</div>
</div>
</div>
</InputGroup>
</Fieldset>
@@ -466,7 +585,7 @@ function LogoForm({ organization }: { organization: { avatar: Avatar; title: str
function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
return (
<Popover>
<PopoverTrigger className="box-content grid size-10 place-items-center rounded-sm border-2 border-charcoal-775 bg-charcoal-775 hover:border-charcoal-600">
<PopoverTrigger className={cn(iconTileClass, "border-charcoal-775 hover:border-charcoal-600")}>
<img src={colorWheelIcon} className="m-0 block size-[30px] p-0" />
</PopoverTrigger>
<PopoverContent
@@ -474,10 +593,10 @@ function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
<Form method="post" className="flex w-fit min-w-40 flex-col gap-1 ">
<Form method="post" className="flex w-fit min-w-40 flex-col gap-1">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value={avatar.type} />
{"name" in avatar && <input type="hidden" name="name" value={avatar.name} />}
<input type="hidden" name="type" value={avatar.type === "image" ? "letters" : avatar.type} />
{avatar.type === "icon" && <input type="hidden" name="name" value={avatar.name} />}
{defaultAvatarColors.map((color) => (
<Button
key={color.hex}
@@ -511,29 +630,52 @@ function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
);
}
function avatarFromFormData(formData: FormData): Avatar | undefined {
const action = formData.get("action");
if (!action || action !== "avatar") {
return undefined;
}
function RadioDot({ active }: { active: boolean }) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full border-2 p-0.5 transition",
active ? "border-indigo-500" : "border-charcoal-700 hover:border-charcoal-600"
)}
>
<div
className="size-2 rounded-full"
style={{ backgroundColor: active ? "#6366f1" : "transparent" }}
/>
</div>
);
}
const type = formData.get("type");
const hex = formData.get("hex");
const iconTileClass = "box-content grid size-10 shrink-0 place-items-center rounded-sm border-2 bg-charcoal-775";
if (type === "letters") {
return {
type: "letters",
hex: hex as string,
};
}
if (type === "icon") {
return {
type: "icon",
name: formData.get("name") as string,
hex: hex as string,
};
}
function toRecord(json: unknown): Record<string, unknown> {
return json && typeof json === "object" ? (json as Record<string, unknown>) : {};
}
function extractLastIconHex(avatar: AvatarT): string | undefined {
if ("hex" in avatar) return avatar.hex;
if (avatar.type === "image") return avatar.lastIconHex;
return undefined;
}
function avatarFromFormData(formData: FormData): AvatarT | undefined {
const action = formData.get("action");
if (action !== "avatar") return undefined;
const type = formData.get("type");
const hex = formData.get("hex") as string;
switch (type) {
case "letters":
return { type: "letters", hex };
case "icon":
return { type: "icon", name: formData.get("name") as string, hex };
case "image": {
const url = formData.get("url") as string;
const domain = url ? extractDomain(url) : null;
return { type: "image", url: domain ? buildFaviconUrl(domain) : "" };
}
default:
return undefined;
}
}
@@ -0,0 +1,326 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { fromPromise } from "neverthrow";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { DialogClose } from "@radix-ui/react-dialog";
import { SlackIcon } from "@trigger.dev/companyicons";
import { TrashIcon } from "@heroicons/react/20/solid";
import { Button } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header1 } from "~/components/primitives/Headers";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { $transaction, prisma } from "~/db.server";
import { requireOrganization } from "~/services/org.server";
import { OrganizationParamsSchema, organizationSettingsPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { organization } = await requireOrganization(request, organizationSlug);
const slackIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "SLACK",
deletedAt: null,
},
});
if (!slackIntegration) {
return typedjson({
organization,
slackIntegration: null,
alertChannels: [],
teamName: null,
});
}
const integrationData = slackIntegration.integrationData as any;
const teamName = integrationData?.team?.name ?? null;
const alertChannels = await prisma.projectAlertChannel.findMany({
where: {
type: "SLACK",
project: { organizationId: organization.id },
OR: [
{ integrationId: slackIntegration.id },
{
properties: {
path: ["integrationId"],
equals: slackIntegration.id,
},
},
],
},
include: {
project: {
select: {
id: true,
slug: true,
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return typedjson({
organization,
slackIntegration,
alertChannels,
teamName,
});
};
const ActionSchema = z.object({
intent: z.literal("uninstall"),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { organization, userId } = await requireOrganization(request, organizationSlug);
const formData = await request.formData();
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
if (!result.success) {
return json({ error: "Invalid action" }, { status: 400 });
}
const slackIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "SLACK",
deletedAt: null,
},
});
if (!slackIntegration) {
return json({ error: "Slack integration not found" }, { status: 404 });
}
const txResult = await fromPromise(
$transaction(prisma, async (tx) => {
await tx.projectAlertChannel.updateMany({
where: {
type: "SLACK",
OR: [
{ integrationId: slackIntegration.id },
{
properties: {
path: ["integrationId"],
equals: slackIntegration.id,
},
},
],
},
data: {
enabled: false,
integrationId: null,
},
});
await tx.organizationIntegration.update({
where: { id: slackIntegration.id },
data: { deletedAt: new Date() },
});
}),
(error) => error
);
if (txResult.isErr()) {
logger.error("Failed to remove Slack integration", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: slackIntegration.id,
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
});
return json(
{ error: "Failed to remove Slack integration. Please try again." },
{ status: 500 }
);
}
logger.info("Slack integration removed successfully", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: slackIntegration.id,
});
return redirect(organizationSettingsPath({ slug: organizationSlug }));
};
export default function SlackIntegrationPage() {
const { slackIntegration, alertChannels, teamName } =
useTypedLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUninstalling =
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
if (!slackIntegration) {
return (
<PageContainer>
<PageBody>
<div className="flex flex-col items-center justify-center py-8">
<Header1>No Slack Integration Found</Header1>
<Paragraph className="mt-2 text-center text-text-dimmed">
This organization doesn't have a Slack integration configured. You can connect Slack
when setting up alert channels in your project settings.
</Paragraph>
</div>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<PageBody>
<div className="mb-8">
<Header1>Slack Integration</Header1>
<Paragraph className="mt-2 text-text-dimmed">
Manage your organization's Slack integration and connected alert channels.
</Paragraph>
</div>
{/* Integration Info Section */}
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
{teamName && (
<div>
<span className="font-medium">Slack Workspace:</span> {teamName}
</div>
)}
<div>
<span className="font-medium">Installed:</span>{" "}
{formatDate(new Date(slackIntegration.createdAt))}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/medium" LeadingIcon={TrashIcon} disabled={isUninstalling}>
Remove Integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Slack Integration</DialogTitle>
</DialogHeader>
<DialogDescription>
This will remove the Slack integration and disable all connected alert channels.
This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing..." : "Remove Integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
{actionData?.error && (
<Paragraph variant="small" className="text-error">
{actionData.error}
</Paragraph>
)}
</div>
</div>
</div>
{/* Connected Alert Channels Section */}
<div>
<h2 className="mb-4 text-lg font-medium text-text-bright">
Connected Alert Channels ({alertChannels.length})
</h2>
{alertChannels.length === 0 ? (
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
<Paragraph className="text-text-dimmed">
No alert channels are currently connected to this Slack integration.
</Paragraph>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Channel Name</TableHeaderCell>
<TableHeaderCell>Project</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>{channel.name}</TableCell>
<TableCell>{channel.project.name}</TableCell>
<TableCell>
<EnabledStatus enabled={channel.enabled} />
</TableCell>
<TableCell>{formatDate(new Date(channel.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</PageBody>
</PageContainer>
);
}
@@ -28,7 +28,7 @@ import { requireOrganization } from "~/services/org.server";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
import { TrashIcon } from "@heroicons/react/20/solid";
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
import { LinkButton } from "~/components/primitives/Buttons";
function formatDate(date: Date): string {
@@ -354,7 +354,7 @@ export default function VercelIntegrationPage() {
<TableCell>
<LinkButton
variant="minimal/small"
to={v3ProjectSettingsPath(
to={v3ProjectSettingsIntegrationsPath(
organization,
projectIntegration.project,
{ slug: "prod" } // Default to production environment
@@ -1,15 +1,17 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { FolderIcon } from "@heroicons/react/20/solid";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { CommandLineIcon, FolderIcon } from "@heroicons/react/20/solid";
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import type { Prisma } from "@trigger.dev/database";
import React, { useEffect, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { Feedback } from "~/components/Feedback";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { TechnologyPicker } from "~/components/onboarding/TechnologyPicker";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -19,6 +21,7 @@ import { FormTitle } from "~/components/primitives/FormTitle";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Select, SelectItem } from "~/components/primitives/Select";
import { ButtonSpinner } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
@@ -34,6 +37,78 @@ import {
} from "~/utils/pathBuilder";
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
const WORKING_ON_OTHER = "Other/not sure yet";
const GOALS_OTHER = "Other/not sure yet";
const workingOnOptions = [
"AI agent",
"Media processing pipeline",
"Media generation with AI",
"Event-driven workflow",
"Realtime streaming",
"Internal tool or background job",
WORKING_ON_OTHER,
] as const;
const goalOptions = [
"Ship a production workflow",
"Prototype or explore",
"Migrate an existing system",
"Learn how Trigger works",
"Evaluate against alternatives",
GOALS_OTHER,
] as const;
function shuffleArray<T>(arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function MultiSelectField({
value,
setValue,
items,
icon,
}: {
value: string[];
setValue: (value: string[]) => void;
items: string[];
icon: React.ReactNode;
}) {
return (
<Select<string[], string>
value={value}
setValue={setValue}
placeholder="Select some options"
variant="secondary/small"
dropdownIcon
icon={icon}
items={items}
className="h-8 min-w-0 border-0 bg-charcoal-750 pl-2 text-sm text-text-dimmed ring-charcoal-600 transition hover:bg-charcoal-650 hover:text-text-dimmed hover:ring-1"
text={(v) =>
v.length === 0 ? undefined : (
<span className="flex min-w-0 items-center text-text-bright">
<span className="truncate">{v.slice(0, 2).join(", ")}</span>
{v.length > 2 && <span className="ml-1 flex-none">+{v.length - 2} more</span>}
</span>
)
}
>
{(items) =>
items.map((item) => (
<SelectItem key={item} value={item} checkPosition="left">
<span className="text-text-bright">{item}</span>
</SelectItem>
))
}
</Select>
);
}
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
@@ -62,14 +137,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
//if you don't have v3 access, you must select a plan
const { isManagedCloud } = featuresForRequest(request);
if (isManagedCloud && !organization.v3Enabled) {
return redirect(selectPlanPath({ slug: organizationSlug }));
}
const url = new URL(request.url);
const message = url.searchParams.get("message");
return typedjson({
@@ -90,6 +163,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const schema = z.object({
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
projectVersion: z.enum(["v2", "v3"]),
workingOn: z.string().optional(),
workingOnOther: z.string().optional(),
technologies: z.string().optional(),
technologiesOther: z.string().optional(),
goals: z.string().optional(),
goalsOther: z.string().optional(),
});
export const action: ActionFunction = async ({ request, params }) => {
@@ -104,21 +183,54 @@ export const action: ActionFunction = async ({ request, params }) => {
return json(submission);
}
// Check for Vercel integration params in URL
const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
const next = url.searchParams.get("next");
const stringArraySchema = z.array(z.string());
function safeParseStringArray(value: string | undefined): string[] | undefined {
if (!value) return undefined;
try {
const result = stringArraySchema.safeParse(JSON.parse(value));
return result.success && result.data.length > 0 ? result.data : undefined;
} catch {
return undefined;
}
}
const onboardingData: Record<string, Prisma.InputJsonValue> = {};
const workingOn = safeParseStringArray(submission.value.workingOn);
if (workingOn) onboardingData.workingOn = workingOn;
if (submission.value.workingOnOther) {
onboardingData.workingOnOther = submission.value.workingOnOther;
}
const technologies = safeParseStringArray(submission.value.technologies);
if (technologies) onboardingData.technologies = technologies;
const technologiesOther = safeParseStringArray(submission.value.technologiesOther);
if (technologiesOther) onboardingData.technologiesOther = technologiesOther;
const goals = safeParseStringArray(submission.value.goals);
if (goals) onboardingData.goals = goals;
if (submission.value.goalsOther) {
onboardingData.goalsOther = submission.value.goalsOther;
}
try {
const project = await createProject({
organizationSlug: organizationSlug,
name: submission.value.projectName,
userId,
version: submission.value.projectVersion,
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
});
// If this is a Vercel integration flow, generate state and redirect to connect
if (code && configurationId) {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
@@ -195,7 +307,6 @@ export default function Page() {
const [form, { projectName, projectVersion }] = useForm({
id: "create-project",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
@@ -205,10 +316,31 @@ export default function Page() {
const navigation = useNavigation();
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
const [selectedWorkingOn, setSelectedWorkingOn] = useState<string[]>([]);
const [workingOnOther, setWorkingOnOther] = useState("");
const [selectedTechnologies, setSelectedTechnologies] = useState<string[]>([]);
const [customTechnologies, setCustomTechnologies] = useState<string[]>([]);
const [selectedGoals, setSelectedGoals] = useState<string[]>([]);
const [goalsOther, setGoalsOther] = useState("");
const [shuffledWorkingOn, setShuffledWorkingOn] = useState<string[]>([...workingOnOptions]);
const [shuffledGoals, setShuffledGoals] = useState<string[]>([...goalOptions]);
useEffect(() => {
const nonOther = workingOnOptions.filter((o) => o !== WORKING_ON_OTHER);
setShuffledWorkingOn([...shuffleArray(nonOther), WORKING_ON_OTHER]);
const nonOtherGoals = goalOptions.filter((o) => o !== GOALS_OTHER);
setShuffledGoals([...shuffleArray(nonOtherGoals), GOALS_OTHER]);
}, []);
const showWorkingOnOther = selectedWorkingOn.includes(WORKING_ON_OTHER);
const showGoalsOther = selectedGoals.includes(GOALS_OTHER);
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<MainCenteredContainer className="max-w-[29rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<div>
<FormTitle
LeadingIcon={<FolderIcon className="size-7 text-indigo-500" />}
@@ -223,7 +355,9 @@ export default function Page() {
)}
<Fieldset>
<InputGroup>
<Label htmlFor={projectName.id}>Project name</Label>
<Label htmlFor={projectName.id}>
Project name <span className="text-text-bright">*</span>
</Label>
<Input
{...conform.input(projectName, { type: "text" })}
placeholder="Your project name"
@@ -237,6 +371,78 @@ export default function Page() {
) : (
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
)}
<div className="border-t border-charcoal-700" />
<InputGroup>
<Label>What are you working on?</Label>
<input type="hidden" name="workingOn" value={JSON.stringify(selectedWorkingOn)} />
<MultiSelectField
value={selectedWorkingOn}
setValue={setSelectedWorkingOn}
items={shuffledWorkingOn}
icon={<CommandLineIcon className="mr-1 size-4 text-text-dimmed" />}
/>
{showWorkingOnOther && (
<>
<input type="hidden" name="workingOnOther" value={workingOnOther} />
<Input
type="text"
variant="small"
value={workingOnOther}
onChange={(e) => setWorkingOnOther(e.target.value)}
placeholder="Tell us what you're working on"
spellCheck={false}
containerClassName="h-8"
/>
</>
)}
</InputGroup>
<InputGroup>
<Label>What technologies are you using?</Label>
<input
type="hidden"
name="technologies"
value={JSON.stringify(selectedTechnologies)}
/>
<input
type="hidden"
name="technologiesOther"
value={JSON.stringify(customTechnologies)}
/>
<TechnologyPicker
value={selectedTechnologies}
onChange={setSelectedTechnologies}
customValues={customTechnologies}
onCustomValuesChange={setCustomTechnologies}
/>
</InputGroup>
<InputGroup>
<Label>What are you trying to do with Trigger.dev?</Label>
<input type="hidden" name="goals" value={JSON.stringify(selectedGoals)} />
<MultiSelectField
value={selectedGoals}
setValue={setSelectedGoals}
items={shuffledGoals}
icon={<CommandLineIcon className="mr-1 size-4 text-text-dimmed" />}
/>
{showGoalsOther && (
<>
<input type="hidden" name="goalsOther" value={goalsOther} />
<Input
type="text"
variant="small"
value={goalsOther}
onChange={(e) => setGoalsOther(e.target.value)}
placeholder="Tell us what you're trying to do with Trigger.dev"
spellCheck={false}
containerClassName="h-8"
/>
</>
)}
</InputGroup>
<FormButtons
confirmButton={
<Button
+65 -31
View File
@@ -1,10 +1,10 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BuildingOffice2Icon } from "@heroicons/react/20/solid";
import { BuildingOffice2Icon, GlobeAltIcon } from "@heroicons/react/20/solid";
import { RadioGroup } from "@radix-ui/react-radio-group";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { json, redirect, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
@@ -19,18 +19,18 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { TextArea } from "~/components/primitives/TextArea";
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { sendNewOrgMessage } from "~/services/slack.server";
import { extractDomain, faviconUrl } from "~/utils/favicon";
import { organizationPath, rootPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
companySize: z.string().optional(),
whyUseUs: z.string().optional(),
companyUrl: z.string().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -53,23 +53,32 @@ export const action: ActionFunction = async ({ request }) => {
}
try {
const companySize = submission.value.companySize ?? null;
const onboardingData: Record<string, string> = {};
if (submission.value.companyUrl) {
onboardingData.companyUrl = submission.value.companyUrl;
}
if (submission.value.companySize) {
onboardingData.companySize = submission.value.companySize;
}
let avatar: { type: "image"; url: string } | undefined;
if (submission.value.companyUrl) {
const domain = extractDomain(submission.value.companyUrl);
if (domain) {
avatar = { type: "image", url: faviconUrl(domain) };
}
}
const organization = await createOrganization({
title: submission.value.orgName,
userId: user.id,
companySize: submission.value.companySize ?? null,
companySize,
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
avatar,
});
const whyUseUs = formData.get("whyUseUs");
if (whyUseUs) {
await sendNewOrgMessage({
orgName: submission.value.orgName,
whyUseUs: whyUseUs.toString(),
userEmail: user.email,
});
}
// Preserve Vercel integration params if present
const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
@@ -77,7 +86,6 @@ export const action: ActionFunction = async ({ request }) => {
const next = url.searchParams.get("next");
if (code && configurationId && integration === "vercel") {
// Redirect to projects/new with params preserved
const params = new URLSearchParams({
code,
configurationId,
@@ -101,10 +109,12 @@ export default function NewOrganizationPage() {
const lastSubmission = useActionData();
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
const [companyUrl, setCompanyUrl] = useState("");
const faviconUrl = useFaviconUrl(companyUrl);
const [faviconError, setFaviconError] = useState(false);
const [form, { orgName }] = useForm({
id: "create-organization",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
@@ -115,6 +125,21 @@ export default function NewOrganizationPage() {
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
const urlIcon =
faviconUrl && !faviconError ? (
<img
src={faviconUrl}
alt=""
width={16}
height={16}
className="ml-0.5 shrink-0 rounded-sm"
onError={() => setFaviconError(true)}
onLoad={() => setFaviconError(false)}
/>
) : (
GlobeAltIcon
);
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
@@ -126,20 +151,36 @@ export default function NewOrganizationPage() {
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup>
<Label htmlFor={orgName.id}>Organization name</Label>
<Label htmlFor={orgName.id}>Organization name *</Label>
<Input
{...conform.input(orgName, { type: "text" })}
placeholder="Your Organization name"
icon={BuildingOffice2Icon}
autoFocus
/>
<Hint>E.g. your company name or your workspace name.</Hint>
<Hint>Normally your company name.</Hint>
<FormError id={orgName.errorId}>{orgName.error}</FormError>
</InputGroup>
{isManagedCloud && (
<>
<InputGroup>
<Label htmlFor={"companySize"}>Number of employees</Label>
<Label htmlFor="companyUrl">URL</Label>
<Input
id="companyUrl"
name="companyUrl"
type="url"
placeholder="Your Organization URL"
icon={urlIcon}
value={companyUrl}
onChange={(e) => {
setCompanyUrl(e.target.value);
setFaviconError(false);
}}
/>
<Hint>Add your company URL and we'll use it as your organization's logo.</Hint>
</InputGroup>
<InputGroup>
<Label htmlFor="companySize">Number of employees</Label>
<RadioGroup
name="companySize"
className="flex items-center justify-between gap-2"
@@ -174,13 +215,6 @@ export default function NewOrganizationPage() {
/>
</RadioGroup>
</InputGroup>
<InputGroup>
<Label htmlFor={"whyUseUs"}>What problem are you trying to solve?</Label>
<TextArea name="whyUseUs" rows={4} spellCheck={false} />
<Hint>
Your answer will help us understand your use case and provide better support.
</Hint>
</InputGroup>
</>
)}
@@ -192,7 +226,7 @@ export default function NewOrganizationPage() {
}
cancelButton={
hasOrganizations ? (
<LinkButton to={rootPath()} variant={"tertiary/small"}>
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
) : null
@@ -0,0 +1,148 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import {
CreateInputStreamWaitpointRequestBody,
type CreateInputStreamWaitpointResponseBody,
} from "@trigger.dev/core/v3";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import { $replica } from "~/db.server";
import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server";
import {
deleteInputStreamWaitpoint,
setInputStreamWaitpoint,
} from "~/services/inputStreamWaitpointCache.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { parseDelay } from "~/utils/delays";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { engine } from "~/v3/runEngine.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
const ParamsSchema = z.object({
runFriendlyId: z.string(),
});
const { action, loader } = createActionApiRoute(
{
params: ParamsSchema,
body: CreateInputStreamWaitpointRequestBody,
maxContentLength: 1024 * 10, // 10KB
method: "POST",
},
async ({ authentication, body, params }) => {
try {
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: params.runFriendlyId,
runtimeEnvironmentId: authentication.environment.id,
},
select: {
id: true,
friendlyId: true,
realtimeStreamsVersion: true,
},
});
if (!run) {
return json({ error: "Run not found" }, { status: 404 });
}
const idempotencyKeyExpiresAt = body.idempotencyKeyTTL
? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL)
: undefined;
const timeout = await parseDelay(body.timeout);
// Process tags (same pattern as api.v1.waitpoints.tokens.ts)
const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags;
if (bodyTags && bodyTags.length > MAX_TAGS_PER_WAITPOINT) {
throw new ServiceValidationError(
`Waitpoints can only have ${MAX_TAGS_PER_WAITPOINT} tags, you're trying to set ${bodyTags.length}.`
);
}
if (bodyTags && bodyTags.length > 0) {
for (const tag of bodyTags) {
await createWaitpointTag({
tag,
environmentId: authentication.environment.id,
projectId: authentication.environment.projectId,
});
}
}
// Step 1: Create the waitpoint
const result = await engine.createManualWaitpoint({
environmentId: authentication.environment.id,
projectId: authentication.environment.projectId,
idempotencyKey: body.idempotencyKey,
idempotencyKeyExpiresAt,
timeout,
tags: bodyTags,
});
// Step 2: Cache the mapping in Redis for fast lookup from .send()
const ttlMs = timeout ? timeout.getTime() - Date.now() : undefined;
await setInputStreamWaitpoint(
run.friendlyId,
body.streamId,
result.waitpoint.id,
ttlMs && ttlMs > 0 ? ttlMs : undefined
);
// Step 3: Check if data was already sent to this input stream (race condition handling).
// If .send() landed before .wait(), the data is in the S2 stream but no waitpoint
// existed to complete. We check from the client's last known position.
if (!result.isCached) {
try {
const realtimeStream = getRealtimeStreamInstance(
authentication.environment,
run.realtimeStreamsVersion
);
const records = await realtimeStream.readRecords(
run.friendlyId,
`$trigger.input:${body.streamId}`,
body.lastSeqNum
);
if (records.length > 0) {
const record = records[0]!;
// Record data is the raw user payload — no wrapper to unwrap
await engine.completeWaitpoint({
id: result.waitpoint.id,
output: {
value: record.data,
type: "application/json",
isError: false,
},
});
// Clean up the Redis cache since we completed it ourselves
await deleteInputStreamWaitpoint(run.friendlyId, body.streamId);
}
} catch {
// Non-fatal: if the S2 check fails, the waitpoint is still PENDING.
// The next .send() will complete it via the Redis cache path.
}
}
return json<CreateInputStreamWaitpointResponseBody>({
waitpointId: WaitpointId.toFriendlyId(result.waitpoint.id),
isCached: result.isCached,
});
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json({ error: error.message }, { status: 500 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
export { action, loader };
@@ -166,7 +166,7 @@ async function responseHeaders(
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:runs:${run.friendlyId}`],
scopes: [`read:runs:${run.friendlyId}`, `write:inputStreams:${run.friendlyId}`],
realtime,
};
@@ -99,11 +99,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
// Check for stream parsing errors
if (
error.message.includes("Invalid JSON") ||
error.message.includes("exceeds maximum size")
) {
// Check for stream parsing errors (e.g. invalid JSON)
if (error.message.includes("Invalid JSON")) {
return json({ error: error.message }, { status: 400 });
}
+165 -30
View File
@@ -1,11 +1,12 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ArrowRightIcon, EnvelopeIcon, HeartIcon, UserIcon } from "@heroicons/react/20/solid";
import { ArrowRightIcon, EnvelopeIcon, UserGroupIcon, UserIcon } from "@heroicons/react/20/solid";
import { HandRaisedIcon } from "@heroicons/react/24/solid";
import { ActionFunction, json } from "@remix-run/node";
import { RadioGroup } from "@radix-ui/react-radio-group";
import { json, type ActionFunction } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { motion } from "framer-motion";
import { forwardRef, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import { z } from "zod";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
@@ -18,6 +19,8 @@ import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { Select, SelectItem } from "~/components/primitives/Select";
import { prisma } from "~/db.server";
import { useFeatures } from "~/hooks/useFeatures";
import { useUser } from "~/hooks/useUser";
@@ -27,6 +30,40 @@ import { requireUserId } from "~/services/session.server";
import { rootPath } from "~/utils/pathBuilder";
import { getVercelInstallParams } from "~/v3/vercel";
const referralSourceOptions = [
"Search engine",
"YouTube",
"Twitter/X",
"LinkedIn",
"Word of mouth",
"AI assistant/LLM",
"Blog/article",
"Event",
"Other",
] as const;
const roleOptions = [
"Founder",
"Staff/principal engineer",
"Senior software engineer",
"Software engineer",
"AI/ML engineer",
"Engineering manager",
"Product engineer",
"Non technical builder using AI tools",
"Student/learner",
"Other",
] as const;
function shuffleArray<T>(arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
@@ -40,13 +77,11 @@ function createSchema(
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
@@ -61,6 +96,9 @@ function createSchema(
}),
confirmEmail: z.string(),
referralSource: z.string().optional(),
referralSourceOther: z.string().optional(),
role: z.string().optional(),
roleOther: z.string().optional(),
})
.refine((value) => value.email === value.confirmEmail, {
message: "Emails must match",
@@ -99,19 +137,39 @@ export const action: ActionFunction = async ({ request }) => {
}
try {
const updatedUser = await updateUser({
const onboardingData: Record<string, string | undefined> = {};
if (submission.value.referralSource) {
onboardingData.referralSource = submission.value.referralSource;
if (submission.value.referralSource === "Other" && submission.value.referralSourceOther) {
onboardingData.referralSourceOther = submission.value.referralSourceOther;
}
}
if (submission.value.role) {
onboardingData.role = submission.value.role;
if (submission.value.role === "Other" && submission.value.roleOther) {
onboardingData.roleOther = submission.value.roleOther;
}
}
const referralSourceForLegacy =
submission.value.referralSource === "Other" && submission.value.referralSourceOther
? `Other: ${submission.value.referralSourceOther}`
: submission.value.referralSource;
await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
referralSource: submission.value.referralSource,
referralSource: referralSourceForLegacy,
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
});
// Preserve Vercel integration params if present
const vercelParams = getVercelInstallParams(request);
let redirectUrl = rootPath();
if (vercelParams) {
// Redirect to orgs/new with params preserved
const params = new URLSearchParams({
code: vercelParams.code,
configurationId: vercelParams.configurationId,
@@ -143,10 +201,24 @@ export default function Page() {
const lastSubmission = useActionData();
const [enteredEmail, setEnteredEmail] = useState<string>(user.email ?? "");
const { isManagedCloud } = useFeatures();
const [selectedReferralSource, setSelectedReferralSource] = useState<string | undefined>();
const [selectedRole, setSelectedRole] = useState<string>("");
const [form, { name, email, confirmEmail, referralSource }] = useForm({
const [shuffledReferralSources, setShuffledReferralSources] = useState<string[]>([
...referralSourceOptions,
]);
const [shuffledRoles, setShuffledRoles] = useState<string[]>([...roleOptions]);
useEffect(() => {
const nonOtherReferral = referralSourceOptions.filter((r) => r !== "Other");
setShuffledReferralSources([...shuffleArray(nonOtherReferral), "Other"]);
const nonOtherRoles = roleOptions.filter((r) => r !== "Other");
setShuffledRoles([...shuffleArray(nonOtherRoles), "Other"]);
}, []);
const [form, { name, email, confirmEmail }] = useForm({
id: "confirm-basic-details",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
@@ -159,7 +231,7 @@ export default function Page() {
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<MainCenteredContainer className="max-w-[29rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<Form method="post" {...form.props}>
<FormTitle
title="Welcome to Trigger.dev"
@@ -187,7 +259,9 @@ export default function Page() {
/>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Label htmlFor={name.id}>
Full name <span className="text-text-bright">*</span>
</Label>
<Input
{...conform.input(name, { type: "text" })}
defaultValue={user.name ?? ""}
@@ -195,11 +269,12 @@ export default function Page() {
icon={UserIcon}
autoFocus
/>
<Hint>Your team will see this name and we'll use it to contact you.</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email</Label>
<Label htmlFor={email.id}>
Email <span className="text-text-bright">*</span>
</Label>
<Input
{...conform.input(email, { type: "email" })}
defaultValue={enteredEmail}
@@ -210,9 +285,6 @@ export default function Page() {
icon={EnvelopeIcon}
spellCheck={false}
/>
{!shouldShowConfirm && (
<Hint>Confirm this is the email you'd like for your Trigger.dev account.</Hint>
)}
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
@@ -225,9 +297,6 @@ export default function Page() {
icon={EnvelopeIcon}
spellCheck={false}
/>
<Hint>
Check this is the email you'd like associated with your Trigger.dev account.
</Hint>
<FormError id={confirmEmail.errorId}>{confirmEmail.error}</FormError>
</InputGroup>
) : (
@@ -235,16 +304,82 @@ export default function Page() {
<input {...conform.input(confirmEmail, { type: "hidden" })} value={user.email} />
</>
)}
{isManagedCloud && (
<InputGroup>
<Label htmlFor={confirmEmail.id}>How did you hear about us?</Label>
<Input
{...conform.input(referralSource, { type: "text" })}
placeholder="LLM, Google, X (Twitter)…?"
icon={HeartIcon}
spellCheck={false}
/>
</InputGroup>
<>
<div className="border-t border-charcoal-700" />
<InputGroup>
<Label className="mb-0.5" id="referral-label">
How did you hear about us?
</Label>
<input
type="hidden"
name="referralSource"
value={selectedReferralSource ?? ""}
/>
<RadioGroup
value={selectedReferralSource}
onValueChange={setSelectedReferralSource}
className="flex flex-wrap gap-2"
aria-labelledby="referral-label"
>
{shuffledReferralSources.map((option) => (
<RadioGroupItem
key={option}
id={`referral-${option}`}
label={option}
value={option}
variant="button/small"
/>
))}
</RadioGroup>
{selectedReferralSource === "Other" && (
<div className="mt-2">
<Input
name="referralSourceOther"
type="text"
placeholder="What was the source?"
spellCheck={false}
/>
</div>
)}
</InputGroup>
<InputGroup className="mt-1">
<Label id="role-label">What role fits you best?</Label>
<input type="hidden" name="role" value={selectedRole} />
<Select<string, string>
value={selectedRole}
setValue={setSelectedRole}
placeholder="Select an option"
aria-labelledby="role-label"
variant="secondary/small"
dropdownIcon
icon={<UserGroupIcon className="mr-1 size-4.5 text-text-dimmed" />}
items={shuffledRoles}
className="h-8 min-w-0 border-0 bg-charcoal-750 pl-2 text-sm text-text-dimmed ring-charcoal-600 transition hover:bg-charcoal-650 hover:text-text-dimmed hover:ring-1"
text={(v) => (v ? <span className="text-text-bright">{v}</span> : undefined)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item} value={item}>
<span className="text-text-bright">{item}</span>
</SelectItem>
))
}
</Select>
{selectedRole === "Other" && (
<div>
<Input
name="roleOther"
type="text"
placeholder="What's your role?"
spellCheck={false}
/>
</div>
)}
</InputGroup>
</>
)}
<FormButtons
@@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import { ProjectParamSchema, v3ProjectSettingsPath } from "~/utils/pathBuilder";
import { ProjectParamSchema, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
@@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const selector = new SelectBestEnvironmentPresenter();
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
return redirect(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment));
return redirect(v3ProjectSettingsGeneralPath({ slug: organizationSlug }, project, environment));
};
@@ -0,0 +1,174 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import {
getInputStreamWaitpoint,
deleteInputStreamWaitpoint,
} from "~/services/inputStreamWaitpointCache.server";
import {
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { engine } from "~/v3/runEngine.server";
const ParamsSchema = z.object({
runId: z.string(),
streamId: z.string(),
});
const BodySchema = z.object({
data: z.unknown(),
});
// POST: Send data to an input stream
const { action } = createActionApiRoute(
{
params: ParamsSchema,
maxContentLength: 1024 * 1024, // 1MB max
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "write",
resource: (params) => ({ inputStreams: params.runId }),
superScopes: ["write:inputStreams", "write:all", "admin"],
},
},
async ({ request, params, authentication }) => {
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: authentication.environment.id,
},
select: {
id: true,
friendlyId: true,
completedAt: true,
realtimeStreamsVersion: true,
},
});
if (!run) {
return json({ ok: false, error: "Run not found" }, { status: 404 });
}
if (run.completedAt) {
return json(
{ ok: false, error: "Cannot send to input stream on a completed run" },
{ status: 400 }
);
}
const body = BodySchema.safeParse(await request.json());
if (!body.success) {
return json({ ok: false, error: "Invalid request body" }, { status: 400 });
}
const realtimeStream = getRealtimeStreamInstance(
authentication.environment,
run.realtimeStreamsVersion
);
// Build the input stream record (raw user data, no wrapper)
const recordId = `inp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const record = JSON.stringify(body.data.data);
// Append the record to the per-stream S2 stream (auto-creates on first write)
await realtimeStream.appendPart(
record,
recordId,
run.friendlyId,
`$trigger.input:${params.streamId}`
);
// Check Redis cache for a linked .wait() waitpoint (fast, no DB hit if none)
// Get first, complete, then delete — so the mapping survives if completeWaitpoint throws
const waitpointId = await getInputStreamWaitpoint(params.runId, params.streamId);
if (waitpointId) {
await engine.completeWaitpoint({
id: waitpointId,
output: {
value: JSON.stringify(body.data.data),
type: "application/json",
isError: false,
},
});
await deleteInputStreamWaitpoint(params.runId, params.streamId);
}
return json({ ok: true });
}
);
// GET: SSE stream for reading input stream data (used by the in-task SSE tail)
const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async (params, auth) => {
return $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: auth.environment.id,
},
include: {
batch: {
select: {
friendlyId: true,
},
},
},
});
},
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, request, resource: run, authentication }) => {
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
const timeoutInSeconds =
timeoutInSecondsRaw !== undefined ? parseInt(timeoutInSecondsRaw, 10) : undefined;
if (timeoutInSeconds !== undefined && isNaN(timeoutInSeconds)) {
return new Response("Invalid timeout seconds", { status: 400 });
}
if (timeoutInSeconds !== undefined && timeoutInSeconds < 1) {
return new Response("Timeout seconds must be greater than 0", { status: 400 });
}
if (timeoutInSeconds !== undefined && timeoutInSeconds > 600) {
return new Response("Timeout seconds must be less than 600", { status: 400 });
}
const realtimeStream = getRealtimeStreamInstance(
authentication.environment,
run.realtimeStreamsVersion
);
// Read from the internal S2 stream name (prefixed to avoid user stream collisions)
return realtimeStream.streamResponse(
request,
run.friendlyId,
`$trigger.input:${params.streamId}`,
request.signal,
{
lastEventId,
timeoutInSeconds,
}
);
}
);
export { action, loader };
+7 -3
View File
@@ -173,6 +173,7 @@ export function MetricWidget({
const [response, setResponse] = useState<MetricWidgetActionResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const isDirtyRef = useRef(false);
// Track the latest props so the submit callback always uses fresh values
// without needing to be recreated (which would cause useInterval to re-register listeners).
@@ -180,8 +181,11 @@ export function MetricWidget({
propsRef.current = props;
const submit = useCallback(() => {
// Skip fetching if the widget is not visible on screen
if (!isVisibleRef.current) return;
if (!isVisibleRef.current) {
isDirtyRef.current = true;
return;
}
isDirtyRef.current = false;
// Abort any in-flight request for this widget
abortControllerRef.current?.abort();
@@ -225,7 +229,7 @@ export function MetricWidget({
// When a widget scrolls into view and has no data yet, trigger a load.
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
onVisibilityChange: (visible) => {
if (visible && !response) {
if (visible && (!response || isDirtyRef.current)) {
submit();
}
},
@@ -43,7 +43,7 @@ import { requireUserId } from "~/services/session.server";
import {
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
v3ProjectSettingsIntegrationsPath,
} from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import { type BranchTrackingConfig } from "~/v3/github";
@@ -459,7 +459,7 @@ export function ConnectGitHubRepoModal({
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
`${v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
@@ -567,7 +567,7 @@ export function GitHubConnectionPrompt({
redirectUrl?: string;
}) {
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
return (
<Fieldset>
<InputGroup fullWidth>
@@ -58,6 +58,7 @@ import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/Ru
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { RunTag } from "~/components/runs/v3/RunTag";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptStatus";
@@ -126,7 +127,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
runParam,
spanParam,
error,
linkedRunId,
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
cause:
error.cause instanceof Error
? { name: error.cause.name, message: error.cause.message }
: error.cause,
}
: error,
});
return redirectWithErrorMessage(
v3RunPath(
@@ -992,7 +1005,7 @@ function RunBody({
)}
</div>
</div>
<div className="flex items-center flex-wrap py-2 justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-grid-dimmed px-2 py-2">
<div className="flex items-center gap-4">
{run.friendlyId !== runParam && (
<LinkButton
@@ -1036,9 +1049,11 @@ function RunBody({
</PopoverTrigger>
<PopoverContent className="min-w-[140px] p-1" align="end">
<PopoverMenuItem
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
new Date(run.createdAt).getTime() - 60000
}`}
to={`${v3LogsPath(
organization,
project,
environment
)}?runId=${runParam}&from=${new Date(run.createdAt).getTime() - 60000}`}
title="View logs"
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
@@ -1189,50 +1204,6 @@ function SpanEntity({ span }: { span: Span }) {
<Property.Label>Message</Property.Label>
<Property.Value className="whitespace-pre-wrap">{span.message}</Property.Value>
</Property.Item>
{span.triggeredRuns.length > 0 && (
<Property.Item>
<div className="flex flex-col gap-1.5">
<Header3>Triggered runs</Header3>
<Table containerClassName="max-h-[12.5rem]">
<TableHeader className="bg-background-bright">
<TableRow>
<TableHeaderCell>Run #</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{span.triggeredRuns.map((run) => {
const path = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
);
return (
<TableRow key={run.friendlyId}>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
{run.number}
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
{run.taskIdentifier}
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
{run.taskVersion ?? ""}
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
<DateTime date={run.createdAt} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</Property.Item>
)}
</Property.Table>
{span.events.length > 0 && <SpanEvents spanEvents={span.events} />}
{span.properties !== undefined ? (
@@ -1257,6 +1228,48 @@ function SpanEntity({ span }: { span: Span }) {
showOpenInModal
/>
) : null}
{span.triggeredRuns.length > 0 && (
<div className="flex flex-col gap-1.5">
<Header3>Runs</Header3>
<Table containerClassName="max-h-[12.5rem]">
<TableHeader className="bg-background-bright">
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{span.triggeredRuns.map((run) => {
const path = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
);
return (
<TableRow key={run.friendlyId}>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
<TruncatedCopyableValue value={run.friendlyId} />
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
{run.taskIdentifier}
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
<TaskRunStatusCombo status={run.status} />
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
<DateTimeAccurate date={run.createdAt} hour12={false} hideDate={true} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}
@@ -1335,6 +1348,7 @@ function SpanEntity({ span }: { span: Span }) {
runId={span.entity.object.runId}
streamKey={span.entity.object.streamKey}
metadata={span.entity.object.metadata}
displayName={span.entity.object.displayName}
/>
);
}
@@ -98,10 +98,12 @@ export function RealtimeStreamViewer({
runId,
streamKey,
metadata,
displayName,
}: {
runId: string;
streamKey: string;
metadata: Record<string, unknown> | undefined;
displayName?: string;
}) {
const organization = useOrganization();
const project = useProject();
@@ -244,8 +246,8 @@ export function RealtimeStreamViewer({
variant="small/bright"
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
>
<span>Stream:</span>
<span className="truncate font-mono text-text-dimmed">{streamKey}</span>
<span>{displayName ? "Input stream:" : "Stream:"}</span>
<span className="truncate font-mono text-text-dimmed">{displayName ?? streamKey}</span>
</Paragraph>
</div>
<div className="flex w-full flex-wrap items-center justify-between gap-3 @[300px]:w-auto @[300px]:flex-nowrap">
@@ -487,6 +489,9 @@ function useRealtimeStream(resourcePath: string, startIndex?: number) {
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
setChunks([]);
setError(null);
const abortController = new AbortController();
let reader: ReadableStreamDefaultReader<SSEStreamPart<unknown>> | null = null;
@@ -44,7 +44,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import {
VercelSettingsPresenter,
type VercelOnboardingData,
@@ -224,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json(submission);
}
const settingsPath = v3ProjectSettingsPath(
const settingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
@@ -244,6 +244,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
// Get the previous staging environment before updating
const previousIntegration = await vercelService.getVercelProjectIntegration(project.id);
const previousStagingEnvId =
previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment?.environmentId ?? null;
const newStagingEnvId = parsedStagingEnv?.environmentId ?? null;
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
atomicBuilds,
pullEnvVarsBeforeBuild,
@@ -252,6 +258,15 @@ export async function action({ request, params }: ActionFunctionArgs) {
});
if (result) {
// Sync staging TRIGGER_SECRET_KEY if the custom environment changed
if (previousStagingEnvId !== newStagingEnvId) {
await vercelService.syncStagingKeyForCustomEnvironment(
project.id,
previousStagingEnvId,
newStagingEnvId
);
}
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
}
@@ -321,6 +336,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
});
if (result) {
// During onboarding there's no previous custom environment — just upsert
await vercelService.syncStagingKeyForCustomEnvironment(
project.id,
null,
parsedStagingEnv?.environmentId ?? null
);
return json({ success: true });
}
@@ -210,7 +210,7 @@ const pricingDefinitions = {
},
additionalRealtimeConnections: {
title: "Additional Realtime connections",
content: "Then $10/month per 100",
content: "Then $10/month per 1000",
},
additionalSeats: {
title: "Additional seats",
+2 -2
View File
@@ -7,7 +7,7 @@ import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercel
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
const VercelConnectSchema = z.object({
@@ -139,7 +139,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw new Response("Environment not found", { status: 404 });
}
const settingsPath = v3ProjectSettingsPath(
const settingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: stateData.organizationSlug },
{ slug: stateData.projectSlug },
{ slug: environment.slug }
+10
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { logger } from "~/services/logger.server";
import { loopsClient } from "~/services/loops.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
import { findProjectBySlug } from "~/models/project.server";
@@ -65,6 +66,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
projectSlug: project_slug,
});
// Send Loops.so event (fire-and-forget, don't block the redirect)
loopsClient
?.vercelIntegrationStarted({
userId: user.id,
email: user.email,
name: user.name,
})
.catch(() => {});
// Generate Vercel install URL
const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
@@ -79,11 +79,21 @@ export class IdempotencyKeyConcern {
}
// We have an idempotent run, so we return it
const associatedWaitpoint = existingRun.associatedWaitpoint;
const parentRunId = request.body.options?.parentRunId;
const resumeParentOnCompletion = request.body.options?.resumeParentOnCompletion;
//We're using `andWait` so we need to block the parent run with a waitpoint
if (associatedWaitpoint && resumeParentOnCompletion && parentRunId) {
if (resumeParentOnCompletion && parentRunId) {
// Get or create waitpoint lazily (existing run may not have one if it was standalone)
let associatedWaitpoint = existingRun.associatedWaitpoint;
if (!associatedWaitpoint) {
associatedWaitpoint = await this.engine.getOrCreateRunWaitpoint({
runId: existingRun.id,
projectId: request.environment.projectId,
environmentId: request.environment.id,
});
}
await this.traceEventConcern.traceIdempotentRun(
request,
parentStore,
@@ -98,13 +108,13 @@ export class IdempotencyKeyConcern {
request.options?.parentAsLinkType === "replay"
? event.spanId
: event.traceparent?.spanId
? `${event.traceparent.spanId}:${event.spanId}`
: event.spanId;
? `${event.traceparent.spanId}:${event.spanId}`
: event.spanId;
//block run with waitpoint
await this.engine.blockRunWithWaitpoint({
runId: RunId.fromFriendlyId(parentRunId),
waitpoints: associatedWaitpoint.id,
waitpoints: associatedWaitpoint!.id,
spanIdToComplete: spanId,
batch: request.options?.batchId
? {
@@ -15,6 +15,22 @@ import type { RunEngine } from "~/v3/runEngine.server";
import { env } from "~/env.server";
import { tryCatch } from "@trigger.dev/core/v3";
import { ServiceValidationError } from "~/v3/services/common.server";
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
import { singleton } from "~/utils/singleton";
// LRU cache for environment queue sizes to reduce Redis calls
const queueSizeCache = singleton("queueSizeCache", () => {
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(env.QUEUE_SIZE_CACHE_MAX_SIZE, "queue-size-cache");
return createCache({
queueSize: new Namespace<number>(ctx, {
stores: [memory],
fresh: env.QUEUE_SIZE_CACHE_TTL_MS,
stale: env.QUEUE_SIZE_CACHE_TTL_MS + 1000,
}),
});
});
/**
* Extract the queue name from a queue option that may be:
@@ -49,7 +65,7 @@ export class DefaultQueueManager implements QueueManager {
constructor(
private readonly prisma: PrismaClientOrTransaction,
private readonly engine: RunEngine
) {}
) { }
async resolveQueueProperties(
request: TriggerTaskRequest,
@@ -75,8 +91,7 @@ export class DefaultQueueManager implements QueueManager {
if (!specifiedQueue) {
throw new ServiceValidationError(
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${
lockedBackgroundWorker.version ?? "<unknown>"
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${lockedBackgroundWorker.version ?? "<unknown>"
}'.`
);
}
@@ -98,8 +113,7 @@ export class DefaultQueueManager implements QueueManager {
if (!lockedTask) {
throw new ServiceValidationError(
`Task '${request.taskId}' not found on locked version '${
lockedBackgroundWorker.version ?? "<unknown>"
`Task '${request.taskId}' not found on locked version '${lockedBackgroundWorker.version ?? "<unknown>"
}'.`
);
}
@@ -113,8 +127,7 @@ export class DefaultQueueManager implements QueueManager {
version: lockedBackgroundWorker.version,
});
throw new ServiceValidationError(
`Default queue configuration for task '${request.taskId}' missing on locked version '${
lockedBackgroundWorker.version ?? "<unknown>"
`Default queue configuration for task '${request.taskId}' missing on locked version '${lockedBackgroundWorker.version ?? "<unknown>"
}'.`
);
}
@@ -210,12 +223,19 @@ export class DefaultQueueManager implements QueueManager {
async validateQueueLimits(
environment: AuthenticatedEnvironment,
queueName: string,
itemsToAdd?: number
): Promise<QueueValidationResult> {
const queueSizeGuard = await guardQueueSizeLimitsForEnv(this.engine, environment, itemsToAdd);
const queueSizeGuard = await guardQueueSizeLimitsForQueue(
this.engine,
environment,
queueName,
itemsToAdd
);
logger.debug("Queue size guard result", {
queueSizeGuard,
queueName,
environment: {
id: environment.id,
type: environment.type,
@@ -263,7 +283,7 @@ export class DefaultQueueManager implements QueueManager {
}
}
function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): number | undefined {
export function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): number | undefined {
if (environment.type === "DEVELOPMENT") {
return environment.organization.maximumDevQueueSize ?? env.MAXIMUM_DEV_QUEUE_SIZE;
} else {
@@ -271,9 +291,10 @@ function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): nu
}
}
async function guardQueueSizeLimitsForEnv(
async function guardQueueSizeLimitsForQueue(
engine: RunEngine,
environment: AuthenticatedEnvironment,
queueName: string,
itemsToAdd: number = 1
) {
const maximumSize = getMaximumSizeForEnvironment(environment);
@@ -282,7 +303,7 @@ async function guardQueueSizeLimitsForEnv(
return { isWithinLimits: true };
}
const queueSize = await engine.lengthOfEnvQueue(environment);
const queueSize = await getCachedQueueSize(engine, environment, queueName);
const projectedSize = queueSize + itemsToAdd;
return {
@@ -291,3 +312,20 @@ async function guardQueueSizeLimitsForEnv(
queueSize,
};
}
async function getCachedQueueSize(
engine: RunEngine,
environment: AuthenticatedEnvironment,
queueName: string
): Promise<number> {
if (!env.QUEUE_SIZE_CACHE_ENABLED) {
return engine.lengthOfQueue(environment, queueName);
}
const cacheKey = `${environment.id}:${queueName}`;
const result = await queueSizeCache.queueSize.swr(cacheKey, async () => {
return engine.lengthOfQueue(environment, queueName);
});
return result.val ?? 0;
}
@@ -5,6 +5,7 @@ import {
type IOPacket,
packetRequiresOffloading,
parsePacket,
TaskRunErrorCodes,
} from "@trigger.dev/core/v3";
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
@@ -15,12 +16,11 @@ import { env } from "~/env.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { batchTriggerWorker } from "~/v3/batchTriggerWorker.server";
import { DefaultQueueManager } from "../concerns/queues.server";
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../../v3/r2.server";
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
import { TriggerTaskService } from "../../v3/services/triggerTask.server";
import { startActiveSpan } from "../../v3/tracer.server";
import { TriggerFailedTaskService } from "./triggerFailedTask.server";
const PROCESSING_BATCH_SIZE = 50;
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
@@ -56,8 +56,6 @@ export type BatchTriggerTaskServiceOptions = {
export class RunEngineBatchTriggerService extends WithRunEngine {
private _batchProcessingStrategy: BatchProcessingStrategy;
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
private readonly queueConcern: DefaultQueueManager;
private readonly validator: DefaultTriggerTaskValidator;
constructor(
batchProcessingStrategy?: BatchProcessingStrategy,
@@ -65,9 +63,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
) {
super({ prisma });
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine);
this.validator = new DefaultTriggerTaskValidator();
// Eric note: We need to force sequential processing because when doing parallel, we end up with high-contention on the parent run lock
// becuase we are triggering a lot of runs at once, and each one is trying to lock the parent run.
// by forcing sequential, we are only ever locking the parent run for a single run at a time.
@@ -88,18 +83,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
span.setAttribute("batchId", friendlyId);
// Validate entitlement and extract planType for batch runs
const entitlementValidation = await this.validator.validateEntitlement({
environment,
});
if (!entitlementValidation.ok) {
throw entitlementValidation.error;
}
// Extract plan type from entitlement response
const planType = entitlementValidation.plan?.type;
// Upload to object store
const payloadPacket = await this.#handlePayloadPacket(
body.items,
@@ -112,8 +95,7 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
payloadPacket,
environment,
body,
options,
planType
options
);
if (!batch) {
@@ -166,8 +148,7 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
payloadPacket: IOPacket,
environment: AuthenticatedEnvironment,
body: BatchTriggerTaskV2RequestBody,
options: BatchTriggerTaskServiceOptions = {},
planType?: string
options: BatchTriggerTaskServiceOptions = {}
) {
if (body.items.length <= ASYNC_BATCH_PROCESS_SIZE_THRESHOLD) {
const batch = await this._prisma.batchTaskRun.create({
@@ -206,7 +187,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options,
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
planType,
});
switch (result.status) {
@@ -236,7 +216,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: "sequential",
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
planType,
});
return batch;
@@ -259,7 +238,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: "sequential",
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
planType,
});
return batch;
@@ -303,7 +281,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: this._batchProcessingStrategy,
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
planType,
});
break;
@@ -326,7 +303,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: this._batchProcessingStrategy,
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
planType,
})
)
);
@@ -430,7 +406,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options: $options,
parentRunId: options.parentRunId,
resumeParentOnCompletion: options.resumeParentOnCompletion,
planType: options.planType,
});
switch (result.status) {
@@ -464,7 +439,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: options.strategy,
parentRunId: options.parentRunId,
resumeParentOnCompletion: options.resumeParentOnCompletion,
planType: options.planType,
});
}
@@ -492,7 +466,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: options.strategy,
parentRunId: options.parentRunId,
resumeParentOnCompletion: options.resumeParentOnCompletion,
planType: options.planType,
});
} else {
await this.#enqueueBatchTaskRun({
@@ -509,7 +482,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
strategy: options.strategy,
parentRunId: options.parentRunId,
resumeParentOnCompletion: options.resumeParentOnCompletion,
planType: options.planType,
});
}
@@ -527,7 +499,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options,
parentRunId,
resumeParentOnCompletion,
planType,
}: {
batch: BatchTaskRun;
environment: AuthenticatedEnvironment;
@@ -537,7 +508,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options?: BatchTriggerTaskServiceOptions;
parentRunId?: string | undefined;
resumeParentOnCompletion?: boolean | undefined;
planType?: string;
}): Promise<
| { status: "COMPLETE" }
| { status: "INCOMPLETE"; workingIndex: number }
@@ -546,35 +516,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
// Grab the next PROCESSING_BATCH_SIZE items
const itemsToProcess = items.slice(currentIndex, currentIndex + batchSize);
const newRunCount = await this.#countNewRuns(environment, itemsToProcess);
// Only validate queue size if we have new runs to create, i.e. they're not all cached
if (newRunCount > 0) {
const queueSizeGuard = await this.queueConcern.validateQueueLimits(environment, newRunCount);
logger.debug("Queue size guard result for chunk", {
batchId: batch.friendlyId,
currentIndex,
runCount: batch.runCount,
newRunCount,
queueSizeGuard,
});
if (!queueSizeGuard.ok) {
return {
status: "ERROR",
error: `Cannot trigger ${newRunCount} new tasks as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`,
workingIndex: currentIndex,
};
}
} else {
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] All runs are cached", {
batchId: batch.friendlyId,
currentIndex,
runCount: batch.runCount,
});
}
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Processing batch items", {
batchId: batch.friendlyId,
currentIndex,
@@ -585,7 +526,14 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
let runIds: string[] = [];
const triggerFailedTaskService = new TriggerFailedTaskService({
prisma: this._prisma,
engine: this._engine,
});
for (const item of itemsToProcess) {
let runFriendlyId: string | null = null;
try {
const run = await this.#processBatchTaskRunItem({
batch,
@@ -595,34 +543,58 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options,
parentRunId,
resumeParentOnCompletion,
planType,
});
if (!run) {
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to process item", {
if (run) {
runFriendlyId = run.friendlyId;
}
} catch (error) {
// Trigger failed - will try to create pre-failed run below
runFriendlyId = null;
}
if (!runFriendlyId) {
const errorMessage =
"Trigger failed for batch item (queue limit, entitlement, or validation error)";
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Item trigger failed, creating pre-failed run", {
batchId: batch.friendlyId,
currentIndex: workingIndex,
task: item.task,
});
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload,
payloadType: item.options?.payloadType,
errorMessage,
parentRunId,
resumeParentOnCompletion,
batch: { id: batch.id, index: workingIndex },
options: item.options as Record<string, unknown>,
traceContext: options?.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: options?.spanParentAsLink,
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
});
if (failedRunId) {
runFriendlyId = failedRunId;
} else {
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to create pre-failed run", {
batchId: batch.friendlyId,
currentIndex: workingIndex,
});
throw new Error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to process item");
return {
status: "ERROR",
error: "Could not trigger item and could not create pre-failed run",
workingIndex,
};
}
runIds.push(run.friendlyId);
workingIndex++;
} catch (error) {
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Failed to process item", {
batchId: batch.friendlyId,
currentIndex: workingIndex,
error,
});
return {
status: "ERROR",
error: error instanceof Error ? error.message : String(error),
workingIndex,
};
}
runIds.push(runFriendlyId);
workingIndex++;
}
//add the run ids to the batch
@@ -671,7 +643,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options,
parentRunId,
resumeParentOnCompletion,
planType,
}: {
batch: BatchTaskRun;
environment: AuthenticatedEnvironment;
@@ -680,7 +651,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
options?: BatchTriggerTaskServiceOptions;
parentRunId: string | undefined;
resumeParentOnCompletion: boolean | undefined;
planType?: string;
}) {
logger.debug("[RunEngineBatchTrigger][processBatchTaskRunItem] Processing item", {
batchId: batch.friendlyId,
@@ -707,8 +677,6 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
spanParentAsLink: options?.spanParentAsLink,
batchId: batch.id,
batchIndex: currentIndex,
skipChecks: true, // Skip entitlement and queue checks since we already validated at batch/chunk level
planType, // Pass planType from batch-level entitlement check
realtimeStreamsVersion: options?.realtimeStreamsVersion,
},
"V2"
@@ -752,85 +720,4 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
};
});
}
#groupItemsByTaskIdentifier(
items: BatchTriggerTaskV2RequestBody["items"]
): Record<string, BatchTriggerTaskV2RequestBody["items"]> {
return items.reduce((acc, item) => {
if (!item.options?.idempotencyKey) return acc;
if (!acc[item.task]) {
acc[item.task] = [];
}
acc[item.task].push(item);
return acc;
}, {} as Record<string, BatchTriggerTaskV2RequestBody["items"]>);
}
async #countNewRuns(
environment: AuthenticatedEnvironment,
items: BatchTriggerTaskV2RequestBody["items"]
): Promise<number> {
// If cached runs check is disabled, return the total number of items
if (!env.BATCH_TRIGGER_CACHED_RUNS_CHECK_ENABLED) {
return items.length;
}
// Group items by taskIdentifier for efficient lookup
const itemsByTask = this.#groupItemsByTaskIdentifier(items);
// If no items have idempotency keys, all are new runs
if (Object.keys(itemsByTask).length === 0) {
return items.length;
}
// Fetch cached runs for each task identifier separately to make use of the index
const cachedRuns = await Promise.all(
Object.entries(itemsByTask).map(([taskIdentifier, taskItems]) =>
this._prisma.taskRun.findMany({
where: {
runtimeEnvironmentId: environment.id,
taskIdentifier,
idempotencyKey: {
in: taskItems.map((i) => i.options?.idempotencyKey).filter(Boolean),
},
},
select: {
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
},
})
)
).then((results) => results.flat());
// Create a Map for O(1) lookups instead of O(m) find operations
const cachedRunsMap = new Map(cachedRuns.map((run) => [run.idempotencyKey, run]));
// Count items that are NOT cached (or have expired cache)
let newRunCount = 0;
const now = new Date();
for (const item of items) {
const idempotencyKey = item.options?.idempotencyKey;
if (!idempotencyKey) {
// No idempotency key = always a new run
newRunCount++;
continue;
}
const cachedRun = cachedRunsMap.get(idempotencyKey);
if (!cachedRun) {
// No cached run = new run
newRunCount++;
} else if (cachedRun.idempotencyKeyExpiresAt && cachedRun.idempotencyKeyExpiresAt < now) {
// Expired cached run = new run
newRunCount++;
}
// else: valid cached run = not a new run
}
return newRunCount;
}
}
@@ -90,17 +90,8 @@ export class CreateBatchService extends WithRunEngine {
);
}
// Validate queue limits for the expected batch size
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
environment,
body.runCount
);
if (!queueSizeGuard.ok) {
throw new ServiceValidationError(
`Cannot create batch with ${body.runCount} items as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
}
// Note: Queue size limits are validated per-queue when batch items are processed,
// since we don't know which queues items will go to until they're streamed.
// Create BatchTaskRun in Postgres with PENDING status
// The batch will be sealed (status -> PROCESSING) when items are streamed
@@ -1,5 +1,4 @@
import {
type BatchItemNDJSON,
type StreamBatchItemsResponse,
BatchItemNDJSON as BatchItemNDJSONSchema,
} from "@trigger.dev/core/v3";
@@ -15,6 +14,14 @@ export type StreamBatchItemsServiceOptions = {
maxItemBytes: number;
};
export type OversizedItemMarker = {
__batchItemError: "OVERSIZED";
index: number;
task: string;
actualSize: number;
maxSize: number;
};
export type StreamBatchItemsServiceConstructorOptions = {
prisma?: PrismaClientOrTransaction;
engine?: RunEngine;
@@ -111,6 +118,41 @@ export class StreamBatchItemsService extends WithRunEngine {
// Process items from the stream
for await (const rawItem of itemsIterator) {
// Check for oversized item markers from the NDJSON parser
if (rawItem && typeof rawItem === "object" && "__batchItemError" in rawItem) {
const marker = rawItem as OversizedItemMarker;
const itemIndex = marker.index >= 0 ? marker.index : lastIndex + 1;
const errorMessage = `Batch item payload is too large (${(marker.actualSize / 1024).toFixed(1)} KB). Maximum allowed size is ${(marker.maxSize / 1024).toFixed(1)} KB. Reduce the payload size or offload large data to external storage.`;
// Enqueue with __error metadata - processItemCallback will detect this
// and use TriggerFailedTaskService to create a pre-failed run
const batchItem: BatchItem = {
task: marker.task,
payload: "{}",
payloadType: "application/json",
options: {
__error: errorMessage,
__errorCode: "PAYLOAD_TOO_LARGE",
},
};
const result = await this._engine.enqueueBatchItem(
batchId,
environment.id,
itemIndex,
batchItem
);
if (result.enqueued) {
itemsAccepted++;
} else {
itemsDeduplicated++;
}
lastIndex = itemIndex;
continue;
}
// Parse and validate the item
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
if (!parseResult.success) {
@@ -169,6 +211,34 @@ export class StreamBatchItemsService extends WithRunEngine {
// Validate we received the expected number of items
if (enqueuedCount !== batch.runCount) {
// The batch queue consumers may have already processed all items and
// cleaned up the Redis keys before we got here (especially likely when
// items include pre-failed runs that complete instantly). Check if the
// batch was already sealed/completed in Postgres.
const currentBatch = await this._prisma.batchTaskRun.findUnique({
where: { id: batchId },
select: { sealed: true, status: true },
});
if (currentBatch?.sealed) {
logger.info("Batch already sealed before count check (fast completion)", {
batchId: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
enqueuedCount,
expectedCount: batch.runCount,
batchStatus: currentBatch.status,
});
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: true,
runCount: batch.runCount,
};
}
logger.warn("Batch item count mismatch", {
batchId: batchFriendlyId,
expected: batch.runCount,
@@ -186,6 +256,7 @@ export class StreamBatchItemsService extends WithRunEngine {
sealed: false,
enqueuedCount,
expectedCount: batch.runCount,
runCount: batch.runCount,
};
}
@@ -237,6 +308,7 @@ export class StreamBatchItemsService extends WithRunEngine {
itemsAccepted,
itemsDeduplicated,
sealed: true,
runCount: batch.runCount,
};
}
@@ -273,12 +345,128 @@ export class StreamBatchItemsService extends WithRunEngine {
itemsAccepted,
itemsDeduplicated,
sealed: true,
runCount: batch.runCount,
};
}
);
}
}
/**
* Extract `index` and `task` from raw JSON bytes without decoding the full line.
* Scans at most 512 bytes, tracking JSON nesting depth to only match top-level keys.
*/
export function extractIndexAndTask(bytes: Uint8Array): { index: number; task: string } {
let index = -1;
let task = "unknown";
let depth = 0;
let foundIndex = false;
let foundTask = false;
const limit = Math.min(bytes.byteLength, 512);
const QUOTE = 0x22; // "
const COLON = 0x3a; // :
const LBRACE = 0x7b; // {
const RBRACE = 0x7d; // }
const LBRACKET = 0x5b; // [
const RBRACKET = 0x5d; // ]
const BACKSLASH = 0x5c; // \
// Byte patterns for "index" and "task" (without quotes)
const INDEX_BYTES = [0x69, 0x6e, 0x64, 0x65, 0x78]; // index
const TASK_BYTES = [0x74, 0x61, 0x73, 0x6b]; // task
let i = 0;
while (i < limit && !(foundIndex && foundTask)) {
const b = bytes[i];
if (b === LBRACE || b === LBRACKET) {
depth++;
i++;
continue;
}
if (b === RBRACE || b === RBRACKET) {
depth--;
i++;
continue;
}
// Only match keys at depth 1 (top-level object)
if (b === QUOTE && depth === 1) {
// Read the key inside quotes
const keyStart = i + 1;
let keyEnd = keyStart;
while (keyEnd < limit && bytes[keyEnd] !== QUOTE) {
if (bytes[keyEnd] === BACKSLASH) keyEnd++; // skip escaped char
keyEnd++;
}
const keyLen = keyEnd - keyStart;
// Check if this key matches "index" or "task"
const isIndex =
!foundIndex &&
keyLen === INDEX_BYTES.length &&
INDEX_BYTES.every((b, j) => bytes[keyStart + j] === b);
const isTask =
!foundTask &&
keyLen === TASK_BYTES.length &&
TASK_BYTES.every((b, j) => bytes[keyStart + j] === b);
if (isIndex || isTask) {
// Skip past closing quote and find colon
let pos = keyEnd + 1;
while (pos < limit && bytes[pos] !== COLON) pos++;
pos++; // skip colon
// Skip whitespace
while (pos < limit && (bytes[pos] === 0x20 || bytes[pos] === 0x09)) pos++;
if (isIndex) {
// Parse digits
let num = 0;
let hasDigit = false;
while (pos < limit && bytes[pos] >= 0x30 && bytes[pos] <= 0x39) {
num = num * 10 + (bytes[pos] - 0x30);
hasDigit = true;
pos++;
}
if (hasDigit) {
index = num;
foundIndex = true;
}
} else {
// Parse quoted string value
if (pos < limit && bytes[pos] === QUOTE) {
const valStart = pos + 1;
let valEnd = valStart;
while (valEnd < limit && bytes[valEnd] !== QUOTE) {
if (bytes[valEnd] === BACKSLASH) valEnd++;
valEnd++;
}
// Decode just this slice
try {
task = new TextDecoder("utf-8", { fatal: true }).decode(
bytes.slice(valStart, valEnd)
);
foundTask = true;
} catch {
// Leave as "unknown"
}
}
}
}
// Skip past the key's closing quote
i = keyEnd + 1;
continue;
}
i++;
}
return { index, task };
}
/**
* Create an NDJSON parser transform stream.
*
@@ -303,6 +491,9 @@ export function createNdjsonParserStream(
let chunks: Uint8Array[] = [];
let totalBytes = 0;
let lineNumber = 0;
// When an oversized incomplete line is detected (Case 2), we must discard
// all remaining bytes of that line until the next newline delimiter.
let skipUntilNewline = false;
const NEWLINE_BYTE = 0x0a; // '\n'
@@ -396,6 +587,24 @@ export function createNdjsonParserStream(
return new TransformStream<Uint8Array, unknown>({
transform(chunk, controller) {
// If we're skipping the remainder of an oversized line, scan for the
// next newline in this chunk and discard everything before it.
if (skipUntilNewline) {
const nlPos = chunk.indexOf(NEWLINE_BYTE);
if (nlPos === -1) {
// Entire chunk is still part of the oversized line — discard it
return;
}
// Found the newline — keep everything after it
skipUntilNewline = false;
const remaining = chunk.slice(nlPos + 1);
if (remaining.byteLength === 0) {
return;
}
// Replace chunk with the remainder and fall through to normal processing
chunk = remaining;
}
// Append chunk to buffer
chunks.push(chunk);
totalBytes += chunk.byteLength;
@@ -405,11 +614,19 @@ export function createNdjsonParserStream(
while ((newlineIndex = findNewlineIndex()) !== -1) {
// Check size limit BEFORE extracting/decoding (bytes up to newline)
if (newlineIndex > maxItemBytes) {
throw new Error(
`Item at line ${
lineNumber + 1
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${newlineIndex})`
);
// Case 1: Complete line exceeds limit - emit marker instead of throwing
const lineBytes = extractLine(newlineIndex);
const extracted = extractIndexAndTask(lineBytes);
const marker: OversizedItemMarker = {
__batchItemError: "OVERSIZED",
index: extracted.index,
task: extracted.task,
actualSize: newlineIndex,
maxSize: maxItemBytes,
};
controller.enqueue(marker);
lineNumber++;
continue;
}
const lineBytes = extractLine(newlineIndex);
@@ -419,11 +636,23 @@ export function createNdjsonParserStream(
// Check if the remaining buffer (incomplete line) exceeds the limit
// This prevents OOM from a single huge line without newlines
if (totalBytes > maxItemBytes) {
throw new Error(
`Item at line ${
lineNumber + 1
} exceeds maximum size of ${maxItemBytes} bytes (buffered: ${totalBytes}, no newline found)`
);
// Case 2: Incomplete line exceeds limit - emit marker instead of throwing
const extracted = extractIndexAndTask(concatenateChunks());
const marker: OversizedItemMarker = {
__batchItemError: "OVERSIZED",
index: extracted.index,
task: extracted.task,
actualSize: totalBytes,
maxSize: maxItemBytes,
};
controller.enqueue(marker);
lineNumber++;
// Clear buffer and skip remaining bytes of this oversized line
// until the next newline delimiter is found in a subsequent chunk
chunks = [];
totalBytes = 0;
skipUntilNewline = true;
return;
}
},
@@ -439,11 +668,17 @@ export function createNdjsonParserStream(
// Check size limit before processing final line
if (totalBytes > maxItemBytes) {
throw new Error(
`Item at line ${
lineNumber + 1
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${totalBytes})`
);
// Case 3: Flush with oversized remaining - emit marker instead of throwing
const extracted = extractIndexAndTask(concatenateChunks());
const marker: OversizedItemMarker = {
__batchItemError: "OVERSIZED",
index: extracted.index,
task: extracted.task,
actualSize: totalBytes,
maxSize: maxItemBytes,
};
controller.enqueue(marker);
return;
}
const finalBytes = concatenateChunks();
@@ -0,0 +1,297 @@
import { RunEngine } from "@internal/run-engine";
import { TaskRunErrorCodes, type TaskRunError } from "@trigger.dev/core/v3";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import type { RuntimeEnvironmentType, TaskRun } from "@trigger.dev/database";
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { getEventRepository } from "~/v3/eventRepository/index.server";
import { DefaultQueueManager } from "../concerns/queues.server";
import type { TriggerTaskRequest } from "../types";
export type TriggerFailedTaskRequest = {
/** The task identifier (e.g. "my-task") */
taskId: string;
/** The fully-resolved authenticated environment */
environment: AuthenticatedEnvironment;
/** Raw payload — string or object */
payload: unknown;
/** MIME type of the payload (defaults to "application/json") */
payloadType?: string;
/** Error message describing why the run failed */
errorMessage: string;
/** Parent run friendly ID (e.g. "run_xxxx") */
parentRunId?: string;
/** Whether completing this run should resume the parent */
resumeParentOnCompletion?: boolean;
/** Batch association */
batch?: { id: string; index: number };
/** Trigger options from the original request (queue config, etc.) */
options?: Record<string, unknown>;
/** Trace context for span correlation */
traceContext?: Record<string, unknown>;
/** Whether the span parent should be treated as a link rather than a parent */
spanParentAsLink?: boolean;
errorCode?: TaskRunErrorCodes;
};
/**
* Creates a pre-failed TaskRun with a trace event span.
*
* This is used when a task cannot be triggered (e.g. queue limit reached, validation
* error, etc.) but we still need to record the failure so that:
* - Batch completion can track the item
* - Parent runs get unblocked
* - The failed run shows up in the run logs view
*
* This service resolves the parent run (for rootTaskRunId/depth) and queue properties
* the same way triggerTask does, so the run is correctly associated in the task tree
* and the SpanPresenter can find the TaskQueue.
*/
export class TriggerFailedTaskService {
private readonly prisma: PrismaClientOrTransaction;
private readonly engine: RunEngine;
constructor(opts: { prisma: PrismaClientOrTransaction; engine: RunEngine }) {
this.prisma = opts.prisma;
this.engine = opts.engine;
}
async call(request: TriggerFailedTaskRequest): Promise<string | null> {
const failedRunFriendlyId = RunId.generate().friendlyId;
const taskRunError: TaskRunError = {
type: "INTERNAL_ERROR" as const,
code: request.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
message: request.errorMessage,
};
try {
const { repository, store } = await getEventRepository(
request.environment.organization.featureFlags as Record<string, unknown>,
undefined
);
// Resolve parent run for rootTaskRunId and depth (same as triggerTask.server.ts)
const parentRun = request.parentRunId
? await this.prisma.taskRun.findFirst({
where: {
id: RunId.fromFriendlyId(request.parentRunId),
runtimeEnvironmentId: request.environment.id,
},
})
: undefined;
const depth = parentRun ? parentRun.depth + 1 : 0;
const rootTaskRunId = parentRun?.rootTaskRunId ?? parentRun?.id;
// Resolve queue properties (same as triggerTask) so span presenter can find TaskQueue.
// Best-effort: if resolution throws (e.g. request shape, missing worker), we still create
// the run without queue/lockedQueueId so run creation and trace events never regress.
let queueName: string | undefined;
let lockedQueueId: string | undefined;
try {
const queueConcern = new DefaultQueueManager(this.prisma, this.engine);
const bodyOptions = request.options as TriggerTaskRequest["body"]["options"];
const triggerRequest: TriggerTaskRequest = {
taskId: request.taskId,
friendlyId: failedRunFriendlyId,
environment: request.environment,
body: {
payload:
typeof request.payload === "string"
? request.payload
: JSON.stringify(request.payload ?? {}),
options: bodyOptions,
},
};
// Resolve the locked background worker if lockToVersion is set (same as triggerTask).
// resolveQueueProperties requires the worker to be passed when lockToVersion is present.
const lockedToBackgroundWorker = bodyOptions?.lockToVersion
? await this.prisma.backgroundWorker.findFirst({
where: {
projectId: request.environment.projectId,
runtimeEnvironmentId: request.environment.id,
version: bodyOptions.lockToVersion,
},
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
},
})
: undefined;
const resolved = await queueConcern.resolveQueueProperties(
triggerRequest,
lockedToBackgroundWorker ?? undefined
);
queueName = resolved.queueName;
lockedQueueId = resolved.lockedQueueId;
} catch (queueResolveError) {
const err =
queueResolveError instanceof Error
? queueResolveError
: new Error(String(queueResolveError));
logger.warn("TriggerFailedTaskService: queue resolution failed, using defaults", {
taskId: request.taskId,
friendlyId: failedRunFriendlyId,
error: err.message,
});
}
// Create the failed run inside a trace event span so it shows up in run logs
const failedRun: TaskRun = await repository.traceEvent(
request.taskId,
{
context: request.traceContext,
spanParentAsLink: request.spanParentAsLink,
kind: "SERVER",
environment: {
id: request.environment.id,
type: request.environment.type,
organizationId: request.environment.organizationId,
projectId: request.environment.projectId,
project: { externalRef: request.environment.project.externalRef },
},
taskSlug: request.taskId,
attributes: {
properties: {},
style: { icon: "task" },
},
incomplete: false,
isError: true,
immediate: true,
},
async (event, traceContext) => {
event.setAttribute("runId", failedRunFriendlyId);
event.failWithError(taskRunError);
return await this.engine.createFailedTaskRun({
friendlyId: failedRunFriendlyId,
environment: {
id: request.environment.id,
type: request.environment.type,
project: { id: request.environment.project.id },
organization: { id: request.environment.organization.id },
},
taskIdentifier: request.taskId,
payload:
typeof request.payload === "string"
? request.payload
: JSON.stringify(request.payload ?? ""),
payloadType: request.payloadType ?? "application/json",
error: taskRunError,
parentTaskRunId: parentRun?.id,
rootTaskRunId,
depth,
resumeParentOnCompletion: request.resumeParentOnCompletion,
batch: request.batch,
traceId: event.traceId,
spanId: event.spanId,
traceContext: traceContext as Record<string, unknown>,
taskEventStore: store,
...(queueName !== undefined && { queue: queueName }),
...(lockedQueueId !== undefined && { lockedQueueId }),
});
}
);
return failedRun.friendlyId;
} catch (createError) {
const createErrorMsg =
createError instanceof Error ? createError.message : String(createError);
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun", {
taskId: request.taskId,
friendlyId: failedRunFriendlyId,
originalError: request.errorMessage,
createError: createErrorMsg,
});
return null;
}
}
/**
* Creates a pre-failed run without trace events.
* Used when the environment can't be fully resolved (e.g. environment not found)
* and we can't create trace events or look up parent runs.
*/
async callWithoutTraceEvents(opts: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
projectId: string;
organizationId: string;
taskId: string;
payload: unknown;
payloadType?: string;
errorMessage: string;
parentRunId?: string;
resumeParentOnCompletion?: boolean;
batch?: { id: string; index: number };
errorCode?: TaskRunErrorCodes;
}): Promise<string | null> {
const failedRunFriendlyId = RunId.generate().friendlyId;
try {
// Best-effort parent run lookup for rootTaskRunId/depth
let parentTaskRunId: string | undefined;
let rootTaskRunId: string | undefined;
let depth = 0;
if (opts.parentRunId) {
const parentRun = await this.prisma.taskRun.findFirst({
where: {
id: RunId.fromFriendlyId(opts.parentRunId),
runtimeEnvironmentId: opts.environmentId,
},
});
if (parentRun) {
parentTaskRunId = parentRun.id;
rootTaskRunId = parentRun.rootTaskRunId ?? parentRun.id;
depth = parentRun.depth + 1;
} else {
parentTaskRunId = RunId.fromFriendlyId(opts.parentRunId);
}
}
await this.engine.createFailedTaskRun({
friendlyId: failedRunFriendlyId,
environment: {
id: opts.environmentId,
type: opts.environmentType,
project: { id: opts.projectId },
organization: { id: opts.organizationId },
},
taskIdentifier: opts.taskId,
payload:
typeof opts.payload === "string"
? opts.payload
: JSON.stringify(opts.payload ?? ""),
payloadType: opts.payloadType ?? "application/json",
error: {
type: "INTERNAL_ERROR" as const,
code: opts.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
message: opts.errorMessage,
},
parentTaskRunId,
rootTaskRunId,
depth,
resumeParentOnCompletion: opts.resumeParentOnCompletion,
batch: opts.batch,
});
return failedRunFriendlyId;
} catch (createError) {
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun (no trace)", {
taskId: opts.taskId,
friendlyId: failedRunFriendlyId,
originalError: opts.errorMessage,
createError: createError instanceof Error ? createError.message : String(createError),
});
return null;
}
}
}
@@ -234,24 +234,6 @@ export class RunEngineTriggerTaskService {
});
}
if (!options.skipChecks) {
const queueSizeGuard = await this.queueConcern.validateQueueLimits(environment);
if (!queueSizeGuard.ok) {
throw new ServiceValidationError(
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
}
}
const metadataPacket = body.options?.metadata
? handleMetadataPacket(
body.options?.metadata,
body.options?.metadataType ?? "application/json",
this.metadataMaximumSize
)
: undefined;
const lockedToBackgroundWorker = body.options?.lockToVersion
? await this.prisma.backgroundWorker.findFirst({
where: {
@@ -273,6 +255,27 @@ export class RunEngineTriggerTaskService {
lockedToBackgroundWorker ?? undefined
);
if (!options.skipChecks) {
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
environment,
queueName
);
if (!queueSizeGuard.ok) {
throw new ServiceValidationError(
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
}
}
const metadataPacket = body.options?.metadata
? handleMetadataPacket(
body.options?.metadata,
body.options?.metadataType ?? "application/json",
this.metadataMaximumSize
)
: undefined;
//upsert tags
const tags = await createTags(
{
+1
View File
@@ -64,6 +64,7 @@ export interface QueueManager {
getQueueName(request: TriggerTaskRequest): Promise<string>;
validateQueueLimits(
env: AuthenticatedEnvironment,
queueName: string,
itemsToAdd?: number
): Promise<QueueValidationResult>;
getWorkerQueue(
@@ -1,6 +1,6 @@
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments"] as const;
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams"] as const;
export type AuthorizationResources = {
[key in (typeof ResourceTypes)[number]]?: string | string[];
@@ -0,0 +1,100 @@
import { Redis } from "ioredis";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
const KEY_PREFIX = "isw:";
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function buildKey(runFriendlyId: string, streamId: string): string {
return `${KEY_PREFIX}${runFriendlyId}:${streamId}`;
}
function initializeRedis(): Redis | undefined {
const host = env.CACHE_REDIS_HOST;
if (!host) {
return undefined;
}
return new Redis({
connectionName: "inputStreamWaitpointCache",
host,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
keyPrefix: "tr:",
enableAutoPipelining: true,
...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
}
const redis = singleton("inputStreamWaitpointCache", initializeRedis);
/**
* Store a mapping from input stream to waitpoint ID in Redis.
* Called when `.wait()` creates a new waitpoint.
*/
export async function setInputStreamWaitpoint(
runFriendlyId: string,
streamId: string,
waitpointId: string,
ttlMs?: number
): Promise<void> {
if (!redis) return;
try {
const key = buildKey(runFriendlyId, streamId);
await redis.set(key, waitpointId, "PX", ttlMs ?? DEFAULT_TTL_MS);
} catch (error) {
logger.error("Failed to set input stream waitpoint cache", {
runFriendlyId,
streamId,
error,
});
}
}
/**
* Get the waitpoint ID for an input stream without deleting it.
* Called from the `.send()` route before completing the waitpoint.
*/
export async function getInputStreamWaitpoint(
runFriendlyId: string,
streamId: string
): Promise<string | null> {
if (!redis) return null;
try {
const key = buildKey(runFriendlyId, streamId);
return await redis.get(key);
} catch (error) {
logger.error("Failed to get input stream waitpoint cache", {
runFriendlyId,
streamId,
error,
});
return null;
}
}
/**
* Delete the cache entry for an input stream waitpoint.
* Called when a waitpoint is completed or timed out.
*/
export async function deleteInputStreamWaitpoint(
runFriendlyId: string,
streamId: string
): Promise<void> {
if (!redis) return;
try {
const key = buildKey(runFriendlyId, streamId);
await redis.del(key);
} catch (error) {
logger.error("Failed to delete input stream waitpoint cache", {
runFriendlyId,
streamId,
error,
});
}
}
+18
View File
@@ -22,6 +22,24 @@ class LoopsClient {
});
}
async vercelIntegrationStarted({
userId,
email,
name,
}: {
userId: string;
email: string;
name: string | null;
}) {
logger.info(`Loops send "vercel-integration" event`, { userId, email, name });
return this.#sendEvent({
email,
userId,
firstName: name?.split(" ").at(0),
eventName: "vercel-integration",
});
}
async #sendEvent({
email,
userId,
@@ -30,10 +30,10 @@ export class ProjectSettingsService {
);
}
deleteProject(projectSlug: string, userId: string) {
deleteProject(projectId: string, userId: string) {
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({
return fromPromise(deleteProjectService.call({ projectId, userId }), (error) => ({
type: "other" as const,
cause: error,
}));
@@ -466,4 +466,8 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
});
}
}
async readRecords(): Promise<never> {
throw new Error("readRecords is not implemented for Redis realtime streams");
}
}
@@ -1,6 +1,6 @@
// app/realtime/S2RealtimeStreams.ts
import type { UnkeyCache } from "@internal/cache";
import { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types";
import { StreamIngestor, StreamRecord, StreamResponder, StreamResponseOptions } from "./types";
import { Logger, LogLevel } from "@trigger.dev/core/logger";
import { randomUUID } from "node:crypto";
@@ -10,6 +10,12 @@ export type S2RealtimeStreamsOptions = {
accessToken: string; // "Bearer" token issued in S2 console
streamPrefix?: string; // defaults to ""
// Custom endpoint for s2-lite (self-hosted)
endpoint?: string; // e.g., "http://localhost:4566/v1"
// Skip access token issuance (s2-lite doesn't support /access-tokens)
skipAccessTokens?: boolean;
// Read behavior
s2WaitSeconds?: number;
@@ -37,8 +43,11 @@ type S2AppendAck = {
export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
private readonly basin: string;
private readonly baseUrl: string;
private readonly accountUrl: string;
private readonly endpoint?: string;
private readonly token: string;
private readonly streamPrefix: string;
private readonly skipAccessTokens: boolean;
private readonly s2WaitSeconds: number;
@@ -56,9 +65,12 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
constructor(opts: S2RealtimeStreamsOptions) {
this.basin = opts.basin;
this.baseUrl = `https://${this.basin}.b.aws.s2.dev/v1`;
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
this.endpoint = opts.endpoint;
this.token = opts.accessToken;
this.streamPrefix = opts.streamPrefix ?? "";
this.skipAccessTokens = opts.skipAccessTokens ?? false;
this.s2WaitSeconds = opts.s2WaitSeconds ?? 60;
@@ -80,17 +92,20 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }> {
const id = randomUUID();
const accessToken = await this.getS2AccessToken(id);
const accessToken = this.skipAccessTokens
? this.token
: await this.getS2AccessToken(randomUUID());
return {
responseHeaders: {
"X-S2-Access-Token": accessToken,
"X-S2-Stream-Name": `/runs/${runId}/${streamId}`,
"X-S2-Stream-Name": this.skipAccessTokens
? this.toStreamName(runId, streamId)
: `/runs/${runId}/${streamId}`,
"X-S2-Basin": this.basin,
"X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(),
"X-S2-Max-Retries": this.maxRetries.toString(),
...(this.endpoint ? { "X-S2-Endpoint": this.endpoint } : {}),
},
};
}
@@ -121,6 +136,88 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
throw new Error("S2 streams are written to S2 via the client, not from the server");
}
async readRecords(
runId: string,
streamId: string,
afterSeqNum?: number
): Promise<StreamRecord[]> {
const s2Stream = this.toStreamName(runId, streamId);
const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0;
const qs = new URLSearchParams();
qs.set("seq_num", String(startSeq));
qs.set("clamp", "true");
qs.set("wait", "0"); // Non-blocking: return immediately with existing records
const res = await fetch(
`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records?${qs}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "text/event-stream",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
}
);
if (!res.ok) {
// Stream may not exist yet (no data sent)
if (res.status === 404) {
return [];
}
const text = await res.text().catch(() => "");
throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText} ${text}`);
}
// Parse the SSE response body to extract records
const body = await res.text();
return this.parseSSEBatchRecords(body);
}
private parseSSEBatchRecords(sseText: string): StreamRecord[] {
const records: StreamRecord[] = [];
// SSE events are separated by double newlines
const events = sseText.split("\n\n").filter((e) => e.trim());
for (const event of events) {
const lines = event.split("\n");
let eventType: string | undefined;
let data: string | undefined;
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data = line.slice(5).trim();
}
}
if (eventType === "batch" && data) {
try {
const parsed = JSON.parse(data) as {
records: Array<{ body: string; seq_num: number; timestamp: number }>;
};
for (const record of parsed.records) {
const parsedBody = JSON.parse(record.body) as { data: string; id: string };
records.push({
data: parsedBody.data,
id: parsedBody.id,
seqNum: record.seq_num,
});
}
} catch {
// Skip malformed events
}
}
}
return records;
}
// ---------- Serve SSE from S2 ----------
async streamResponse(
@@ -155,7 +252,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
"S2-Format": "raw", // UTF-8 JSON encoding (no base64 overhead) when your data is text. :contentReference[oaicite:8]{index=8}
"S2-Format": "raw",
"S2-Basin": this.basin,
},
body: JSON.stringify(body),
});
@@ -184,7 +282,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
private async s2IssueAccessToken(id: string): Promise<string> {
// POST /v1/access-tokens
const res = await fetch(`https://aws.s2.dev/v1/access-tokens`, {
const res = await fetch(`${this.accountUrl}/access-tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
@@ -235,6 +333,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
Authorization: `Bearer ${this.token}`,
Accept: "text/event-stream",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
signal: opts.signal,
});
@@ -1,3 +1,9 @@
export type StreamRecord = {
data: string;
id: string;
seqNum: number;
};
// Interface for stream ingestion
export interface StreamIngestor {
initializeStream(
@@ -16,6 +22,12 @@ export interface StreamIngestor {
appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void>;
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number>;
readRecords(
runId: string,
streamId: string,
afterSeqNum?: number
): Promise<StreamRecord[]>;
}
export type StreamResponseOptions = {
@@ -36,10 +36,16 @@ export function getRealtimeStreamInstance(
if (streamVersion === "v1") {
return v1RealtimeStreams;
} else {
if (env.REALTIME_STREAMS_S2_BASIN && env.REALTIME_STREAMS_S2_ACCESS_TOKEN) {
if (
env.REALTIME_STREAMS_S2_BASIN &&
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN ||
env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
) {
return new S2RealtimeStreams({
basin: env.REALTIME_STREAMS_S2_BASIN,
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN,
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "",
endpoint: env.REALTIME_STREAMS_S2_ENDPOINT,
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
streamPrefix: [
"org",
environment.organization.id,
@@ -68,7 +74,7 @@ export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" |
if (
streamVersion === "v2" &&
env.REALTIME_STREAMS_S2_BASIN &&
env.REALTIME_STREAMS_S2_ACCESS_TOKEN
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
) {
return "v2";
}
+13 -2
View File
@@ -6,7 +6,18 @@ import { getImpersonationId } from "./impersonation.server";
export async function getUserId(request: Request): Promise<string | undefined> {
const impersonatedUserId = await getImpersonationId(request);
if (impersonatedUserId) return impersonatedUserId;
if (impersonatedUserId) {
// Verify the real user (from the session cookie) is still an admin
const authUser = await authenticator.isAuthenticated(request);
if (authUser?.userId) {
const realUser = await getUserById(authUser.userId);
if (realUser?.admin) {
return impersonatedUserId;
}
}
// Admin revoked or session invalid — fall through to return the real user's ID
return authUser?.userId;
}
let authUser = await authenticator.isAuthenticated(request);
return authUser?.userId;
@@ -54,7 +65,7 @@ export async function requireUser(request: Request) {
dashboardPreferences: user.dashboardPreferences,
confirmedBasicDetails: user.confirmedBasicDetails,
mfaEnabledAt: user.mfaEnabledAt,
isImpersonating: !!impersonationId,
isImpersonating: !!impersonationId && impersonationId === userId,
};
}

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