Compare commits

...

46 Commits

Author SHA1 Message Date
Claude 076f7579d1 docs: add missing usage import to run-usage code examples
Add the missing import statements for the usage object from
@trigger.dev/sdk/v3 to the code examples in the run-usage docs.
2026-01-05 09:54:56 +00:00
Eric Allam edf5b142fc fix(fair-queue): Prevent unbounded memory growth from metrics cardinality explosion (#2819) 2025-12-25 08:51:40 +00:00
Eric Allam 4c3dfac223 fix(fair-queue): prevent unbounded cooloff states growth (#2818) 2025-12-24 16:33:38 +00:00
nicktrn 7ccbbdb368 docs: tidy up sync env vars note blocks (#2817) 2025-12-24 13:02:26 +00:00
Eric Allam 71279a7b12 fix(fair-queue): prevent unbounded memory growth by cleaning up queue descriptor and cooloff state cache (#2816) 2025-12-24 10:40:32 +00:00
Eric Allam 2eba36c086 chore(redis-worker): add otel spans to fair queue processing pipeline (#2815) 2025-12-24 00:37:24 +00:00
Eric Allam 29827e96f2 fix(batch): add batch queue back into master queue after visibility timeout (#2814) 2025-12-24 00:25:46 +00:00
Dan d416f340ad Added example projects link (#2812)
Closes #<issue>

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

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯
2025-12-23 18:37:19 +00:00
nicktrn 7a54a843e2 fix(cli): update command should preserve existing package.json order (#2810)
This fixes a regression introduced in #2778 - stable sort is required
for deterministic builds, but we can safely preserve order for the user
package.json during package updates
2025-12-23 12:52:06 +00:00
nicktrn 52e9baede5 fix(webapp): don't rate limit deployment finalization (#2809)
turns out we also had v2 and v3 routes
2025-12-23 09:22:00 +00:00
Eric Allam deb80890fe chore(otel): add spans to the batch queue processing pipeline (#2808) 2025-12-23 08:40:33 +00:00
Eric Allam d82089686c fix(batch): extract the queue name out of an already nested queue option (#2807) 2025-12-22 21:40:14 +00:00
Oskar Otwinowski acc10e847c chore(docs): add more cost saving tips (#2806)
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-12-22 15:36:54 +00:00
Eric Allam f1a83cffc4 chore(docs): debounce options (#2797) 2025-12-22 15:35:51 +00:00
Eric Allam 61fee91830 chore(docs): upgrade for new batch trigger limits and functionality (#2787) 2025-12-22 15:35:43 +00:00
github-actions[bot] caa40ce925 chore: release v4.3.1 (#2788)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 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
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


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

### Patch Changes

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

## trigger.dev@4.3.1

### Patch Changes

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

## @trigger.dev/core@4.3.1

### Patch Changes

- Added support for idempotency reset
([#2777](https://github.com/triggerdotdev/trigger.dev/pull/2777))

## @trigger.dev/python@4.3.1

### Patch Changes

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

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

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/rsc@4.3.1

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.3.1

### Patch Changes

- feat: Support for new batch trigger system
([#2779](https://github.com/triggerdotdev/trigger.dev/pull/2779))
- feat(sdk): Support debouncing runs when triggering with new debounce
options
([#2794](https://github.com/triggerdotdev/trigger.dev/pull/2794))
- Added support for idempotency reset
([#2777](https://github.com/triggerdotdev/trigger.dev/pull/2777))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

---------

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>
2025-12-22 15:29:18 +00:00
Dan ba9b0e17c1 Added Claude Agent SDK guide and examples (#2803) 2025-12-19 17:10:32 +00:00
Oskar Otwinowski 469808cf09 fix(webapp): Make spans fluid again while task is executing (#2801)
Closes #<issue>

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

Tested on executing tasks and on moving between tasks

---

## Changelog

Small UI fix for spans on the task runs page

---

## Screenshots



https://github.com/user-attachments/assets/72095b03-c74f-4472-afde-9c63e6e7c224



💯
2025-12-19 15:45:54 +01:00
Mihai Popescu 7574c69c2d feat(webapp): Add support for resetting idempotency keys (#2777)
Add support for resetting idempotency keys both from ui and sdk

##  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
- Created a new run with a idempotency idempotencyKey.
- Started a new run with the same task and got redirected to the first
run.
- Deleted the key from the UI on the run details
- Started a new run with the same task  and it created a new one
- Did the above steps using the SDK


---

## Changelog

- Add new action route for resetting idempotency keys via UI
- Add reset button in Idempotency section of run detail view
- Added API and SDK for resetting imdepotency
- Updated docs page for this feature


---

## Screenshots

_[Screenshots]_
<img width="438" height="363" alt="Screenshot 2025-12-11 at 11 56 37"
src="https://github.com/user-attachments/assets/30b8ef5e-8aac-4d04-b57a-9bf30d085dcb"
/>
2025-12-19 11:55:04 +00:00
Eric Allam 06cbe6e3ca chore(docs): upgrade sentry guide to use new global init and global hooks (#2799) 2025-12-19 00:00:02 +00:00
Eric Allam 3875bb292a feat(engine): run debounce system (#2794)
Adds support for **debounced task runs** - when triggering a task with a
debounce key, subsequent triggers with the same key will reschedule the
existing delayed run instead of creating new runs. This continues until
no new triggers occur within the delay window.

## Usage

```typescript
await myTask.trigger({ userId: "123" }, {
  debounce: {
    key: "user-123-update",
    delay: "5s",
    mode: "leading", // default
  }
});
```

- **key**: Scoped to the task identifier
- **delay**: How long to wait before executing (supports duration
strings like `"5s"`, `"1m"`)
- **mode**: Either `"leading"` or `"trailing"`. Leading debounce will
use the payload and options from the first run created with the debounce
key. Trailing will use payload and options from the last run.

### "trailing" mode overrides

When using `mode: "trailing"` with debounce, the following options are
updated from the **last** trigger:

- **`payload`** - The task input data
- **`metadata`** - Run metadata
- **`tags`** - Run tags (replaces existing tags)
- **`maxAttempts`** - Maximum retry attempts
- **`maxDuration`** - Maximum compute time
- **`machine`**  - Machine preset (cpu/memory)

## Behavior

- **First run wins**: The first trigger creates the run, subsequent
triggers push its execution time later
- **Idempotency keys take precedence**: If both are specified,
idempotency is checked first
- **Max duration**: Configurable via `DEBOUNCE_MAX_DURATION_MS` env var
(default: 10 minutes)

Works with `triggerAndWait` - parent runs correctly block on the
debounced run.
2025-12-18 16:04:43 +00:00
Saadi Myftija ff80742ab7 fix(ci): use workflow_dispatch instead of repository_dispatch for publish.yml (#2791)
Mixed up the trigger in #2790
2025-12-16 16:53:22 +01:00
Saadi Myftija 11366e658c chore(ci): add manual trigger to the image publishing workflow (#2790)
Useful to retry failures manually.
2025-12-16 16:34:57 +01:00
Eric Allam e751f8832e fix(app): incorrectly duplicated env vars for new batch trigger system (#2789) 2025-12-16 15:27:13 +00:00
Eric Allam a999d9ea3f feat(engine): Batch trigger reloaded (#2779)
New batch trigger system with larger payloads, streaming ingestion,
larger batch sizes, and a fair processing system.

This PR introduces a new `FairQueue` abstraction inspired by our own
`RunQueue` that enables multi-tenant fair queueing with concurrency
limits. The new `BatchQueue` is built on top of the `FairQueue`, and
handles processing Batch triggers in a fair manner with per-environment
concurrency limits defined per-org. Additionally, there is a global
concurrency limit to prevent the BatchQueue system from creating too
many runs too quickly, which can cause downstream issues.

For this new BatchQueue system we have a completely new batch trigger
creation and ingestion system. Previously this was a single endpoint
with a single JSON body that defined details about the batch as well as
all the items in the batch.

We're introducing a two-phase batch trigger ingestion system. In the
first phase, the BatchTaskRun record is created (and possibly rate
limited). The second phase is another endpoint that accepts an NDJSON
body with each line being a single item/run with payload and options.

At ingestion time all items are added to a queue, in order, and then
processed by the BatchQueue system.

## New batch trigger rate limits

This PR implements a new batch trigger specific rate limit, configured
on the `Organization.batchRateLimitConfig` column, and defaults using
these environment variables:

- `BATCH_RATE_LIMIT_REFILL_RATE` defaults to 10
- `BATCH_RATE_LIMIT_REFILL_INTERVAL` the duration interval, defaults to
`"10s"`
- `BATCH_RATE_LIMIT_MAX` defaults to 1200

This rate limiter is scoped to the environment ID and controls how many
runs can be submitted via batch triggers per interval. The SDK handles
the retrying side.

## Batch queue concurrency limits

The new column `Organization.batchQueueConcurrencyConfig` now defines an
org specific `processingConcurrency` value, with a backup of the env var
`BATCH_CONCURRENCY_LIMIT_DEFAULT` which defaults to 10. This controls
how many batch queue items are processed concurrently per environment.

There is also a global rate limit for the batch queue set via the
`BATCH_QUEUE_GLOBAL_RATE_LIMIT` which defaults to being disabled. If
set, the entire batch queue system won't process more than
`BATCH_QUEUE_GLOBAL_RATE_LIMIT` items per second. This allows
controlling the maximum number of runs created per second via batch
triggers.

## Batch trigger settings

- `STREAMING_BATCH_MAX_ITEMS` controls the maximum number of items in a
single batch
- `STREAMING_BATCH_ITEM_MAXIMUM_SIZE` controls the maximum size of each
item in a batch
- `BATCH_CONCURRENCY_DEFAULT_CONCURRENCY` controls the default
environment concurrency
- `BATCH_QUEUE_DRR_QUANTUM` how many credits each environment gets each
round for the DRR scheduler
- `BATCH_QUEUE_MAX_DEFICIT` the maximum deficit for the DRR scheduler
- `BATCH_QUEUE_CONSUMER_COUNT` how many queue consumers to run
- `BATCH_QUEUE_CONSUMER_INTERVAL_MS` how frequently they poll for items
in the queue

### Configuration Recommendations by Use Case

**High-throughput priority (fairness acceptable at 0.98+):**

```env
BATCH_QUEUE_DRR_QUANTUM=25
BATCH_QUEUE_MAX_DEFICIT=100
BATCH_QUEUE_CONSUMER_COUNT=10
BATCH_QUEUE_CONSUMER_INTERVAL_MS=50
BATCH_CONCURRENCY_DEFAULT_CONCURRENCY=25
```

**Strict fairness priority (throughput can be lower):**

```env
BATCH_QUEUE_DRR_QUANTUM=5
BATCH_QUEUE_MAX_DEFICIT=25
BATCH_QUEUE_CONSUMER_COUNT=3
BATCH_QUEUE_CONSUMER_INTERVAL_MS=100
BATCH_CONCURRENCY_DEFAULT_CONCURRENCY=5
```
2025-12-16 14:32:49 +00:00
Oskar Otwinowski 28a66ac021 fix(ui): respect rootOnlyDefault, disable adjacent run navigation in inspector views (#2781)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 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
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
## Changelog

- Add disableAdjacentRows prop to TaskRunsTable component to control
table state encoding
- Pass rootOnlyDefault prop from loader to TaskRunsTable for proper
state management
- Disable adjacent run navigation in schedule, waitpoint, and other
inspector views
- Preserve adjacent run navigation on main runs list page with rootOnly
filter support
2025-12-12 12:16:29 +00:00
github-actions[bot] 7d34817473 chore: release v4.3.0 (#2770)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## trigger.dev@4.3.0

### Minor Changes

- feat(cli): deterministic image builds for deployments
([#2778](https://github.com/triggerdotdev/trigger.dev/pull/2778))
- feat(cli): enable zstd compression for deployment images
([#2773](https://github.com/triggerdotdev/trigger.dev/pull/2773))

### Patch Changes

- The new `triggeredVia` field is now populated in deployments via the
CLI. ([#2767](https://github.com/triggerdotdev/trigger.dev/pull/2767))
- fix(dev): stop max listeners exceeded warning messages when running
more than 10 runs concurrently
([#2771](https://github.com/triggerdotdev/trigger.dev/pull/2771))
- Upgrade @modelcontextprotocol/sdk to 1.24.3
([#2768](https://github.com/triggerdotdev/trigger.dev/pull/2768))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`
    -   `@trigger.dev/build@4.3.0`
    -   `@trigger.dev/schema-to-json@4.3.0`

## @trigger.dev/core@4.3.0

### Minor Changes

- feat(cli): deterministic image builds for deployments
([#2778](https://github.com/triggerdotdev/trigger.dev/pull/2778))

### Patch Changes

- The new `triggeredVia` field is now populated in deployments via the
CLI. ([#2767](https://github.com/triggerdotdev/trigger.dev/pull/2767))

## @trigger.dev/build@4.3.0

### Patch Changes

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

## @trigger.dev/python@4.3.0

### Patch Changes

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

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

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/rsc@4.3.0

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.3.0

### Patch Changes

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

---------

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>
2025-12-12 13:11:56 +01:00
Saadi Myftija 6d6ed471d1 feat(cli): deterministic image builds for deployments (#2778)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
This PR makes our image builds deterministic and reproducible by
ensuring that identical source code always produces the same image
layers and image digest. This means that deployments where nothing has
changed will no longer invalidate the image cache in our worker cluster
nodes, thus avoid making the cold starts for runs worse.

**Context**
New deployments currently increase the cold start times for runs, as
they generate a new image which needs to be pulled in the worker cluster
where runs are executed. It happens also when the source code for the
deployment has not changed due to non-deterministic steps in our build
system. This addresses the latter issue by making builds reproducible.

**Main changes**
- Avoided baking `TRIGGER_DEPLOYMENT_ID` and
`TRIGGER_DEPLOYMENT_VERSION` in the image, we now pass these via the
supervisor instead.
- Used `json-stable-stringify` for consistent key ordering in the files
we generate for the build, e.g., `package.json`, `build.json`,
`index.json`.
- Removed `metafile.json` from the image contents as it is not actually
used in the container. This is only relevant for the `analyze` command.
- Added `SOURCE_DATE_EPOCH=0` and `rewrite-timestamp=true` to Docker
builds to normalize file timestamps.
- Removed some `timings` and `outputHashes` from build outputs and
manifests.

The builds are now reproducible for both native build server and Depot
paths. This should also lead to better image layer cache reuse in
general.
2025-12-12 09:48:04 +01:00
Oskar Otwinowski 7f7f993587 feat(webapp): improve adjacent runs navigation and timeline performance (#2776)
- Add replace prop to LinkButton to use history replacement for adjacent
run navigation
- Preserve span and tab params when navigating between adjacent runs
- Disable animations for completed spans in timeline to improve
performance
- Include spanId in runs list navigation for better context preservation
- Direct link to task test page when filtering by single task with no
runs
- Fix minor styling issue with run friendlyId display padding
2025-12-11 17:26:15 +00:00
Oskar Otwinowski d28707826c feat(webapp): add GitHub onboarding flow to empty Tasks and Deployments pages (#2775)
##  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

Set up the local github application, and tested its connection with
trigger.dev

Checked:
- Change is backwards compatible
- Actions/ux are uniform across the pages (empty Tasks, Deployments,
project settings)
- Connecting GH, Connecting Repo, disconnecting Repo, modifying settings

---

## Changelog

- Create new resource route for GitHub settings management with loader
and actions
- Add GitHubSettingsPresenter to fetch connected repos and installations
- Implement GitHubSettingsPanel component for reusable GitHub
configuration UI
- Refactor project settings page to use shared GitHubSettingsPanel
component
- Integrate GitHub connection flow into empty state onboarding for Tasks
and Deployments
- Add support for GitHub repo connection, disconnection, and branch
tracking settings
- Include redirect URL support for seamless navigation after GitHub
actions
- Remove duplicate GitHub connection code from project settings route

---

## Screenshots


https://github.com/user-attachments/assets/8fc24699-640b-4f9e-afd8-b26edc945218

🐐

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-12-10 17:03:16 +00:00
Max Strübing 8b00198f99 docs(kubernetes): use v4 helm chart instead of beta (#2671) 2025-12-10 14:52:32 +00:00
nicktrn 74e9246bfa feat(cli): enable zstd compression for deployment images (#2773)
This will speed up ice cold starts (*) for two reasons:
- better compression ratio
- faster decompression

This is a minor release because zstd compression will now be enabled by
default for all deployments.

(*) ice cold starts happen when deploy images are not cached on the
worker node yet. These cold start durations are highly dependent on
image size and as it turns out, also the type of compression used.
2025-12-10 14:49:44 +00:00
Mihai Popescu 07a31d3732 fix(webapp) : fixed Cmd+Left Arrow was intercepted by the TreeView component (#2772)
Fixed the issue where Cmd+Left Arrow was being intercepted by the
TreeView component on task runs screen.

Solution:
Added a check in the getTreeProps keyboard handler to detect when
metaKey (Cmd on macOS) is pressed with Left Arrow. When detected, the
handler returns early without preventing the default browser behavior,
allowing Chrome's native back navigation to work.


Closes #<issue>

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

Went to the task details page and confirmed that Cmd + Left Arrow will
navigate back

---

## Changelog

Modified TreeView.tsx to check for e.metaKey before handling Left Arrow
key events
When Cmd+Left is pressed, the event is no longer prevented, allowing
browser default behavior
---

Co-authored-by: Mihai Popescu <mihaipopescu@Mihais-MacBook-Pro.local>
2025-12-10 12:05:16 +00:00
Saadi Myftija da111e220f fix(api): whitelist deployment endpoints from the general API rate limits (#2774)
Deployments are affected by general API rate limits, this is just a
quick fix by whitelisting the deployment related endpoints. In a follow
up PR we'll add a separate rate limiter for this group of endpoints.
2025-12-10 12:03:56 +01:00
Oskar Otwinowski 2c3cb4a43a feat(webapp): UX improvements for TaskRun page and TaskRun table (#2760)
##  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

Manual testing of the task run pages

---

## Changelog

- Add previous/next run navigation buttons to run detail page header
- Support [ and ] keyboard shortcuts to jump between adjacent runs
- Preserve runs table state (filters, pagination) when navigating
- Preload adjacent page runs at boundaries for seamless navigation
- Add actions prop to PageTitle component
- Document shortcut in keyboard shortcuts panel

- Store current filter state from runs table as `tableState` search
param when navigating to individual run pages
- Restore filters when navigating back from run detail view to runs list
- Update `v3RunPath` and `v3RunSpanPath` helpers to accept optional
searchParams
- Use `useOptimisticLocation` to capture current search params in
TaskRunsTable
- Parse `tableState` param in run detail route and pass filters to back
button

- This improves UX by remembering filter selections (task, status, date
range, etc.) when users click into a run and then navigate back to the
runs list

- Add new text-below variant that shows "Click to copy" tooltip on hover
and "Copied" on click. Also add controlled open/onOpenChange props to
SimpleTooltip for managing tooltip visibility.

---

## Screenshots


https://github.com/user-attachments/assets/5067bbe0-1bcd-4e75-80a7-f56dabd5ed69
2025-12-09 13:10:11 +00:00
Saadi Myftija b71bf89444 chore(releases): minor improvements to the release workflow (#2764)
Changes in this PR:
- Arbitrary refs are now allowed when triggering the release workflow
manually (ref must be on the main branch).
- Release summary is now displayed in the GH job output; makes for a
nicer experience when approving the release workflow.
2025-12-09 11:29:48 +01:00
Eric Allam 28c0c78257 fix(api): triggering a batch on a v4 project via the v3 SDK no longer results in errors (#2752)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Updates `BatchTriggerV3Service` to send `batch.id` (not `friendlyId`)
as `batchId` to `TriggerTaskService.call`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
68bf8df4a29bdcac5bfb2806bc417541f69ffd6e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-09 09:54:39 +00:00
Eric Allam c021d1db63 fix(dev): stop max listeners exceeded warning messages when running more than 10 runs concurrently (#2771)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Centralizes SIGTERM handling in `DevSupervisor` and removes per-run
SIGTERM listeners in `DevRunController` to avoid
MaxListenersExceededWarning under high concurrency.
> 
> - **Dev runtime**:
> - **SIGTERM handling**: Add centralized handler in
`packages/cli-v3/src/dev/devSupervisor.ts` to gracefully stop all run
controllers; unregisters on `shutdown()`.
> - **Cleanup**: Remove per-controller `SIGTERM` listener and handler
from `packages/cli-v3/src/entryPoints/dev-run-controller.ts` to reduce
event listeners and warnings.
> - **Changeset**: Add patch note in
`.changeset/fuzzy-ghosts-admire.md`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5ad2f5341829cebf6fd37a3c616a2db5e4ad936a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-09 09:54:28 +00:00
Eric Allam f62cdfe00e feat(dashboard): login with google and "last used" indicator (#2746)
<img width="568" height="513" alt="CleanShot 2025-12-05 at 14 27 16"
src="https://github.com/user-attachments/assets/1f44d8b9-8791-4b44-96d5-4a0960a1ab36"
/>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Google OAuth login and a cookie-based “last used” indicator on
the login page, with supporting backend, routes, and schema updates.
> 
> - **Auth/Backend**:
> - **Google OAuth**: Integrates `remix-auth-google` via new
`addGoogleStrategy` and enables when `AUTH_GOOGLE_CLIENT_ID/SECRET` are
set (`services/googleAuth.server.ts`, `services/auth.server.ts`).
> - **User handling**: Implements `findOrCreateGoogleUser` with
linking/upsert logic and conflict logging (`models/user.server.ts`).
> - **MFA + session**: Google/GitHub/Magic callbacks now set session,
handle MFA, and set a "last-auth-method" cookie
(`routes/auth.google*.tsx`, `routes/auth.github.callback.tsx`,
`routes/magic.tsx`, `services/lastAuthMethod.server.ts`).
> - **GitHub strategy**: Safer email check
(`services/gitHubAuth.server.ts`).
> - **Routes/UI**:
> - **Login page**: Adds "Continue with Google" button and animated
"Last used" badge based on cookie; keeps GitHub/Email options
(`routes/login._index/route.tsx`).
> - **Redirect safety**: Sanitize redirect paths and persist redirect
via cookies in auth actions (`routes/auth.github.ts`,
`routes/auth.google.ts`).
>   - **Assets**: Adds `GoogleLogo` SVG.
>   - **Avatar**: Set `referrerPolicy="no-referrer"` on profile image.
> - **Config/Schema**:
> - **Env**: Adds `AUTH_GOOGLE_CLIENT_ID`/`AUTH_GOOGLE_CLIENT_SECRET`
(`env.server.ts`).
> - **DB**: Extends `AuthenticationMethod` enum with `GOOGLE` (Prisma
schema + migration).
> - **Dependencies**:
>   - Adds `remix-auth-google` in `package.json`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9f84f974bd6f21f1699c4f69a6aa91616842d1b1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-12-09 09:51:42 +00:00
nicktrn 66c6da7114 security: dependabot alert triage (#2768)
- Upgrade @modelcontextprotocol/sdk to 1.24.3
- Override jws to 3.2.3
2025-12-08 21:02:13 +00:00
Saadi Myftija 7fba9e9f6b feat(deployments): add build server meta and trigger source info (#2767)
This PR applies a small change to the deployments table to keep track
of:
- where the deployment was triggered from
- build server metadata, if the build server was involved
2025-12-08 16:29:26 +00:00
Saadi Myftija cf63fc9cd2 chore: update version requirement hint in build settings (#2757) 2025-12-08 09:27:28 +01:00
Saadi Myftija d1c3bfb9c9 fix(deployments): ecr repo exists check (#2762)
We recently upgraded the ECR sdk version. Our ECR repo exists check
relies on the type of the error thrown and the new ECR sdk version seems
to have broken that behavior. This PR adds a workaround to the issue.
2025-12-05 19:16:28 +01:00
Saadi Myftija d8f5853457 fix(releases): use contents: write permission in the release workflow (#2761)
Needed to push tags
2025-12-05 17:22:24 +01:00
Saadi Myftija a52566d9cc feat(releases): add a manual trigger for the package release workflow (#2759)
The manual trigger is currently enabled only for the prerelease job.
This PR adds it for the normal release flow too, as it is useful to
retrigger failed release workflows.
2025-12-05 17:00:02 +01:00
Saadi Myftija 07a1d04d52 fix(releases): add missing npm upgrade step (#2758)
Accidentally removed this in a previous PR.
2025-12-05 16:41:45 +01:00
217 changed files with 36516 additions and 2200 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
fix(cli): update command should preserve existing package.json order
+6
View File
@@ -0,0 +1,6 @@
---
description: how to create and apply database migrations
alwaysApply: false
---
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
+7 -1
View File
@@ -85,4 +85,10 @@ POSTHOG_PROJECT_KEY=
# These control the server-side internal telemetry
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
@@ -1,4 +1,4 @@
name: 🦋 Changeset PR
name: 🦋 Changesets PR
on:
push:
+1
View File
@@ -1,6 +1,7 @@
name: 🚀 Publish Trigger.dev Docker
on:
workflow_dispatch:
workflow_call:
inputs:
image_tag:
+47 -16
View File
@@ -7,13 +7,21 @@ on:
- main
workflow_dispatch:
inputs:
type:
description: "Select release type"
required: true
type: choice
options:
- release
- prerelease
default: "prerelease"
ref:
description: "The ref (branch, tag, or SHA) to checkout and release from"
required: true
type: string
tag:
prerelease_tag:
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
required: true
required: false
type: string
default: "prerelease"
@@ -22,19 +30,35 @@ concurrency:
cancel-in-progress: false
jobs:
show-release-summary:
name: 📋 Release Summary
runs-on: ubuntu-latest
if: |
github.repository == 'triggerdotdev/trigger.dev' &&
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref == 'changeset-release/main'
steps:
- name: Show release summary
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
release:
name: 🚀 Release npm packages
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: read
contents: write
packages: write
id-token: write
if: |
github.repository == 'triggerdotdev/trigger.dev' &&
github.event_name != 'workflow_dispatch' &&
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'changeset-release/')
(
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
)
outputs:
published: ${{ steps.changesets.outputs.published }}
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
@@ -44,6 +68,15 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
- name: Verify ref is on main
if: github.event_name == 'workflow_dispatch'
run: |
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
echo "Error: ref must be an ancestor of main (i.e., already merged)"
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -56,6 +89,11 @@ jobs:
node-version: 20.19.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
- name: Setup npm 11.x for OIDC
run: npm install -g npm@11.6.4
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -100,7 +138,7 @@ jobs:
permissions:
contents: read
id-token: write
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch'
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
steps:
- name: Checkout repo
uses: actions/checkout@v4
@@ -108,13 +146,6 @@ jobs:
fetch-depth: 0
ref: ${{ github.event.inputs.ref }}
- name: Validate ref is on main
run: |
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
echo "Error: ref must be an ancestor of main (i.e., already merged)"
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -138,7 +169,7 @@ jobs:
run: pnpm run generate
- name: Snapshot version
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.tag }}
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -149,6 +180,6 @@ jobs:
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
- name: Publish prerelease
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.tag }}
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -35,6 +35,8 @@ jobs:
- name: 🔎 Type check
run: pnpm run typecheck
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: 🔎 Check exports
run: pnpm run check-exports
+2 -1
View File
@@ -62,4 +62,5 @@ apps/**/public/build
/packages/trigger-sdk/src/package.json
/packages/python/src/package.json
.claude
.mcp.log
.mcp.log
.cursor/debug.log
+1 -1
View File
@@ -4,7 +4,7 @@
### Build and deploy fullymanaged AI agents and workflows
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red.svg)](https://github.com/triggerdotdev/trigger.dev)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
+121
View File
@@ -0,0 +1,121 @@
## Creating and applying migrations
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
Edit the `schema.prisma` file to add or modify the schema.
Create a new migration file but don't apply it yet:
```bash
cd internal-packages/database
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
```
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
```sql
-- AlterEnum
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
-- AlterTable
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
-- AlterTable
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
-- AlterTable
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
-- AlterTable
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_completedWaitpoints_AB_unique";
-- CreateIndex
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
```
All the following lines should be removed:
```sql
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
-- AlterTable
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
-- AlterTable
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
-- AlterTable
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_completedWaitpoints_AB_unique";
-- CreateIndex
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
```
Leaving only this:
```sql
-- AlterEnum
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
-- AlterTable
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
```
After editing the migration file, apply the migration:
```bash
cd internal-packages/database
pnpm run db:migrate:deploy && pnpm run generate
```
+8
View File
@@ -244,6 +244,12 @@ class ManagedSupervisor {
}
try {
if (!message.deployment.friendlyId) {
// mostly a type guard, deployments always exists for deployed environments
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
throw new Error("Deployment is missing");
}
await this.workloadManager.create({
dequeuedAt: message.dequeuedAt,
envId: message.environment.id,
@@ -252,6 +258,8 @@ class ManagedSupervisor {
machine: message.run.machine,
orgId: message.organization.id,
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`TRIGGER_ENV_ID=${opts.envId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
name: "TRIGGER_ENV_ID",
value: opts.envId,
},
{
name: "TRIGGER_DEPLOYMENT_ID",
value: opts.deploymentFriendlyId,
},
{
name: "TRIGGER_DEPLOYMENT_VERSION",
value: opts.deploymentVersion,
},
{
name: "TRIGGER_SNAPSHOT_ID",
value: opts.snapshotFriendlyId,
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
envType: EnvironmentType;
orgId: string;
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
@@ -0,0 +1,22 @@
export function GoogleLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
fill="#4285F4"
/>
<path
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
fill="#34A853"
/>
<path
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
fill="#FBBC02"
/>
<path
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
fill="#EA4335"
/>
</svg>
);
}
+148 -132
View File
@@ -52,6 +52,13 @@ import {
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "./primitives/ClientTabs";
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export function HasNoTasksDev() {
return (
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
);
return <DeploymentOnboardingSteps />;
}
export function DeploymentsNoneDev() {
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
<>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="" title="Switch to a deployed environment" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>
This is the Development environment. When you're ready to deploy your tasks, switch to a
different environment.
</Paragraph>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous
Integration service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
/>
</StepContentContainer>
</>
);
}
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
</div>
);
}
function DeploymentOnboardingSteps() {
const environment = useEnvironment();
const organization = useOrganization();
const project = useProject();
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<ClientTabs defaultValue="github">
<ClientTabsList variant="segmented" className="mb-6">
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
GitHub
</ClientTabsTrigger>
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
Manual
</ClientTabsTrigger>
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
GitHub Actions
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"github"}>
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
<StepContentContainer>
<Paragraph spacing>
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"github-actions"}>
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
</ClientTabsContent>
</ClientTabs>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</PackageManagerProvider>
);
}
+4
View File
@@ -134,6 +134,10 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to adjacent">
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
</Shortcut>
@@ -22,6 +22,7 @@ export function UserAvatar({
className={cn("aspect-square rounded-full p-[7%]")}
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
/>
</div>
) : (
@@ -331,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<
LinkProps,
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({
to,
onClick,
@@ -340,6 +340,7 @@ export const LinkButton = ({
onMouseLeave,
download,
disabled = false,
replace,
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
@@ -372,7 +373,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -387,7 +388,8 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
replace={replace}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -408,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
return (
<NavLink
to={to}
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
@@ -1,41 +1,185 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/utils/cn";
import { type Variants } from "./Tabs";
type ClientTabsContextValue = {
value?: string;
};
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
function useClientTabsContext() {
return React.useContext(ClientTabsContext);
}
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
React.useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
const handleValueChange = React.useCallback(
(nextValue: string) => {
if (valueProp === undefined) {
setValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, valueProp]
);
const controlledProps =
valueProp !== undefined
? { value: valueProp }
: defaultValue !== undefined
? { defaultValue }
: {};
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
return (
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
onValueChange={handleValueChange}
{...controlledProps}
{...props}
/>
</ClientTabsContext.Provider>
);
});
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex items-center justify-center transition duration-100", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
variant?: Variants;
}
>(({ className, variant = "pipe-divider", ...props }, ref) => {
const variantClassName = (() => {
switch (variant) {
case "segmented":
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
case "underline":
return "flex gap-x-6 border-b border-grid-bright";
default:
return "inline-flex items-center justify-center transition duration-100";
}
})();
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
});
ClientTabsList.displayName = TabsPrimitive.List.displayName;
const ClientTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
variant?: Variants;
layoutId?: string;
}
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
const context = useClientTabsContext();
const activeValue = context?.value;
const isActive = activeValue === props.value;
if (variant === "segmented") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
className
)}
{...props}
>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
isActive
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{isActive ? (
layoutId ? (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
) : (
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
)
) : null}
</TabsPrimitive.Trigger>
);
}
if (variant === "underline") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{layoutId ? (
isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)
) : isActive ? (
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
}
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
</TabsPrimitive.Trigger>
);
});
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const ClientTabsContent = React.forwardRef<
@@ -61,39 +205,7 @@ export type TabsProps = {
currentValue: string;
className?: string;
layoutId: string;
variant?: Variants;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-charcoal-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
@@ -3,59 +3,95 @@ import { useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
export function CopyableText({
value,
copyValue,
className,
asChild,
variant,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
variant?: "icon-right" | "text-below";
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
return (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
const resolvedVariant = variant ?? "icon-right";
if (resolvedVariant === "icon-right") {
return (
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
</span>
</span>
</span>
);
);
}
if (resolvedVariant === "text-below") {
return (
<SimpleTooltip
button={
<Button
variant="minimal/small"
onClick={(e) => {
e.stopPropagation();
copy();
}}
className={cn(
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
className
)}
>
<span>{value}</span>
</Button>
}
content={copied ? "Copied" : "Click to copy"}
className="font-sans px-2 py-1"
disableHoverableContent
open={isHovered || copied}
onOpenChange={setIsHovered}
/>
);
}
return null;
}
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const medium =
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
+112 -18
View File
@@ -1,10 +1,12 @@
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
export type TabsProps = {
tabs: {
label: string;
@@ -12,13 +14,14 @@ export type TabsProps = {
}[];
className?: string;
layoutId: string;
variant?: Variants;
};
export function Tabs({ tabs, className, layoutId }: TabsProps) {
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
return (
<TabContainer className={className}>
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink key={index} to={tab.to} layoutId={layoutId}>
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
{tab.label}
</TabLink>
))}
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
);
}
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
{children}
</div>
);
export function TabContainer({
children,
className,
variant = "underline",
}: {
children: ReactNode;
className?: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<div
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
>
{children}
</div>
);
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
);
}
return <div className={cn(`flex`, className)}>{children}</div>;
}
export function TabLink({
to,
children,
layoutId,
variant = "underline",
}: {
to: string;
children: ReactNode;
layoutId: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
active
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active && (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
to={to}
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<span
className={cn(
"text-sm transition duration-200",
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
)}
>
{children}
</span>
);
}}
</NavLink>
);
}
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
{({ isActive, isPending }) => {
@@ -51,13 +138,19 @@ export function TabLink({
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-text-bright" : "text-text-bright"
isActive || isPending
? "text-text-bright"
: "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -106,17 +199,18 @@ export function TabButton({
<>
<div className="flex items-center gap-1">
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-bright"
)}
className={"text-sm transition duration-200 text-text-bright"}
>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
};
type Variant = keyof typeof variantClasses;
@@ -64,6 +64,8 @@ function SimpleTooltip({
buttonStyle,
asChild = false,
sideOffset,
open,
onOpenChange,
}: {
button: React.ReactNode;
content: React.ReactNode;
@@ -76,10 +78,12 @@ function SimpleTooltip({
buttonStyle?: React.CSSProperties;
asChild?: boolean;
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<Tooltip open={open} onOpenChange={onOpenChange}>
<TooltipTrigger
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
}
case "Left":
case "ArrowLeft": {
if (e.metaKey) {
return;
}
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
@@ -1,17 +1,27 @@
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
Array<BatchTaskRunStatus>
>;
export const allBatchStatuses = [
"PROCESSING",
"PENDING",
"COMPLETED",
"PARTIAL_FAILED",
"ABORTED",
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
const descriptions: Record<BatchTaskRunStatus, string> = {
PROCESSING: "The batch is being processed and runs are being created.",
PENDING: "The batch has child runs that have not yet completed.",
COMPLETED: "All the batch child runs have finished.",
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
ABORTED: "The batch was aborted because child tasks could not be triggered.",
};
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
className: string;
}) {
switch (status) {
case "PROCESSING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "PENDING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
case "PARTIAL_FAILED":
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
case "ABORTED":
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
default: {
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
export function batchStatusColor(status: BatchTaskRunStatus): string {
switch (status) {
case "PROCESSING":
return "text-blue-500";
case "PENDING":
return "text-pending";
case "COMPLETED":
return "text-success";
case "PARTIAL_FAILED":
return "text-warning";
case "ABORTED":
return "text-error";
default: {
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
export function batchStatusTitle(status: BatchTaskRunStatus): string {
switch (status) {
case "PROCESSING":
return "Processing";
case "PENDING":
return "In progress";
case "COMPLETED":
return "Completed";
case "PARTIAL_FAILED":
return "Partial failure";
case "ABORTED":
return "Aborted";
default: {
@@ -55,6 +55,8 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
type RunsTableProps = {
total: number;
@@ -62,9 +64,11 @@ type RunsTableProps = {
filters: NextRunListAppliedFilters;
showJob?: boolean;
runs: NextRunListItem[];
rootOnlyDefault?: boolean;
isLoading?: boolean;
allowSelection?: boolean;
variant?: TableVariant;
disableAdjacentRows?: boolean;
};
export function TaskRunsTable({
@@ -72,6 +76,8 @@ export function TaskRunsTable({
hasFilters,
filters,
runs,
rootOnlyDefault,
disableAdjacentRows = false,
isLoading = false,
allowSelection = false,
variant = "dimmed",
@@ -81,6 +87,12 @@ export function TaskRunsTable({
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
const { value } = useSearchParams();
const location = useOptimisticLocation();
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
const showCompute = isManagedCloud;
@@ -293,16 +305,20 @@ export function TaskRunsTable({
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run, index) => {
const searchParams = new URLSearchParams();
if (tableStateParam) {
searchParams.set("tableState", tableStateParam);
}
const path = v3RunSpanPath(organization, project, run.environment, run, {
spanId: run.spanId,
});
}, searchParams);
return (
<TableRow key={run.id}>
{allowSelection && (
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.friendlyId)}
onChange={(element) => {
onChange={() => {
toggle(run.friendlyId);
}}
ref={(r) => {
+15 -15
View File
@@ -1,22 +1,28 @@
import {
createReadableStreamFromReadable,
type DataFunctionArgs,
type EntryContext,
} from "@remix-run/node"; // or cloudflare/deno
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
import { RemixServer } from "@remix-run/react";
import { wrapHandleErrorWithSentry } from "@sentry/remix";
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { PassThrough } from "stream";
import * as Worker from "~/services/worker.server";
import { bootstrap } from "./bootstrap";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import {
OperatingSystemContextProvider,
OperatingSystemPlatform,
} from "./components/primitives/OperatingSystemProvider";
import { Prisma } from "./db.server";
import { env } from "./env.server";
import { eventLoopMonitor } from "./eventLoopMonitor.server";
import { logger } from "./services/logger.server";
import { resourceMonitor } from "./services/resourceMonitor.server";
import { singleton } from "./utils/singleton";
import { bootstrap } from "./bootstrap";
import { wrapHandleErrorWithSentry } from "@sentry/remix";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
import {
registerRunEngineEventBusHandlers,
setupBatchQueueCallbacks,
} from "./v3/runEngineHandlers.server";
const ABORT_DELAY = 30000;
@@ -228,19 +234,13 @@ process.on("uncaughtException", (error, origin) => {
});
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
export { apiRateLimiter } from "./services/apiRateLimit.server";
export { engineRateLimiter } from "./services/engineRateLimit.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
export { socketIo } from "./v3/handleSocketIo.server";
export { wss } from "./v3/handleWebsockets.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
import { eventLoopMonitor } from "./eventLoopMonitor.server";
import { env } from "./env.server";
import { logger } from "./services/logger.server";
import { Prisma } from "./db.server";
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
import { resourceMonitor } from "./services/resourceMonitor.server";
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
eventLoopMonitor.enable();
+26
View File
@@ -94,6 +94,8 @@ const EnvironmentSchema = z
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
FROM_EMAIL: z.string().optional(),
REPLY_TO_EMAIL: z.string().optional(),
@@ -526,6 +528,7 @@ const EnvironmentSchema = z
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
@@ -535,6 +538,14 @@ const EnvironmentSchema = z
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
// 2-phase batch API settings
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
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),
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
REALTIME_STREAM_TTL: z.coerce
@@ -600,6 +611,12 @@ const EnvironmentSchema = z
.default(60_000),
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
.number()
.int()
.default(60_000 * 60), // 1 hour
RUN_ENGINE_WORKER_REDIS_HOST: z
.string()
.optional()
@@ -929,6 +946,15 @@ const EnvironmentSchema = z
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
// BatchQueue DRR settings (Run Engine v2)
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
// 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(),
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
+142 -1
View File
@@ -1,5 +1,6 @@
import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import type { GoogleProfile } from "remix-auth-google";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import {
@@ -8,6 +9,8 @@ import {
} from "~/services/dashboardPreferences.server";
export type { User } from "@trigger.dev/database";
import { assertEmailAllowed } from "~/utils/email";
import { logger } from "~/services/logger.server";
type FindOrCreateMagicLink = {
authenticationMethod: "MAGIC_LINK";
email: string;
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
type FindOrCreateGoogle = {
authenticationMethod: "GOOGLE";
email: User["email"];
authenticationProfile: GoogleProfile;
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
type LoggedInUser = {
user: User;
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
case "MAGIC_LINK": {
return findOrCreateMagicLinkUser(input);
}
case "GOOGLE": {
return findOrCreateGoogleUser(input);
}
}
}
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
};
}
export async function findOrCreateGoogleUser({
email,
authenticationProfile,
authenticationExtraParams,
}: FindOrCreateGoogle): Promise<LoggedInUser> {
assertEmailAllowed(email);
const name = authenticationProfile._json.name;
let avatarUrl: string | undefined = undefined;
if (authenticationProfile.photos[0]) {
avatarUrl = authenticationProfile.photos[0].value;
}
const displayName = authenticationProfile.displayName;
const authProfile = authenticationProfile
? (authenticationProfile as unknown as Prisma.JsonObject)
: undefined;
const authExtraParams = authenticationExtraParams
? (authenticationExtraParams as unknown as Prisma.JsonObject)
: undefined;
const authIdentifier = `google:${authenticationProfile.id}`;
const existingUser = await prisma.user.findUnique({
where: {
authIdentifier,
},
});
const existingEmailUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingEmailUser && !existingUser) {
// Link existing email account to Google auth, preserving original authenticationMethod
const user = await prisma.user.update({
where: {
email,
},
data: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
avatarUrl,
authIdentifier,
},
});
return {
user,
isNewUser: false,
};
}
if (existingEmailUser && existingUser) {
// Check if email user and auth user are the same
if (existingEmailUser.id !== existingUser.id) {
// Different users: email is taken by one user, Google auth belongs to another
logger.error(
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
{
email,
existingEmailUserId: existingEmailUser.id,
existingAuthUserId: existingUser.id,
authIdentifier,
}
);
return {
user: existingUser,
isNewUser: false,
};
}
// Same user: update all profile fields
const user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {
email,
displayName,
name,
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
});
return {
user,
isNewUser: false,
};
}
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
// It's not possible that the email is already taken by a different user because that would have been handled
// by one of the if statements above.
const user = await prisma.user.upsert({
where: {
authIdentifier,
},
update: {
email,
displayName,
name,
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
create: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
name,
avatarUrl,
displayName,
authIdentifier,
email,
authenticationMethod: "GOOGLE",
},
});
return {
user,
isNewUser: !existingUser,
};
}
export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
@@ -195,7 +195,7 @@ WHERE
throw new Error(`Environment not found for Batch ${batch.id}`);
}
const hasFinished = batch.status !== "PENDING";
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
return {
id: batch.id,
@@ -0,0 +1,122 @@
import { type BatchTaskRunStatus } from "@trigger.dev/database";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
type BatchPresenterOptions = {
environmentId: string;
batchId: string;
userId?: string;
};
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
export class BatchPresenter extends BasePresenter {
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
const batch = await this._replica.batchTaskRun.findFirst({
select: {
id: true,
friendlyId: true,
status: true,
runCount: true,
batchVersion: true,
createdAt: true,
updatedAt: true,
completedAt: true,
processingStartedAt: true,
processingCompletedAt: true,
successfulRunCount: true,
failedRunCount: true,
idempotencyKey: true,
runtimeEnvironment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
errors: {
select: {
id: true,
index: true,
taskIdentifier: true,
error: true,
errorCode: true,
createdAt: true,
},
orderBy: {
index: "asc",
},
},
},
where: {
runtimeEnvironmentId: environmentId,
friendlyId: batchId,
},
});
if (!batch) {
throw new Error("Batch not found");
}
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
const isV2 = batch.batchVersion === "runengine:v2";
// For v2 batches in PROCESSING state, get live progress from Redis
// This provides real-time updates without waiting for the batch to complete
let liveSuccessCount = batch.successfulRunCount ?? 0;
let liveFailureCount = batch.failedRunCount ?? 0;
if (isV2 && batch.status === "PROCESSING") {
const liveProgress = await engine.getBatchQueueProgress(batch.id);
if (liveProgress) {
liveSuccessCount = liveProgress.successCount;
liveFailureCount = liveProgress.failureCount;
}
}
return {
id: batch.id,
friendlyId: batch.friendlyId,
status: batch.status as BatchTaskRunStatus,
runCount: batch.runCount,
batchVersion: batch.batchVersion,
isV2,
createdAt: batch.createdAt.toISOString(),
updatedAt: batch.updatedAt.toISOString(),
completedAt: batch.completedAt?.toISOString(),
processingStartedAt: batch.processingStartedAt?.toISOString(),
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
finishedAt: batch.completedAt
? batch.completedAt.toISOString()
: hasFinished
? batch.updatedAt.toISOString()
: undefined,
hasFinished,
successfulRunCount: liveSuccessCount,
failedRunCount: liveFailureCount,
idempotencyKey: batch.idempotencyKey,
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
errors: batch.errors.map((error) => ({
id: error.id,
index: error.index,
taskIdentifier: error.taskIdentifier,
error: error.error,
errorCode: error.errorCode,
createdAt: error.createdAt.toISOString(),
})),
};
}
}
@@ -1,4 +1,5 @@
import {
BuildServerMetadata,
DeploymentErrorData,
ExternalBuildData,
prepareDeploymentError,
@@ -154,17 +155,23 @@ export class DeploymentPresenter {
avatarUrl: true,
},
},
buildServerMetadata: true,
},
});
const gitMetadata = processGitMetadata(deployment.git);
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
const buildServerMetadata = deployment.buildServerMetadata
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
: undefined;
let eventStream = undefined;
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
if (
env.S2_ENABLED === "1" &&
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
) {
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
if (error) {
@@ -0,0 +1,137 @@
import { type PrismaClient } from "@trigger.dev/database";
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { BasePresenter } from "./basePresenter.server";
type GitHubSettingsOptions = {
projectId: string;
organizationId: string;
};
export class GitHubSettingsPresenter extends BasePresenter {
public call({ projectId, organizationId }: GitHubSettingsOptions) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
if (!githubAppEnabled) {
return ok({
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
});
}
const findConnectedGithubRepository = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = () =>
fromPromise(
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
const isPreviewEnvironmentEnabled = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return ResultAsync.combine([
isPreviewEnvironmentEnabled(),
findConnectedGithubRepository(),
listGithubAppInstallations(),
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
}));
}
}
@@ -234,6 +234,7 @@ export class SpanPresenter extends BasePresenter {
environmentId: run.runtimeEnvironment.id,
idempotencyKey: run.idempotencyKey,
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
queue: {
name: run.queue,
@@ -357,6 +358,8 @@ export class SpanPresenter extends BasePresenter {
//idempotency
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
//debounce
debounce: true,
//delayed
delayUntil: true,
//ttl
@@ -0,0 +1,307 @@
import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { motion } from "framer-motion";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatNumber } from "~/utils/numberFormatter";
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
const BatchParamSchema = EnvironmentParamSchema.extend({
batchParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, batchParam } =
BatchParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
try {
const presenter = new BatchPresenter();
const [error, data] = await tryCatch(
presenter.call({
environmentId: environment.id,
batchId: batchParam,
userId,
})
);
if (error) {
throw new Error(error.message);
}
return typedjson({ batch: data });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const { batch } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Auto-reload when batch is still in progress
useAutoRevalidate({
interval: 1000,
onFocus: true,
disabled: batch.hasFinished,
});
const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
{/* Header */}
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
<LinkButton
to={v3BatchesPath(organization, project, environment)}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Status bar */}
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BatchStatusCombo status={batch.status} />
<Paragraph variant="extra-small" className="text-text-dimmed">
{descriptionForBatchStatus(batch.status)}
</Paragraph>
</div>
{/* Scrollable content */}
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="space-y-3">
{/* Progress meter for v2 batches */}
{showProgressMeter && (
<div className="px-3 pt-3">
<BatchProgressMeter
successCount={batch.successfulRunCount}
failureCount={batch.failedRunCount}
totalCount={batch.runCount}
/>
</div>
)}
{/* Properties */}
<div className="px-3 py-3">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={batch.friendlyId} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<BatchStatusCombo status={batch.status} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total runs</Property.Label>
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
</Property.Item>
{batch.isV2 && (
<>
<Property.Item>
<Property.Label>Successfully created</Property.Label>
<Property.Value className="text-success">
{formatNumber(batch.successfulRunCount)}
</Property.Value>
</Property.Item>
{batch.failedRunCount > 0 && (
<Property.Item>
<Property.Label>Failed to create</Property.Label>
<Property.Value className="text-error">
{formatNumber(batch.failedRunCount)}
</Property.Value>
</Property.Item>
)}
</>
)}
{batch.idempotencyKey && (
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={batch.createdAt} />
</Property.Value>
</Property.Item>
{batch.processingStartedAt && (
<Property.Item>
<Property.Label>Processing started</Property.Label>
<Property.Value>
<DateTime date={batch.processingStartedAt} />
</Property.Value>
</Property.Item>
)}
{batch.processingCompletedAt && (
<Property.Item>
<Property.Label>Processing completed</Property.Label>
<Property.Value>
<DateTime date={batch.processingCompletedAt} />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Finished</Property.Label>
<Property.Value>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
{/* Errors section */}
{batch.errors.length > 0 && (
<div className="px-3 pb-3">
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
<ExclamationTriangleIcon className="size-4" />
Run creation errors ({batch.errors.length})
</Header3>
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-charcoal-900">
{batch.errors.map((error) => (
<div key={error.id} className="px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-text-dimmed">
Item #{error.index}
</span>
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
</div>
{error.errorCode && (
<span className="rounded bg-charcoal-750 px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
{error.errorCode}
</span>
)}
</div>
<Paragraph variant="small" className="mt-1 text-error">
{error.error}
</Paragraph>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
<LinkButton
variant="tertiary/medium"
to={v3BatchRunsPath(organization, project, environment, batch)}
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
TrailingIcon={ArrowRightIcon}
>
View runs
</LinkButton>
</div>
</div>
);
}
type BatchProgressMeterProps = {
successCount: number;
failureCount: number;
totalCount: number;
};
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
const processedCount = successCount + failureCount;
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Paragraph variant="small/bright">Run creation progress</Paragraph>
<Paragraph variant="extra-small">
{formatNumber(processedCount)}/{formatNumber(totalCount)}
</Paragraph>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
<motion.div
className="absolute left-0 top-0 h-full bg-success"
initial={{ width: `${successPercentage}%` }}
animate={{ width: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
<motion.div
className="absolute top-0 h-full bg-error"
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-success" />
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
</div>
{failureCount > 0 && (
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-error" />
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
</div>
)}
</div>
</div>
);
}
@@ -1,10 +1,6 @@
import {
ArrowPathRoundedSquareIcon,
ArrowRightIcon,
ExclamationCircleIcon,
} from "@heroicons/react/20/solid";
import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -12,12 +8,15 @@ import { BatchesNone } from "~/components/BlankStatePanels";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
@@ -36,7 +35,6 @@ import {
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -44,13 +42,14 @@ import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type BatchList,
type BatchListItem,
BatchListPresenter,
} from "~/presenters/v3/BatchListPresenter.server";
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
import {
docsPath,
EnvironmentParamSchema,
v3BatchPath,
v3BatchRunsPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
@@ -101,6 +100,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export default function Page() {
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
useTypedLoaderData<typeof loader>();
const { batchParam } = useParams();
const isShowingInspector = batchParam !== undefined;
return (
<PageContainer>
@@ -123,22 +124,34 @@ export default function Page() {
<BatchesNone />
</MainCenteredContainer>
) : (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="batches-main" min={"100px"}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
hasAnyBatches={hasAnyBatches}
/>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
hasAnyBatches={hasAnyBatches}
/>
</div>
</ResizablePanel>
{isShowingInspector && (
<>
<ResizableHandle id="batches-handle" />
<ResizablePanel id="batches-inspector" min="100px" default="500px">
<Outlet />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
@@ -147,10 +160,14 @@ export default function Page() {
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const location = useLocation();
const isLoading =
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { batchParam } = useParams();
return (
<Table className="max-h-full overflow-y-auto">
@@ -195,15 +212,19 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
</div>
</TableBlankRow>
) : (
batches.map((batch, index) => {
const path = v3BatchRunsPath(organization, project, environment, batch);
batches.map((batch) => {
const basePath = v3BatchPath(organization, project, environment, batch);
const inspectorPath = `${basePath}${location.search}`;
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
const isSelected = batchParam === batch.friendlyId;
return (
<TableRow key={batch.id}>
<TableCell to={path} isTabbableCell>
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
<TableCell to={inspectorPath} isTabbableCell>
{batch.friendlyId}
</TableCell>
<TableCell to={path}>
<TableCell to={inspectorPath}>
{batch.batchVersion === "v1" ? (
<SimpleTooltip
content="Upgrade to the latest SDK for batch statuses to appear."
@@ -223,8 +244,12 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
/>
)}
</TableCell>
<TableCell to={path}>{batch.runCount}</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
<TableCell
to={inspectorPath}
className="w-[1%]"
actionClassName="pr-0 tabular-nums"
>
{batch.finishedAt ? (
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
style: "short",
@@ -233,13 +258,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
<LiveTimer startTime={new Date(batch.createdAt)} />
)}
</TableCell>
<TableCell to={path}>
<TableCell to={inspectorPath}>
<DateTime date={batch.createdAt} />
</TableCell>
<TableCell to={path}>
<TableCell to={inspectorPath}>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</TableCell>
<BatchActionsCell batch={batch} path={path} />
<BatchActionsCell runsPath={runsPath} />
</TableRow>
);
})
@@ -257,48 +282,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
);
}
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
const location = useLocation();
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
return <TableCell to={path}>{""}</TableCell>;
}
function BatchActionsCell({ runsPath }: { runsPath: string }) {
return (
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
to={path}
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
title="View batch"
/>
{!batch.hasFinished && (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathRoundedSquareIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Try and resume
</Button>
</DialogTrigger>
<CheckBatchCompletionDialog
batchId={batch.id}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
hiddenButtons={
<LinkButton to={runsPath} variant="minimal/small" LeadingIcon={ArrowRightIcon}>
View runs
</LinkButton>
}
/>
);
@@ -359,11 +359,11 @@ export default function Page() {
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNone />
</MainCenteredContainer>
)}
@@ -2,6 +2,7 @@ import {
ArrowUturnLeftIcon,
BoltSlashIcon,
BookOpenIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
@@ -68,7 +69,6 @@ import {
eventBorderClassName,
} from "~/components/runs/v3/SpanTitle";
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
import { env } from "~/env.server";
import { useDebounce } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
@@ -88,6 +88,7 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -98,6 +99,13 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
const resizableSettings = {
parent: {
@@ -131,6 +139,103 @@ const resizableSettings = {
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
type RunsListNavigation = {
runs: Array<{ friendlyId: string; spanId: string }>;
pagination: { next?: string; previous?: string };
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
};
async function getRunsListFromTableState({
tableStateParam,
organizationSlug,
projectParam,
envParam,
runParam,
userId,
}: {
tableStateParam: string | null;
organizationSlug: string;
projectParam: string;
envParam: string;
runParam: string;
userId: string;
}): Promise<RunsListNavigation | null> {
if (!tableStateParam) {
return null;
}
try {
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
if (!project || !environment) {
return null;
}
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
pageSize: 25, // Load enough runs to provide navigation context
});
const runsList: RunsListNavigation = {
runs: currentPageResult.runs,
pagination: currentPageResult.pagination,
};
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.previous,
direction: "backward",
pageSize: 1, // We only need the last run from the previous page
});
if (prevPageResult.runs.length > 0) {
runsList.prevPageLastRun = {
friendlyId: prevPageResult.runs[0].friendlyId,
spanId: prevPageResult.runs[0].spanId,
cursor: currentPageResult.pagination.previous,
};
}
}
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.next,
direction: "forward",
pageSize: 1, // We only need the first run from the next page
});
if (nextPageResult.runs.length > 0) {
runsList.nextPageFirstRun = {
friendlyId: nextPageResult.runs[0].friendlyId,
spanId: nextPageResult.runs[0].spanId,
cursor: currentPageResult.pagination.next,
};
}
}
return runsList;
} catch (error) {
logger.error("Error loading runs list from tableState:", { error });
return null;
}
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -169,6 +274,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
const runsList = await getRunsListFromTableState({
tableStateParam: url.searchParams.get("tableState"),
organizationSlug,
projectParam,
envParam,
runParam,
userId,
});
return json({
run: result.run,
trace: result.trace,
@@ -177,13 +291,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
parent,
tree,
},
runsList,
});
};
type LoaderData = SerializeFrom<typeof loader>;
export default function Page() {
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
const { run, trace, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -191,16 +306,30 @@ export default function Page() {
logCount: trace?.events.length ?? 0,
isCompleted: run.completedAt !== null,
});
const { value } = useSearchParams();
const tableState = decodeURIComponent(value("tableState") ?? "");
const tableStateSearchParams = new URLSearchParams(tableState);
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const tabParam = value("tab") ?? undefined;
const spanParam = value("span") ?? undefined;
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam});
return (
<>
<NavBar>
<PageTitle
backButton={{
to: v3RunsPath(organization, project, environment),
to: v3RunsPath(organization, project, environment, filters),
text: "Runs",
}}
title={<CopyableText value={run.friendlyId} />}
title={<>
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono px-0 py-0 pb-[2px]"/>
{tableState && (<div className="flex">
<PreviousRunButton to={previousRunPath} />
<NextRunButton to={nextRunPath} />
</div>)}
</>}
/>
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
<PageAccessories>
@@ -276,14 +405,10 @@ export default function Page() {
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
) : (
<NoLogsView
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
)}
</PageBody>
@@ -291,7 +416,7 @@ export default function Page() {
);
}
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -385,7 +510,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
);
}
function NoLogsView({ run, resizable }: LoaderData) {
function NoLogsView({ run }: Pick<LoaderData, "run">) {
const plan = useCurrentPlan();
const organization = useOrganization();
@@ -819,7 +944,6 @@ function TimelineView({
scale,
rootSpanStatus,
rootStartedAt,
parentRef,
timelineScrollRef,
virtualizer,
events,
@@ -835,6 +959,7 @@ function TimelineView({
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
const maxTimelineWidth = minTimelineWidth * 10;
const disableSpansAnimations = rootSpanStatus !== "executing";
//we want to live-update the duration if the root span is still executing
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
@@ -1006,7 +1131,8 @@ function TimelineView({
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
eventBackgroundClassName(node.data)
)}
layoutId={`${node.id}-${event.name}`}
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
@@ -1024,7 +1150,8 @@ function TimelineView({
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
eventBorderClassName(node.data)
)}
layoutId={`${node.id}-${event.name}`}
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
@@ -1043,7 +1170,8 @@ function TimelineView({
>
<motion.div
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
layoutId={`mark-${node.id}`}
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
animate={disableSpansAnimations ? false : undefined}
/>
</Timeline.Span>
) : null}
@@ -1066,6 +1194,7 @@ function TimelineView({
}
node={node}
fadeLeft={isTopSpan && queuedDuration !== undefined}
disableAnimations={disableSpansAnimations}
/>
</>
) : (
@@ -1080,7 +1209,8 @@ function TimelineView({
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
eventBackgroundClassName(node.data)
)}
layoutId={node.id}
layoutId={disableSpansAnimations ? undefined : node.id}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
@@ -1312,8 +1442,9 @@ function SpanWithDuration({
showDuration,
node,
fadeLeft,
disableAnimations,
...props
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean }) {
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean; disableAnimations?: boolean }) {
return (
<Timeline.Span {...props}>
<motion.div
@@ -1323,7 +1454,8 @@ function SpanWithDuration({
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
)}
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
layoutId={node.id}
layoutId={disableAnimations ? undefined : node.id}
animate={disableAnimations ? false : undefined}
>
{node.data.isPartial && (
<div
@@ -1336,10 +1468,12 @@ function SpanWithDuration({
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
!showDuration && "opacity-0"
)}
animate={disableAnimations ? false : undefined}
>
<motion.div
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
layout="position"
layout={disableAnimations ? undefined : "position"}
animate={disableAnimations ? false : undefined}
>
{formatDurationMilliseconds(props.durationMs, {
style: "short",
@@ -1422,16 +1556,16 @@ function KeyboardShortcuts({
expandAllBelowDepth,
collapseAllBelowDepth,
toggleExpandLevel,
setShowDurations,
}: {
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
toggleExpandLevel: (depth: number) => void;
setShowDurations: (show: (show: boolean) => boolean) => void;
setShowDurations?: (show: (show: boolean) => boolean) => void;
}) {
return (
<>
<ArrowKeyShortcuts />
<AdjacentRunsShortcuts />
<ShortcutWithAction
shortcut={{ key: "e" }}
action={() => expandAllBelowDepth(0)}
@@ -1448,6 +1582,16 @@ function KeyboardShortcuts({
);
}
function AdjacentRunsShortcuts() {
return (<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Adjacent runs
</Paragraph>
</div>);
}
function ArrowKeyShortcuts() {
return (
<div className="flex items-center gap-0.5">
@@ -1494,7 +1638,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
return (
<div className="flex items-center gap-0.5">
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
<span className="text-[0.75rem] text-text-dimmed"></span>
<span className="text-[0.65rem] text-text-dimmed"></span>
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Toggle level
@@ -1526,3 +1670,127 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
/>
);
}
function useAdjacentRunPaths({
organization,
project,
environment,
tableState,
run,
runsList,
tabParam,
useSpan
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
tableState: string;
run: { friendlyId: string, spanId: string };
runsList: RunsListNavigation | null;
tabParam?: string;
useSpan?: boolean;
}): [string | null, string | null] {
if (!runsList || runsList.runs.length === 0) {
return [null, null];
}
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
if (currentIndex === -1) {
return [null, null];
}
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
let previousRun: { friendlyId: string; spanId: string } | null = null;
const previousRunTableState = new URLSearchParams(tableState);
if (currentIndex > 0) {
previousRun = runsList.runs[currentIndex - 1];
} else if (runsList.prevPageLastRun) {
previousRun = runsList.prevPageLastRun;
// Update tableState with the new cursor for the previous page
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
previousRunTableState.set("direction", "backward");
}
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
let nextRun: { friendlyId: string; spanId: string } | null = null;
const nextRunTableState = new URLSearchParams(tableState);
if (currentIndex < runsList.runs.length - 1) {
nextRun = runsList.runs[currentIndex + 1];
} else if (runsList.nextPageFirstRun) {
nextRun = runsList.nextPageFirstRun;
// Update tableState with the new cursor for the next page
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
nextRunTableState.set("direction", "forward");
}
const previousURLSearchParams = new URLSearchParams();
previousURLSearchParams.set("tableState", previousRunTableState.toString());
if (previousRun && useSpan) {
previousURLSearchParams.set("span", previousRun.spanId);
}
if (tabParam && useSpan) {
previousURLSearchParams.set("tab", tabParam);
}
const previousRunPath = previousRun
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
: null;
const nextURLSearchParams = new URLSearchParams();
nextURLSearchParams.set("tableState", nextRunTableState.toString());
if (nextRun && useSpan) {
nextURLSearchParams.set("span", nextRun.spanId);
}
if (tabParam && useSpan) {
nextURLSearchParams.set("tab", tabParam);
}
const nextRunPath = nextRun
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
: null;
return [previousRunPath, nextRunPath];
}
function PreviousRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
LeadingIcon={ChevronUpIcon}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "[" }}
tooltip="Previous Run"
disabled={!to}
replace
/>
</div>
);
}
function NextRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
TrailingIcon={ChevronDownIcon}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "]" }}
tooltip="Next Run"
disabled={!to}
replace
/>
</div>
);
}
@@ -55,6 +55,7 @@ import {
v3CreateBulkActionPath,
v3ProjectPath,
v3TestPath,
v3TestTaskPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
@@ -235,7 +236,13 @@ function RunsList({
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
<RunTaskInstructions
task={
list.filters.tasks.length === 1
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
: undefined
}
/>
)
) : (
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
@@ -291,6 +298,7 @@ function RunsList({
runs={list.runs}
isLoading={isLoading}
allowSelection
rootOnlyDefault={rootOnlyDefault}
/>
</div>
)}
@@ -339,7 +347,7 @@ function CreateFirstTaskInstructions() {
);
}
function RunTaskInstructions() {
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -352,7 +360,11 @@ function RunTaskInstructions() {
Perform a test run with a payload directly from the dashboard.
</Paragraph>
<LinkButton
to={v3TestPath(organization, project, environment)}
to={
task
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
: v3TestPath(organization, project, environment)
}
variant="secondary/medium"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-lime-500"
@@ -318,6 +318,7 @@ export default function Page() {
runs={schedule.runs}
isLoading={false}
variant="bright"
disableAdjacentRows
/>
</div>
<div className="flex flex-col gap-1 pt-2">
@@ -1,35 +1,18 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
FolderIcon,
TrashIcon,
LockClosedIcon,
PlusIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
useActionData,
useNavigation,
useNavigate,
useSearchParams,
} from "@remix-run/react";
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 { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { OctoKitty } from "~/components/GitHubLoginButton";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -55,32 +38,12 @@ import {
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
organizationPath,
v3ProjectPath,
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
docsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import React, { useEffect, useState } from "react";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { type BranchTrackingConfig } from "~/v3/github";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import { useEnvironment } from "~/hooks/useEnvironment";
import { DateTime } from "~/components/primitives/DateTime";
import { TextLink } from "~/components/primitives/TextLink";
import { cn } from "~/utils/cn";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export const meta: MetaFunction = () => {
return [
@@ -128,29 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
githubAppEnabled: gitHubApp.enabled,
githubAppInstallations: gitHubApp.installations,
connectedGithubRepository: gitHubApp.connectedRepository,
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
buildSettings,
});
};
const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
});
const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
});
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
@@ -220,12 +164,7 @@ export function createSchema(
}
}),
}),
ConnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
UpdateBuildSettingsFormSchema,
z.object({
action: z.literal("disconnect-repo"),
}),
]);
}
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId, organizationId } = membershipResultOrFail.value;
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
@@ -316,101 +255,6 @@ export const action: ActionFunction = async ({ request, params }) => {
"Project deleted"
);
}
case "disconnect-repo": {
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to disconnect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
}
}
}
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
}
case "update-git-settings": {
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "github_app_not_enabled": {
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
}
case "connected_gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
}
case "production_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
}
case "staging_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update Git settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
}
case "connect-repo": {
const { repositoryId, installationId } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "GitHub repository not found");
}
case "project_already_has_connected_repository": {
return redirectBackWithErrorMessage(
request,
"Project already has a connected repository"
);
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to connect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
}
}
}
return json({
...submission,
success: true,
});
}
case "update-build-settings": {
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
@@ -446,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const {
githubAppInstallations,
connectedGithubRepository,
githubAppEnabled,
buildSettings,
isPreviewEnvironmentEnabled,
} = useTypedLoaderData<typeof loader>();
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
@@ -578,19 +416,12 @@ export default function Page() {
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
{connectedGithubRepository ? (
<ConnectedGitHubRepoForm
connectedGitHubRepo={connectedGithubRepository}
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
/>
) : (
<GitHubConnectionPrompt
gitHubAppInstallations={githubAppInstallations ?? []}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
/>
)}
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
@@ -650,489 +481,6 @@ export default function Page() {
);
}
type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
open?: boolean;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" {...form.props} className="w-full">
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
}) {
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
</InputGroup>
</Fieldset>
);
}
type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const organization = useOrganization();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<OctoKitty className="size-4" />
<a
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-bright hover:underline"
>
{connectedGitHubRepo.repository.fullName}
</a>
{connectedGitHubRepo.repository.private && (
<LockClosedIcon className="size-3 text-text-dimmed" />
)}
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">Disconnect</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="action" value="disconnect-repo" />
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
<Form method="post" {...gitSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
{environmentFullTitle({ type: "PRODUCTION" })}
</span>
</div>
<Input
{...conform.input(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
{environmentFullTitle({ type: "STAGING" })}
</span>
</div>
<Input
{...conform.input(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Switch
name="previewDeploymentsEnabled"
disabled={!previewEnvironmentEnabled}
defaultChecked={
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
}
variant="small"
label="Create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
{!previewEnvironmentEnabled && (
<InfoIconTooltip
content={
<span className="text-xs">
<TextLink to={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
enable preview branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
@@ -1248,7 +596,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
/>
<Hint>
Native build server builds do not rely on external build providers and will become the
default in the future. Version 4.1.3 or newer is required.
default in the future. Version 4.2.0 or newer is required.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.error}
@@ -126,6 +126,7 @@ export default function Page() {
runs={waitpoint.connectedRuns}
isLoading={false}
variant="bright"
disableAdjacentRows
/>
</div>
</div>
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
friendlyId: params.batchId,
runtimeEnvironmentId: auth.environment.id,
},
include: {
errors: true,
},
});
},
authorization: {
@@ -35,6 +38,18 @@ export const loader = createLoaderApiRoute(
updatedAt: batch.updatedAt,
runCount: batch.runCount,
runs: batch.runIds,
// Include error details for PARTIAL_FAILED batches
successfulRunCount: batch.successfulRunCount ?? undefined,
failedRunCount: batch.failedRunCount ?? undefined,
errors:
batch.errors.length > 0
? batch.errors.map((err) => ({
index: err.index,
taskIdentifier: err.taskIdentifier,
error: err.error,
errorCode: err.errorCode ?? undefined,
}))
: undefined,
});
}
);
@@ -0,0 +1,51 @@
import { json } from "@remix-run/server-runtime";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { logger } from "~/services/logger.server";
const ParamsSchema = z.object({
key: z.string(),
});
const BodySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});
export const { action } = createActionApiRoute(
{
params: ParamsSchema,
body: BodySchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "write",
resource: () => ({}),
superScopes: ["write:runs", "admin"],
},
},
async ({ params, body, authentication }) => {
const service = new ResetIdempotencyKeyService();
try {
const result = await service.call(
params.key,
body.taskIdentifier,
authentication.environment
);
return json(result, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 400 });
}
logger.error("Failed to reset idempotency key via API", {
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
});
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
);
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
friendlyId: params.batchId,
runtimeEnvironmentId: auth.environment.id,
},
include: {
errors: true,
},
});
},
authorization: {
@@ -33,8 +36,21 @@ export const loader = createLoaderApiRoute(
idempotencyKey: batch.idempotencyKey ?? undefined,
createdAt: batch.createdAt,
updatedAt: batch.updatedAt,
processingCompletedAt: batch.processingCompletedAt ?? undefined,
runCount: batch.runCount,
runs: batch.runIds,
processing: {
completedAt: batch.processingCompletedAt ?? undefined,
errors:
batch.errors.length > 0
? batch.errors.map((err) => ({
index: err.index,
taskIdentifier: err.taskIdentifier,
error: err.error,
errorCode: err.errorCode ?? undefined,
}))
: [],
},
});
}
);
@@ -110,6 +110,8 @@ const { action, loader } = createActionApiRoute(
? { traceparent, tracestate }
: { external: { traceparent, tracestate } };
// Note: SDK v4.3+ uses the 2-phase batch API (POST /api/v3/batches + streaming items)
// This endpoint is for backwards compatibility with older SDK versions
const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined);
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
@@ -0,0 +1,128 @@
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { env } from "~/env.server";
import {
StreamBatchItemsService,
createNdjsonParserStream,
streamToAsyncIterable,
} from "~/runEngine/services/streamBatchItems.server";
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { engine } from "~/v3/runEngine.server";
const ParamsSchema = z.object({
batchId: z.string(),
});
/**
* Phase 2 of 2-phase batch API: Stream batch items.
*
* POST /api/v3/batches/:batchId/items
*
* Accepts an NDJSON stream of batch items and enqueues them to the BatchQueue.
* Each line in the body should be a valid BatchItemNDJSON object.
*
* The stream is processed with backpressure - items are enqueued as they arrive.
* The batch is sealed when the stream completes successfully.
*/
export async function action({ request, params }: ActionFunctionArgs) {
// Validate params
const paramsResult = ParamsSchema.safeParse(params);
if (!paramsResult.success) {
return json({ error: "Invalid batch ID" }, { status: 400 });
}
const { batchId } = paramsResult.data;
// Validate content type
const contentType = request.headers.get("content-type") || "";
if (
!contentType.includes("application/x-ndjson") &&
!contentType.includes("application/ndjson")
) {
return json(
{
error: "Content-Type must be application/x-ndjson or application/ndjson",
},
{ status: 415 }
);
}
// Authenticate the request
const authResult = await authenticateApiRequestWithFailure(request, {
allowPublicKey: true,
});
if (!authResult.ok) {
return json({ error: authResult.error }, { status: 401 });
}
// Get the request body stream
const body = request.body;
if (!body) {
return json({ error: "Request body is required" }, { status: 400 });
}
logger.debug("Stream batch items request", {
batchId,
contentType,
envId: authResult.environment.id,
});
try {
// Create NDJSON parser transform stream
const parser = createNdjsonParserStream(env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE);
// Pipe the request body through the parser
const parsedStream = body.pipeThrough(parser);
// Convert to async iterable for the service
const itemsIterator = streamToAsyncIterable(parsedStream);
// Process the stream
const service = new StreamBatchItemsService();
const result = await service.call(authResult.environment, batchId, itemsIterator, {
maxItemBytes: env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE,
});
return json(result, { status: 200 });
} catch (error) {
logger.error("Stream batch items error", {
batchId,
error: {
message: (error as Error).message,
stack: (error as Error).stack,
},
});
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")
) {
return json({ error: error.message }, { status: 400 });
}
return json(
{ error: error.message },
{ status: 500, headers: { "x-should-retry": "false" } }
);
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export async function loader({ request }: LoaderFunctionArgs) {
// Return 405 for GET requests - only POST is allowed
return json(
{
error: "Method not allowed. Use POST to stream batch items.",
},
{ status: 405 }
);
}
+229
View File
@@ -0,0 +1,229 @@
import { json } from "@remix-run/server-runtime";
import { CreateBatchRequestBody, CreateBatchResponse, generateJWT } from "@trigger.dev/core/v3";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server";
import { CreateBatchService } from "~/runEngine/services/createBatch.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
handleRequestIdempotency,
saveRequestIdempotency,
} from "~/utils/requestIdempotency.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
import { engine } from "~/v3/runEngine.server";
/**
* Phase 1 of 2-phase batch API: Create a batch.
*
* POST /api/v3/batches
*
* Creates a batch record and optionally blocks the parent run for batchTriggerAndWait.
* Items are streamed separately via POST /api/v3/batches/:batchId/items
*/
const { action, loader } = createActionApiRoute(
{
headers: HeadersSchema,
body: CreateBatchRequestBody,
allowJWT: true,
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
authorization: {
action: "batchTrigger",
resource: () => ({
// No specific tasks to authorize at batch creation time
// Tasks are validated when items are streamed
tasks: [],
}),
superScopes: ["write:tasks", "admin"],
},
corsStrategy: "all",
},
async ({ body, headers, authentication }) => {
// Validate runCount
if (body.runCount <= 0) {
return json({ error: "runCount must be a positive integer" }, { status: 400 });
}
// Check runCount against limit
if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) {
return json(
{
error: `Batch runCount of ${body.runCount} exceeds maximum allowed of ${env.STREAMING_BATCH_MAX_ITEMS}.`,
},
{ status: 400 }
);
}
const {
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
"x-trigger-worker": isFromWorker,
"x-trigger-client": triggerClient,
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
traceparent,
tracestate,
} = headers;
const oneTimeUseToken = await getOneTimeUseToken(authentication);
logger.debug("Create batch request", {
runCount: body.runCount,
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
idempotencyKey: body.idempotencyKey,
triggerVersion,
isFromWorker,
triggerClient,
});
// Handle idempotency for the batch creation
const cachedResponse = await handleRequestIdempotency<
{ friendlyId: string; runCount: number },
CreateBatchResponse
>(body.idempotencyKey, {
requestType: "create-batch",
findCachedEntity: async (cachedRequestId) => {
return await prisma.batchTaskRun.findFirst({
where: {
id: cachedRequestId,
runtimeEnvironmentId: authentication.environment.id,
},
select: {
friendlyId: true,
runCount: true,
},
});
},
buildResponse: (cachedBatch) => ({
id: cachedBatch.friendlyId,
runCount: cachedBatch.runCount,
isCached: true,
}),
buildResponseHeaders: async (responseBody) => {
return await responseHeaders(responseBody, authentication.environment, triggerClient);
},
});
if (cachedResponse) {
return cachedResponse;
}
const traceContext = isFromWorker
? { traceparent, tracestate }
: { external: { traceparent, tracestate } };
const service = new CreateBatchService();
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id);
});
try {
const batch = await service.call(authentication.environment, body, {
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
oneTimeUseToken,
realtimeStreamsVersion: determineRealtimeStreamsVersion(
realtimeStreamsVersion ?? undefined
),
});
const $responseHeaders = await responseHeaders(
batch,
authentication.environment,
triggerClient
);
return json(batch, {
status: 202,
headers: $responseHeaders,
});
} catch (error) {
if (error instanceof BatchRateLimitExceededError) {
logger.info("Batch rate limit exceeded", {
limit: error.limit,
remaining: error.remaining,
resetAt: error.resetAt.toISOString(),
itemCount: error.itemCount,
});
return json(
{ error: error.message },
{
status: 429,
headers: {
"X-RateLimit-Limit": error.limit.toString(),
"X-RateLimit-Remaining": error.remaining.toString(),
"X-RateLimit-Reset": Math.floor(error.resetAt.getTime() / 1000).toString(),
"Retry-After": Math.max(
1,
Math.ceil((error.resetAt.getTime() - Date.now()) / 1000)
).toString(),
},
}
);
}
logger.error("Create batch error", {
error: {
message: (error as Error).message,
stack: (error as Error).stack,
},
});
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json(
{ error: error.message },
{ status: 500, headers: { "x-should-retry": "false" } }
);
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
async function responseHeaders(
batch: CreateBatchResponse,
environment: AuthenticatedEnvironment,
triggerClient?: string | null
): Promise<Record<string, string>> {
const claimsHeader = JSON.stringify({
sub: environment.id,
pub: true,
});
if (triggerClient === "browser") {
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`],
};
const jwt = await generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: claims,
expirationTime: "1h",
});
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt": jwt,
};
}
return {
"x-trigger-jwt-claims": claimsHeader,
};
}
export { action, loader };
+11 -10
View File
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";
@@ -41,19 +42,19 @@ export let loader: LoaderFunction = async ({ request }) => {
session.set("pending-mfa-user-id", userRecord.id);
session.set("pending-mfa-redirect-to", redirectTo);
return redirect("/login/mfa", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
return redirect("/login/mfa", { headers });
}
// and store the user data
session.set(authenticator.sessionKey, auth);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
return redirect(redirectTo, { headers });
};
+8 -3
View File
@@ -1,16 +1,19 @@
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
import { env } from "~/env.server";
import { sanitizeRedirectPath } from "~/utils";
export let loader: LoaderFunction = () => redirect("/login");
export let action: ActionFunction = async ({ request }) => {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
try {
// call authenticate as usual, in successRedirect use returnTo or a fallback
return await authenticator.authenticate("github", request, {
successRedirect: redirectTo ?? "/",
successRedirect: safeRedirect,
failureRedirect: "/login",
});
} catch (error) {
@@ -19,8 +22,8 @@ export let action: ActionFunction = async ({ request }) => {
// if the error is a Response and is a redirect
if (error instanceof Response) {
// we need to append a Set-Cookie header with a cookie storing the
// returnTo value
error.headers.append("Set-Cookie", await redirectCookie.serialize(redirectTo));
// returnTo value (store the sanitized path)
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
}
throw error;
}
@@ -29,4 +32,6 @@ export let action: ActionFunction = async ({ request }) => {
export const redirectCookie = createCookie("redirect-to", {
maxAge: 60 * 60, // 1 hour
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
});
@@ -0,0 +1,61 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.google";
import { sanitizeRedirectPath } from "~/utils";
export let loader: LoaderFunction = async ({ request }) => {
const cookie = request.headers.get("Cookie");
const redirectValue = await redirectCookie.parse(cookie);
const redirectTo = sanitizeRedirectPath(redirectValue);
const auth = await authenticator.authenticate("google", request, {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});
// manually get the session
const session = await getSession(request.headers.get("cookie"));
const userRecord = await prisma.user.findFirst({
where: {
id: auth.userId,
},
select: {
id: true,
mfaEnabledAt: true,
},
});
if (!userRecord) {
return redirectWithErrorMessage(
"/login",
request,
"Could not find your account. Please contact support."
);
}
if (userRecord.mfaEnabledAt) {
session.set("pending-mfa-user-id", userRecord.id);
session.set("pending-mfa-redirect-to", redirectTo);
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
return redirect("/login/mfa", { headers });
}
// and store the user data
session.set(authenticator.sessionKey, auth);
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
return redirect(redirectTo, { headers });
};
+38
View File
@@ -0,0 +1,38 @@
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
import { env } from "~/env.server";
import { sanitizeRedirectPath } from "~/utils";
export let loader: LoaderFunction = () => redirect("/login");
export let action: ActionFunction = async ({ request }) => {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
try {
// call authenticate as usual, in successRedirect use returnTo or a fallback
return await authenticator.authenticate("google", request, {
successRedirect: safeRedirect,
failureRedirect: "/login",
});
} catch (error) {
// here we catch anything authenticator.authenticate throw, this will
// include redirects
// if the error is a Response and is a redirect
if (error instanceof Response) {
// we need to append a Set-Cookie header with a cookie storing the
// returnTo value (store the sanitized path)
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
}
throw error;
}
};
export const redirectCookie = createCookie("google-redirect-to", {
maxAge: 60 * 60, // 1 hour
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
});
+94 -40
View File
@@ -2,7 +2,9 @@ import { EnvelopeIcon } from "@heroicons/react/20/solid";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import { motion, useReducedMotion } from "framer-motion";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
import { LoginPageLayout } from "~/components/LoginPageLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -10,12 +12,33 @@ import { FormError } from "~/components/primitives/FormError";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { isGithubAuthSupported } from "~/services/auth.server";
import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server";
import { getLastAuthMethod } from "~/services/lastAuthMethod.server";
import { commitSession, setRedirectTo } from "~/services/redirectTo.server";
import { getUserId } from "~/services/session.server";
import { getUserSession } from "~/services/sessionStorage.server";
import { requestUrl } from "~/utils/requestUrl.server";
function LastUsedBadge() {
const shouldReduceMotion = useReducedMotion();
return (
<div className="absolute -right-5 top-1 z-10 -translate-y-1/2 shadow-md md:-right-[4.6rem] md:top-1/2">
<motion.div
className="relative rounded border border-charcoal-700 bg-charcoal-800 px-2 py-1 text-center text-xxs font-medium uppercase text-blue-500"
initial={shouldReduceMotion ? undefined : { opacity: 0, x: 4 }}
animate={shouldReduceMotion ? undefined : { opacity: 1, x: 0 }}
transition={shouldReduceMotion ? undefined : { duration: 0.8, ease: "easeOut" }}
>
<span className="pointer-events-none absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
<span className="hidden h-2 w-2 rotate-45 border-b border-l border-charcoal-700 bg-charcoal-800 md:block" />
</span>
Last used
</motion.div>
</div>
);
}
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
.flatMap((match) => match.meta ?? [])
@@ -45,6 +68,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const url = requestUrl(request);
const redirectTo = url.searchParams.get("redirectTo");
const lastAuthMethod = await getLastAuthMethod(request);
if (redirectTo) {
const session = await setRedirectTo(request, redirectTo);
@@ -53,6 +77,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
{
redirectTo,
showGithubAuth: isGithubAuthSupported,
showGoogleAuth: isGoogleAuthSupported,
lastAuthMethod,
authError: null,
},
{
@@ -77,6 +103,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return typedjson({
redirectTo: null,
showGithubAuth: isGithubAuthSupported,
showGoogleAuth: isGoogleAuthSupported,
lastAuthMethod,
authError,
});
}
@@ -87,31 +115,57 @@ export default function LoginPage() {
return (
<LoginPageLayout>
<Form
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
method="post"
className="w-full"
>
<div className="flex flex-col items-center">
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
Welcome
</Header1>
<Paragraph variant="base" className="mb-6">
Create an account or login
</Paragraph>
<Fieldset className="w-full">
<div className="flex flex-col items-center gap-y-2">
{data.showGithubAuth && (
<Button
type="submit"
variant="primary/extra-large"
fullWidth
data-action="continue with github"
<div className="flex w-full flex-col items-center">
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
Welcome
</Header1>
<Paragraph variant="base" className="mb-6">
Create an account or login
</Paragraph>
<Fieldset className="w-full">
<div className="flex flex-col items-center gap-y-3">
{data.showGithubAuth && (
<div className="relative w-full">
{data.lastAuthMethod === "github" && <LastUsedBadge />}
<Form
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
method="post"
className="w-full"
>
<GitHubLightIcon className={"mr-2 size-5"} />
<span className="text-text-bright">Continue with GitHub</span>
</Button>
)}
<Button
type="submit"
variant="secondary/extra-large"
fullWidth
data-action="continue with github"
>
<GitHubLightIcon className="mr-2 size-5" />
<span className="text-text-bright">Continue with GitHub</span>
</Button>
</Form>
</div>
)}
{data.showGoogleAuth && (
<div className="relative w-full">
{data.lastAuthMethod === "google" && <LastUsedBadge />}
<Form
action={`/auth/google${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
method="post"
className="w-full"
>
<Button
type="submit"
variant="secondary/extra-large"
fullWidth
data-action="continue with google"
>
<GoogleLogo className="mr-2 size-5" />
<span className="text-text-bright">Continue with Google</span>
</Button>
</Form>
</div>
)}
<div className="relative w-full">
{data.lastAuthMethod === "email" && <LastUsedBadge />}
<LinkButton
to="/login/magic"
variant="secondary/extra-large"
@@ -122,22 +176,22 @@ export default function LoginPage() {
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
Continue with Email
</LinkButton>
{data.authError && <FormError>{data.authError}</FormError>}
</div>
<Paragraph variant="extra-small" className="mt-2 text-center">
By signing up you agree to our{" "}
<TextLink href="https://trigger.dev/legal" target="_blank">
terms
</TextLink>
{" "}and{" "}
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
privacy
</TextLink>
{" "}policy.
</Paragraph>
</Fieldset>
</div>
</Form>
{data.authError && <FormError>{data.authError}</FormError>}
</div>
<Paragraph variant="extra-small" className="mt-2 text-center">
By signing up you agree to our{" "}
<TextLink href="https://trigger.dev/legal" target="_blank">
terms
</TextLink>{" "}
and{" "}
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
privacy
</TextLink>{" "}
policy.
</Paragraph>
</Fieldset>
</div>
</LoginPageLayout>
);
}
+11 -10
View File
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { getRedirectTo } from "~/services/redirectTo.server";
import { commitSession, getSession } from "~/services/sessionStorage.server";
@@ -38,19 +39,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
session.set("pending-mfa-user-id", userRecord.id);
session.set("pending-mfa-redirect-to", redirectTo ?? "/");
return redirect("/login/mfa", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
return redirect("/login/mfa", { headers });
}
// and store the user data
session.set(authenticator.sessionKey, auth);
return redirect(redirectTo ?? "/", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
return redirect(redirectTo ?? "/", { headers });
}
@@ -0,0 +1,877 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
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 { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import { TextLink } from "~/components/primitives/TextLink";
import { DateTime } from "~/components/primitives/DateTime";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
} from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import { type BranchTrackingConfig } from "~/v3/github";
import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server";
import { useEffect, useState } from "react";
// ============================================================================
// Types
// ============================================================================
export type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
export type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
export type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
// ============================================================================
// Schemas
// ============================================================================
export const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
redirectUrl: z.string().optional(),
});
export const DisconnectGitHubRepoFormSchema = z.object({
action: z.literal("disconnect-repo"),
redirectUrl: z.string().optional(),
});
export const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
redirectUrl: z.string().optional(),
});
const GitHubActionSchema = z.discriminatedUnion("action", [
ConnectGitHubRepoFormSchema,
DisconnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
]);
// ============================================================================
// Loader
// ============================================================================
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new GitHubSettingsPresenter();
const resultOrFail = await presenter.call({
projectId: project.id,
organizationId: project.organizationId,
});
if (resultOrFail.isErr()) {
throw new Response("Failed to load GitHub settings", { status: 500 });
}
return typedjson(resultOrFail.value);
}
// ============================================================================
// Action
// ============================================================================
function redirectWithMessage(
request: Request,
redirectUrl: string | undefined,
message: string,
type: "success" | "error"
) {
if (type === "success") {
return redirectUrl
? redirectWithSuccessMessage(redirectUrl, request, message)
: redirectBackWithSuccessMessage(request, message);
}
return redirectUrl
? redirectWithErrorMessage(redirectUrl, request, message)
: redirectBackWithErrorMessage(request, message);
}
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: GitHubActionSchema });
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, organizationId } = membershipResultOrFail.value;
const { action: actionType } = submission.value;
// Handle connect-repo action
if (actionType === "connect-repo") {
const { repositoryId, installationId, redirectUrl } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"GitHub repository connected successfully",
"success"
);
}
const errorType = resultOrFail.error.type;
if (errorType === "gh_repository_not_found") {
return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error");
}
if (errorType === "project_already_has_connected_repository") {
return redirectWithMessage(
request,
redirectUrl,
"Project already has a connected repository",
"error"
);
}
logger.error("Failed to connect GitHub repository", { error: resultOrFail.error });
return redirectWithMessage(
request,
redirectUrl,
"Failed to connect GitHub repository",
"error"
);
}
// Handle disconnect-repo action
if (actionType === "disconnect-repo") {
const { redirectUrl } = submission.value;
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"GitHub repository disconnected successfully",
"success"
);
}
logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error });
return redirectWithMessage(
request,
redirectUrl,
"Failed to disconnect GitHub repository",
"error"
);
}
// Handle update-git-settings action
if (actionType === "update-git-settings") {
const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } =
submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"Git settings updated successfully",
"success"
);
}
const errorType = resultOrFail.error.type;
const errorMessages: Record<string, string> = {
github_app_not_enabled: "GitHub app is not enabled",
connected_gh_repository_not_found: "Connected GitHub repository not found",
production_tracking_branch_not_found: "Production tracking branch not found",
staging_tracking_branch_not_found: "Staging tracking branch not found",
};
const message = errorMessages[errorType];
if (message) {
return redirectWithMessage(request, redirectUrl, message, "error");
}
logger.error("Failed to update Git settings", { error: resultOrFail.error });
return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error");
}
// Exhaustive check - this should never be reached
submission.value satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
// ============================================================================
// Helper: Build resource URL for fetching GitHub data
// ============================================================================
export function gitHubResourcePath(
organizationSlug: string,
projectSlug: string,
environmentSlug: string
) {
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/github`;
}
// ============================================================================
// Components
// ============================================================================
export function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" action={actionUrl} {...form.props} className="w-full">
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
export function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
}) {
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${githubInstallationRedirect}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={redirectUrl}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
</InputGroup>
</Fieldset>
);
}
export function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
organizationSlug,
projectSlug,
environmentSlug,
billingPath,
redirectUrl,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
redirectUrl?: string;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<OctoKitty className="size-4" />
<a
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-bright hover:underline"
>
{connectedGitHubRepo.repository.fullName}
</a>
{connectedGitHubRepo.repository.private && (
<LockClosedIcon className="size-3 text-text-dimmed" />
)}
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">Disconnect</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect-repo" />
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
<Form method="post" action={actionUrl} {...gitSettingsForm.props}>
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
{environmentFullTitle({ type: "PRODUCTION" })}
</span>
</div>
<Input
{...conform.input(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
{environmentFullTitle({ type: "STAGING" })}
</span>
</div>
<Input
{...conform.input(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Switch
name="previewDeploymentsEnabled"
disabled={!previewEnvironmentEnabled}
defaultChecked={
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
}
variant="small"
label="Create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
{!previewEnvironmentEnabled && (
<InfoIconTooltip
content={
<span className="text-xs">
<TextLink to={billingPath}>Upgrade</TextLink> your plan to enable preview
branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
// ============================================================================
// Main GitHub Settings Panel Component
// ============================================================================
export function GitHubSettingsPanel({
organizationSlug,
projectSlug,
environmentSlug,
billingPath,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
}) {
const fetcher = useTypedFetcher<typeof loader>();
const location = useLocation();
// Use provided redirectUrl or fall back to current path (without search params)
const effectiveRedirectUrl = location.pathname;
useEffect(() => {
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
}, [organizationSlug, projectSlug, environmentSlug]);
const data = fetcher.data;
// Loading state
if (fetcher.state === "loading" && !data) {
return (
<div className="flex items-center gap-2 text-text-dimmed">
<SpinnerWhite className="size-4" />
<span className="text-sm">Loading GitHub settings...</span>
</div>
);
}
// GitHub app not enabled
if (!data || !data.enabled) {
return null;
}
// Connected repository exists - show form
if (data.connectedRepository) {
return (
<ConnectedGitHubRepoForm
connectedGitHubRepo={data.connectedRepository}
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
billingPath={billingPath}
redirectUrl={effectiveRedirectUrl}
/>
);
}
// No connected repository - show connection prompt
return (
<div className="flex flex-col gap-2">
<GitHubConnectionPrompt
gitHubAppInstallations={data.installations ?? []}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={effectiveRedirectUrl}
/>
{!data.connectedRepository && (
<Hint>
Connect your GitHub repository to automatically deploy your changes.
</Hint>
)}
</div>
);
}
@@ -0,0 +1,127 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { jsonWithErrorMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
export const resetIdempotencyKeySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, runParam } =
v3RunParamsSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: resetIdempotencyKeySchema });
if (!submission.value) {
return json(submission);
}
try {
const { taskIdentifier } = submission.value;
const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
runtimeEnvironment: {
slug: envParam,
},
},
select: {
id: true,
idempotencyKey: true,
taskIdentifier: true,
runtimeEnvironmentId: true,
},
});
if (!taskRun) {
submission.error = { runParam: ["Run not found"] };
return json(submission);
}
if (!taskRun.idempotencyKey) {
return jsonWithErrorMessage(
submission,
request,
"This run does not have an idempotency key"
);
}
if (taskRun.taskIdentifier !== taskIdentifier) {
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
return json(submission);
}
const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: taskRun.runtimeEnvironmentId,
},
include: {
project: {
include: {
organization: true,
},
},
},
});
if (!environment) {
return jsonWithErrorMessage(
submission,
request,
"Environment not found"
);
}
const service = new ResetIdempotencyKeyService();
await service.call(taskRun.idempotencyKey, taskIdentifier, {
...environment,
organizationId: environment.project.organizationId,
organization: environment.project.organization,
});
return json({ success: true });
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to reset idempotency key", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${error.message}`
);
} else {
logger.error("Failed to reset idempotency key", { error });
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${JSON.stringify(error)}`
);
}
}
};
@@ -1,4 +1,5 @@
import {
ArrowPathIcon,
CheckIcon,
CloudArrowDownIcon,
EnvelopeIcon,
@@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { toast } from "sonner";
import {
Table,
TableBody,
@@ -40,6 +42,7 @@ import {
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { ToastUI } from "~/components/primitives/Toast";
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import { RunIcon } from "~/components/runs/v3/RunIcon";
@@ -69,6 +72,7 @@ import {
v3BatchPath,
v3DeploymentVersionPath,
v3RunDownloadLogsPath,
v3RunIdempotencyKeyResetPath,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
@@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
import { requireUserId } from "~/services/session.server";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -293,6 +298,28 @@ function RunBody({
const isAdmin = useHasAdminAccess();
const { value, replace } = useSearchParams();
const tab = value("tab");
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
// Handle toast messages from the reset action
useEffect(() => {
if (resetFetcher.data && resetFetcher.state === "idle") {
// Check if the response indicates success
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
toast.custom(
(t) => (
<ToastUI
variant="success"
message="Idempotency key reset successfully"
t={t as string}
/>
),
{
duration: 5000,
}
);
}
}
}, [resetFetcher.data, resetFetcher.state]);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
@@ -543,16 +570,49 @@ function RunBody({
<Property.Item>
<Property.Label>Idempotency</Property.Label>
<Property.Value>
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : ""}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
""
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : ""}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
""
)}
</div>
)}
</div>
{run.idempotencyKey && (
<resetFetcher.Form
method="post"
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
>
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
<Button
type="submit"
variant="minimal/small"
LeadingIcon={ArrowPathIcon}
disabled={resetFetcher.state === "submitting"}
>
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
</Button>
</resetFetcher.Form>
)}
</div>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Debounce</Property.Label>
<Property.Value>
{run.debounce ? (
<div>
<div className="break-all">Key: {run.debounce.key}</div>
<div>Delay: {run.debounce.delay}</div>
</div>
) : (
""
)}
</Property.Value>
</Property.Item>
@@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react";
export default function Story() {
const { tabNumber } = useParams();
return (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">{tabNumber}</h1>
</div>
);
+180 -10
View File
@@ -1,18 +1,188 @@
import { Outlet } from "@remix-run/react";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Tabs } from "~/components/primitives/Tabs";
export default function Story() {
return (
<div className="w-96 p-8">
<Tabs
tabs={[
{ label: "My first tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs"
/>
<Outlet />
<div className="flex items-start justify-center gap-20 px-16 pt-20">
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex h-fit flex-col gap-2">
<div className="flex flex-col gap-2">
<Header1 spacing>{"<Tabs/>"} (updates the URL)</Header1>
<Paragraph>Variant="underline"</Paragraph>
</div>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-1"
variant="underline"
/>
<Outlet />
</div>
<div className="flex h-fit flex-col gap-2">
<Paragraph>Variant="pipe-divider"</Paragraph>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-2"
variant="pipe-divider"
/>
<Outlet />
</div>
<div className="flex h-fit flex-col gap-2">
<Paragraph>Variant="segmented"</Paragraph>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-3"
variant="segmented"
/>
<Outlet />
</div>
</div>
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Header1 spacing>{"<ClientTabs/>"}</Header1>
<Paragraph>Variant="underline"</Paragraph>
</div>
<ClientTabs defaultValue="tab-1">
<div className="flex items-center gap-4">
<ClientTabsList variant="underline">
<ClientTabsTrigger
value={"tab-1"}
variant="underline"
layoutId="client-tabs-underline"
>
First tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-2"}
variant="underline"
layoutId="client-tabs-underline"
>
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-3"}
variant="underline"
layoutId="client-tabs-underline"
>
Third tab
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
<div>
<Paragraph spacing>Variant="pipe-divider"</Paragraph>
<ClientTabs defaultValue="tab-1">
<div className="flex items-center gap-4">
<ClientTabsList variant="pipe-divider">
<ClientTabsTrigger value={"tab-1"} variant="pipe-divider">
First tab
</ClientTabsTrigger>
<ClientTabsTrigger value={"tab-2"} variant="pipe-divider">
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger value={"tab-3"} variant="pipe-divider">
Third tab
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
<div>
<Paragraph spacing>Variant="segmented"</Paragraph>
<ClientTabs defaultValue="tab-1">
<ClientTabsList variant="segmented">
<ClientTabsTrigger
value={"tab-1"}
variant="segmented"
layoutId="client-tabs-segmented"
>
First tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-2"}
variant="segmented"
layoutId="client-tabs-segmented"
>
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-3"}
variant="segmented"
layoutId="client-tabs-segmented"
>
Third tab
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { GlobalRateLimiter } from "@trigger.dev/redis-worker";
import { RateLimiter } from "~/services/rateLimiter.server";
/**
* Creates a global rate limiter for the batch queue that limits
* the maximum number of items processed per second across all consumers.
*
* Uses a token bucket algorithm where:
* - `itemsPerSecond` tokens are available per second
* - The bucket can hold up to `itemsPerSecond` tokens (burst capacity)
*
* @param itemsPerSecond - Maximum items to process per second
* @returns A GlobalRateLimiter compatible with FairQueue
*/
export function createBatchGlobalRateLimiter(itemsPerSecond: number): GlobalRateLimiter {
const limiter = new RateLimiter({
keyPrefix: "batch-queue-global",
// Token bucket: refills `itemsPerSecond` tokens every second
// Bucket capacity is also `itemsPerSecond` (allows burst up to limit)
limiter: Ratelimit.tokenBucket(itemsPerSecond, "1 s", itemsPerSecond),
logSuccess: false,
logFailure: true,
});
return {
async limit() {
const result = await limiter.limit("global");
return {
allowed: result.success,
resetAt: result.reset,
};
},
};
}
@@ -0,0 +1,123 @@
import { Organization } from "@trigger.dev/database";
import { Ratelimit } from "@upstash/ratelimit";
import { z } from "zod";
import { env } from "~/env.server";
import { RateLimiterConfig } from "~/services/authorizationRateLimitMiddleware.server";
import { createRedisRateLimitClient, Duration, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
const BatchLimitsConfig = z.object({
processingConcurrency: z.number().int().default(env.BATCH_CONCURRENCY_LIMIT_DEFAULT),
});
/**
* Batch limits configuration for a plan type
*/
export type BatchLimitsConfig = z.infer<typeof BatchLimitsConfig>;
const batchLimitsRedisClient = singleton("batchLimitsRedisClient", createBatchLimitsRedisClient);
function createBatchLimitsRedisClient() {
const redisClient = createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
});
return redisClient;
}
function createOrganizationRateLimiter(organization: Organization): RateLimiter {
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
const limiter =
limiterConfig.type === "fixedWindow"
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
: limiterConfig.type === "tokenBucket"
? Ratelimit.tokenBucket(
limiterConfig.refillRate,
limiterConfig.interval,
limiterConfig.maxTokens
)
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
return new RateLimiter({
redisClient: batchLimitsRedisClient,
keyPrefix: "ratelimit:batch",
limiter,
logSuccess: false,
logFailure: true,
});
}
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
const defaultRateLimiterConfig: RateLimiterConfig = {
type: "tokenBucket",
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.BATCH_RATE_LIMIT_MAX,
};
if (!batchRateLimitConfig) {
return defaultRateLimiterConfig;
}
const parsedBatchRateLimitConfig = RateLimiterConfig.safeParse(batchRateLimitConfig);
if (!parsedBatchRateLimitConfig.success) {
return defaultRateLimiterConfig;
}
return parsedBatchRateLimitConfig.data;
}
/**
* Get the rate limiter and limits for an organization.
* Internally looks up the plan type, but doesn't expose it to callers.
*/
export async function getBatchLimits(
organization: Organization
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
const rateLimiter = createOrganizationRateLimiter(organization);
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
return { rateLimiter, config };
}
function resolveBatchLimitsConfig(batchLimitsConfig?: unknown): BatchLimitsConfig {
const defaultLimitsConfig: BatchLimitsConfig = {
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
};
if (!batchLimitsConfig) {
return defaultLimitsConfig;
}
const parsedBatchLimitsConfig = BatchLimitsConfig.safeParse(batchLimitsConfig);
if (!parsedBatchLimitsConfig.success) {
return defaultLimitsConfig;
}
return parsedBatchLimitsConfig.data;
}
/**
* Error thrown when batch rate limit is exceeded.
* Contains information for constructing a proper 429 response.
*/
export class BatchRateLimitExceededError extends Error {
constructor(
public readonly limit: number,
public readonly remaining: number,
public readonly resetAt: Date,
public readonly itemCount: number
) {
super(
`Batch rate limit exceeded. Attempted to submit ${itemCount} items but only ${remaining} remaining. Limit resets at ${resetAt.toISOString()}`
);
this.name = "BatchRateLimitExceededError";
}
}
@@ -0,0 +1,171 @@
import { IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { uploadPacketToObjectStore, r2 } from "~/v3/r2.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
export type BatchPayloadProcessResult = {
/** The processed payload - either the original or an R2 path */
payload: unknown;
/** The payload type - "application/store" if offloaded to R2 */
payloadType: string;
/** Whether the payload was offloaded to R2 */
wasOffloaded: boolean;
/** Size of the payload in bytes */
size: number;
};
/**
* BatchPayloadProcessor handles payload offloading for batch items.
*
* When a batch item's payload exceeds the configured threshold, it's uploaded
* to object storage (R2) and the payload is replaced with the storage path.
* This aligns with how single task triggers work via DefaultPayloadProcessor.
*
* Path format: batch_{batchId}/item_{index}/payload.json
*/
export class BatchPayloadProcessor {
/**
* Check if object storage is available for payload offloading.
* If not available, large payloads will be stored inline (which may fail for very large payloads).
*/
isObjectStoreAvailable(): boolean {
return r2 !== undefined && env.OBJECT_STORE_BASE_URL !== undefined;
}
/**
* Process a batch item payload, offloading to R2 if it exceeds the threshold.
*
* @param payload - The raw payload from the batch item
* @param payloadType - The payload type (e.g., "application/json")
* @param batchId - The batch ID (internal format)
* @param itemIndex - The item index within the batch
* @param environment - The authenticated environment for R2 path construction
* @returns The processed result with potentially offloaded payload
*/
async process(
payload: unknown,
payloadType: string,
batchId: string,
itemIndex: number,
environment: AuthenticatedEnvironment
): Promise<BatchPayloadProcessResult> {
return startActiveSpan("BatchPayloadProcessor.process()", async (span) => {
span.setAttribute("batchId", batchId);
span.setAttribute("itemIndex", itemIndex);
span.setAttribute("payloadType", payloadType);
// Create the packet for size checking
const packet = this.#createPayloadPacket(payload, payloadType);
if (!packet.data) {
return {
payload,
payloadType,
wasOffloaded: false,
size: 0,
};
}
const threshold = env.BATCH_PAYLOAD_OFFLOAD_THRESHOLD ?? env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
const { needsOffloading, size } = packetRequiresOffloading(packet, threshold);
span.setAttribute("payloadSize", size);
span.setAttribute("needsOffloading", needsOffloading);
span.setAttribute("threshold", threshold);
if (!needsOffloading) {
return {
payload,
payloadType,
wasOffloaded: false,
size,
};
}
// Check if object store is available
if (!this.isObjectStoreAvailable()) {
logger.warn("Payload exceeds threshold but object store is not available", {
batchId,
itemIndex,
size,
threshold,
});
// Return without offloading - the payload will be stored inline
// This may fail downstream for very large payloads
return {
payload,
payloadType,
wasOffloaded: false,
size,
};
}
// Upload to R2
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
const [uploadError] = await tryCatch(
uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment)
);
if (uploadError) {
logger.error("Failed to upload batch item payload to object store", {
batchId,
itemIndex,
error: uploadError instanceof Error ? uploadError.message : String(uploadError),
});
// Throw to fail this item - SDK can retry
throw new Error(
`Failed to upload large payload to object store: ${
uploadError instanceof Error ? uploadError.message : String(uploadError)
}`
);
}
logger.debug("Batch item payload offloaded to R2", {
batchId,
itemIndex,
filename,
size,
});
span.setAttribute("wasOffloaded", true);
span.setAttribute("offloadPath", filename);
return {
payload: filename,
payloadType: "application/store",
wasOffloaded: true,
size,
};
});
}
/**
* Create an IOPacket from payload for size checking.
*/
#createPayloadPacket(payload: unknown, payloadType: string): IOPacket {
if (payloadType === "application/json") {
// Payload from SDK is already serialized as a string - use directly
if (typeof payload === "string") {
return { data: payload, dataType: "application/json" };
}
// Non-string payloads (e.g., direct API calls with objects) need serialization
return { data: JSON.stringify(payload), dataType: "application/json" };
}
if (typeof payload === "string") {
return { data: payload, dataType: payloadType };
}
// For other types, try to stringify
try {
return { data: JSON.stringify(payload), dataType: payloadType };
} catch {
return { dataType: payloadType };
}
}
}
@@ -16,6 +16,35 @@ import { env } from "~/env.server";
import { tryCatch } from "@trigger.dev/core/v3";
import { ServiceValidationError } from "~/v3/services/common.server";
/**
* Extract the queue name from a queue option that may be:
* - An object with a string `name` property: { name: "queue-name" }
* - A double-wrapped object (bug case): { name: { name: "queue-name", ... } }
*
* This handles the case where the SDK accidentally double-wraps the queue
* option when it's already an object with a name property.
*/
function extractQueueName(queue: { name?: unknown } | undefined): string | undefined {
if (!queue?.name) {
return undefined;
}
// Normal case: queue.name is a string
if (typeof queue.name === "string") {
return queue.name;
}
// Double-wrapped case: queue.name is an object with its own name property
if (typeof queue.name === "object" && queue.name !== null && "name" in queue.name) {
const innerName = (queue.name as { name: unknown }).name;
if (typeof innerName === "string") {
return innerName;
}
}
return undefined;
}
export class DefaultQueueManager implements QueueManager {
constructor(
private readonly prisma: PrismaClientOrTransaction,
@@ -32,8 +61,8 @@ export class DefaultQueueManager implements QueueManager {
// Determine queue name based on lockToVersion and provided options
if (lockedBackgroundWorker) {
// Task is locked to a specific worker version
if (request.body.options?.queue?.name) {
const specifiedQueueName = request.body.options.queue.name;
const specifiedQueueName = extractQueueName(request.body.options?.queue);
if (specifiedQueueName) {
// A specific queue name is provided
const specifiedQueue = await this.prisma.taskQueue.findFirst({
// Validate it exists for the locked worker
@@ -126,8 +155,10 @@ export class DefaultQueueManager implements QueueManager {
const { taskId, environment, body } = request;
const { queue } = body.options ?? {};
if (queue?.name) {
return queue.name;
// Use extractQueueName to handle double-wrapped queue objects
const queueName = extractQueueName(queue);
if (queueName) {
return queueName;
}
const defaultQueueName = `task/${taskId}`;
@@ -1,14 +0,0 @@
import { autoIncrementCounter } from "~/services/autoIncrementCounter.server";
import { RunNumberIncrementer, TriggerTaskRequest } from "../types";
export class DefaultRunNumberIncrementer implements RunNumberIncrementer {
async incrementRunNumber<T>(
request: TriggerTaskRequest,
callback: (num: number) => Promise<T>
): Promise<T | undefined> {
return await autoIncrementCounter.incrementInTransaction(
`v3-run:${request.environment.id}:${request.taskId}`,
callback
);
}
}
@@ -51,6 +51,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
stop: event.stop.bind(event),
},
store
);
@@ -116,6 +117,73 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
stop: event.stop.bind(event),
},
store
);
}
);
}
async traceDebouncedRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
debounceKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
const { existingRun, debounceKey, incomplete, isError } = options;
const { repository, store } = await this.#getEventRepository(request, parentStore);
return await repository.traceEvent(
`${request.taskId} (debounced)`,
{
context: request.options?.traceContext,
spanParentAsLink: request.options?.spanParentAsLink,
kind: "SERVER",
environment: request.environment,
taskSlug: request.taskId,
attributes: {
properties: {
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
},
style: {
icon: "task-cached",
},
runId: existingRun.friendlyId,
},
incomplete,
isError,
immediate: true,
},
async (event, traceContext, traceparent) => {
// Log a message about the debounced trigger
await repository.recordEvent(
`Debounced: using existing run with key "${debounceKey}"`,
{
taskSlug: request.taskId,
environment: request.environment,
attributes: {
runId: existingRun.friendlyId,
},
context: request.options?.traceContext,
parentId: event.spanId,
}
);
return await callback(
{
traceId: event.traceId,
spanId: event.spanId,
traceContext,
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
stop: event.stop.bind(event),
},
store
);
@@ -0,0 +1,209 @@
import type { InitializeBatchOptions } from "@internal/run-engine";
import { type CreateBatchRequestBody, type CreateBatchResponse } from "@trigger.dev/core/v3";
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
import { Evt } from "evt";
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
import { BatchRateLimitExceededError, getBatchLimits } from "../concerns/batchLimits.server";
import { DefaultQueueManager } from "../concerns/queues.server";
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
export type CreateBatchServiceOptions = {
triggerVersion?: string;
traceContext?: Record<string, string | undefined | Record<string, string | undefined>>;
spanParentAsLink?: boolean;
oneTimeUseToken?: string;
realtimeStreamsVersion?: "v1" | "v2";
};
/**
* Create Batch Service (Phase 1 of 2-phase batch API).
*
* This service handles Phase 1 of the streaming batch API:
* 1. Validates entitlement and queue limits
* 2. Creates BatchTaskRun in Postgres with status=PENDING, expectedCount set
* 3. For batchTriggerAndWait: blocks the parent run immediately
* 4. Initializes batch metadata in Redis
* 5. Returns batch ID - items are streamed separately via Phase 2
*
* The batch is NOT sealed until Phase 2 completes.
*/
export class CreateBatchService extends WithRunEngine {
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
private readonly queueConcern: DefaultQueueManager;
private readonly validator: DefaultTriggerTaskValidator;
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {
super({ prisma: _prisma });
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine);
this.validator = new DefaultTriggerTaskValidator();
}
/**
* Create a batch for 2-phase processing.
* Items will be streamed separately via the StreamBatchItemsService.
*/
public async call(
environment: AuthenticatedEnvironment,
body: CreateBatchRequestBody,
options: CreateBatchServiceOptions = {}
): Promise<CreateBatchResponse> {
try {
return await this.traceWithEnv<CreateBatchResponse>(
"createBatch()",
environment,
async (span) => {
const { id, friendlyId } = BatchId.generate();
span.setAttribute("batchId", friendlyId);
span.setAttribute("runCount", body.runCount);
// Validate entitlement
const entitlementValidation = await this.validator.validateEntitlement({
environment,
});
if (!entitlementValidation.ok) {
throw entitlementValidation.error;
}
// Extract plan type from entitlement validation for billing tracking
const planType = entitlementValidation.plan?.type;
// Get batch limits for this organization
const { config, rateLimiter } = await getBatchLimits(environment.organization);
// Check rate limit BEFORE creating the batch
// This prevents burst creation of batches that exceed the rate limit
const rateResult = await rateLimiter.limit(environment.id, body.runCount);
if (!rateResult.success) {
throw new BatchRateLimitExceededError(
rateResult.limit,
rateResult.remaining,
new Date(rateResult.reset),
body.runCount
);
}
// 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}`
);
}
// Create BatchTaskRun in Postgres with PENDING status
// The batch will be sealed (status -> PROCESSING) when items are streamed
const batch = await this._prisma.batchTaskRun.create({
data: {
id,
friendlyId,
runtimeEnvironmentId: environment.id,
status: "PENDING",
runCount: body.runCount,
expectedCount: body.runCount,
runIds: [],
batchVersion: "runengine:v2", // 2-phase streaming batch API
oneTimeUseToken: options.oneTimeUseToken,
idempotencyKey: body.idempotencyKey,
// Not sealed yet - will be sealed when items stream completes
sealed: false,
},
});
this.onBatchTaskRunCreated.post(batch);
// Block parent run if this is a batchTriggerAndWait
if (body.parentRunId && body.resumeParentOnCompletion) {
await this._engine.blockRunWithCreatedBatch({
runId: RunId.fromFriendlyId(body.parentRunId),
batchId: batch.id,
environmentId: environment.id,
projectId: environment.projectId,
organizationId: environment.organizationId,
});
}
// Initialize batch metadata in Redis (without items)
const initOptions: InitializeBatchOptions = {
batchId: id,
friendlyId,
environmentId: environment.id,
environmentType: environment.type,
organizationId: environment.organizationId,
projectId: environment.projectId,
runCount: body.runCount,
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
triggerVersion: options.triggerVersion,
traceContext: options.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: options.spanParentAsLink,
realtimeStreamsVersion: options.realtimeStreamsVersion,
idempotencyKey: body.idempotencyKey,
processingConcurrency: config.processingConcurrency,
planType,
};
await this._engine.initializeBatch(initOptions);
logger.info("Batch created", {
batchId: friendlyId,
runCount: body.runCount,
envId: environment.id,
projectId: environment.projectId,
parentRunId: body.parentRunId,
resumeParentOnCompletion: body.resumeParentOnCompletion,
processingConcurrency: config.processingConcurrency,
});
return {
id: friendlyId,
runCount: body.runCount,
isCached: false,
idempotencyKey: body.idempotencyKey,
};
}
);
} catch (error) {
// Handle Prisma unique constraint violations
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.debug("CreateBatchService: Prisma error", {
code: error.code,
message: error.message,
meta: error.meta,
});
if (error.code === "P2002") {
const target = error.meta?.target;
if (
Array.isArray(target) &&
target.length > 0 &&
typeof target[0] === "string" &&
target[0].includes("oneTimeUseToken")
) {
throw new ServiceValidationError(
"Cannot create batch with a one-time use token as it has already been used."
);
} else {
throw new ServiceValidationError(
"Cannot create batch as it has already been created with the same idempotency key."
);
}
}
}
throw error;
}
}
}
@@ -0,0 +1,470 @@
import {
type BatchItemNDJSON,
type StreamBatchItemsResponse,
BatchItemNDJSON as BatchItemNDJSONSchema,
} from "@trigger.dev/core/v3";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import type { BatchItem, RunEngine } from "@internal/run-engine";
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
import { BatchPayloadProcessor } from "../concerns/batchPayloads.server";
export type StreamBatchItemsServiceOptions = {
maxItemBytes: number;
};
export type StreamBatchItemsServiceConstructorOptions = {
prisma?: PrismaClientOrTransaction;
engine?: RunEngine;
};
/**
* Stream Batch Items Service (Phase 2 of 2-phase batch API).
*
* This service handles Phase 2 of the streaming batch API:
* 1. Validates batch exists and is in PENDING status
* 2. Processes NDJSON stream item by item
* 3. Calls engine.enqueueBatchItem() for each item
* 4. Tracks accepted/deduplicated counts
* 5. On completion: validates count, seals the batch
*
* The service is designed for streaming and processes items as they arrive,
* providing backpressure through the async iterator pattern.
*/
export class StreamBatchItemsService extends WithRunEngine {
private readonly payloadProcessor: BatchPayloadProcessor;
constructor(opts: StreamBatchItemsServiceConstructorOptions = {}) {
super({ prisma: opts.prisma ?? prisma, engine: opts.engine });
this.payloadProcessor = new BatchPayloadProcessor();
}
/**
* Parse a batch friendly ID to its internal ID format.
* Throws a ServiceValidationError with 400 status if the ID is malformed.
*/
private parseBatchFriendlyId(friendlyId: string): string {
try {
return BatchId.fromFriendlyId(friendlyId);
} catch {
throw new ServiceValidationError(`Invalid batchFriendlyId: ${friendlyId}`, 400);
}
}
/**
* Process a stream of batch items from an async iterator.
* Each item is validated and enqueued to the BatchQueue.
* The batch is sealed when the stream completes.
*/
public async call(
environment: AuthenticatedEnvironment,
batchFriendlyId: string,
itemsIterator: AsyncIterable<unknown>,
options: StreamBatchItemsServiceOptions
): Promise<StreamBatchItemsResponse> {
return this.traceWithEnv<StreamBatchItemsResponse>(
"streamBatchItems()",
environment,
async (span) => {
span.setAttribute("batchId", batchFriendlyId);
// Convert friendly ID to internal ID
const batchId = this.parseBatchFriendlyId(batchFriendlyId);
// Validate batch exists and belongs to this environment
const batch = await this._prisma.batchTaskRun.findFirst({
where: {
id: batchId,
runtimeEnvironmentId: environment.id,
},
select: {
id: true,
friendlyId: true,
status: true,
runCount: true,
sealed: true,
batchVersion: true,
},
});
if (!batch) {
throw new ServiceValidationError(`Batch ${batchFriendlyId} not found`);
}
if (batch.sealed) {
throw new ServiceValidationError(
`Batch ${batchFriendlyId} is already sealed and cannot accept more items`
);
}
if (batch.status !== "PENDING") {
throw new ServiceValidationError(
`Batch ${batchFriendlyId} is not in PENDING status (current: ${batch.status})`
);
}
let itemsAccepted = 0;
let itemsDeduplicated = 0;
let lastIndex = -1;
// Process items from the stream
for await (const rawItem of itemsIterator) {
// Parse and validate the item
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
if (!parseResult.success) {
throw new ServiceValidationError(
`Invalid item at index ${lastIndex + 1}: ${parseResult.error.message}`
);
}
const item = parseResult.data;
lastIndex = item.index;
// Validate index is within expected range
if (item.index >= batch.runCount) {
throw new ServiceValidationError(
`Item index ${item.index} exceeds batch runCount ${batch.runCount}`
);
}
// Get the original payload type
const originalPayloadType = (item.options?.payloadType as string) ?? "application/json";
// Process payload - offload to R2 if it exceeds threshold
const processedPayload = await this.payloadProcessor.process(
item.payload,
originalPayloadType,
batchId,
item.index,
environment
);
// Convert to BatchItem format with potentially offloaded payload
const batchItem: BatchItem = {
task: item.task,
payload: processedPayload.payload,
payloadType: processedPayload.payloadType,
options: item.options,
};
// Enqueue the item
const result = await this._engine.enqueueBatchItem(
batchId,
environment.id,
item.index,
batchItem
);
if (result.enqueued) {
itemsAccepted++;
} else {
itemsDeduplicated++;
}
}
// Get the actual enqueued count from Redis
const enqueuedCount = await this._engine.getBatchEnqueuedCount(batchId);
// Validate we received the expected number of items
if (enqueuedCount !== batch.runCount) {
logger.warn("Batch item count mismatch", {
batchId: batchFriendlyId,
expected: batch.runCount,
received: enqueuedCount,
itemsAccepted,
itemsDeduplicated,
});
// Don't seal the batch if count doesn't match
// Return sealed: false so client knows to retry with missing items
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: false,
enqueuedCount,
expectedCount: batch.runCount,
};
}
// Seal the batch - use conditional update to prevent TOCTOU race
// Another concurrent request may have already sealed this batch
const now = new Date();
const sealResult = await this._prisma.batchTaskRun.updateMany({
where: {
id: batchId,
sealed: false,
status: "PENDING",
},
data: {
sealed: true,
sealedAt: now,
status: "PROCESSING",
processingStartedAt: now,
},
});
// Check if we won the race to seal the batch
if (sealResult.count === 0) {
// Another request sealed the batch first - re-query to check current state
const currentBatch = await this._prisma.batchTaskRun.findUnique({
where: { id: batchId },
select: {
id: true,
friendlyId: true,
status: true,
sealed: true,
},
});
if (currentBatch?.sealed && currentBatch.status === "PROCESSING") {
// The batch was sealed by another request - this is fine, the goal was achieved
logger.info("Batch already sealed by concurrent request", {
batchId: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
envId: environment.id,
});
span.setAttribute("itemsAccepted", itemsAccepted);
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
span.setAttribute("sealedByConcurrentRequest", true);
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: true,
};
}
// Batch is in an unexpected state - fail with error
const actualStatus = currentBatch?.status ?? "unknown";
const actualSealed = currentBatch?.sealed ?? "unknown";
logger.error("Batch seal race condition: unexpected state", {
batchId: batchFriendlyId,
expectedStatus: "PENDING",
actualStatus,
expectedSealed: false,
actualSealed,
envId: environment.id,
});
throw new ServiceValidationError(
`Batch ${batchFriendlyId} is in unexpected state (status: ${actualStatus}, sealed: ${actualSealed}). Cannot seal batch.`
);
}
logger.info("Batch sealed and ready for processing", {
batchId: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
totalEnqueued: enqueuedCount,
envId: environment.id,
});
span.setAttribute("itemsAccepted", itemsAccepted);
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: true,
};
}
);
}
}
/**
* Create an NDJSON parser transform stream.
*
* Converts a stream of Uint8Array chunks into parsed JSON objects.
* Each line in the NDJSON is parsed independently.
*
* Uses byte-buffer accumulation to:
* - Prevent OOM from unbounded string buffers
* - Properly handle multibyte UTF-8 characters across chunk boundaries
* - Check size limits on raw bytes before decoding
*
* @param maxItemBytes - Maximum allowed bytes per line (item)
* @returns TransformStream that outputs parsed JSON objects
*/
export function createNdjsonParserStream(
maxItemBytes: number
): TransformStream<Uint8Array, unknown> {
// Single decoder instance, reused for all lines
const decoder = new TextDecoder("utf-8", { fatal: true });
// Byte buffer: array of chunks with tracked total length
let chunks: Uint8Array[] = [];
let totalBytes = 0;
let lineNumber = 0;
const NEWLINE_BYTE = 0x0a; // '\n'
/**
* Concatenate all chunks into a single Uint8Array
*/
function concatenateChunks(): Uint8Array {
if (chunks.length === 0) {
return new Uint8Array(0);
}
if (chunks.length === 1) {
return chunks[0];
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
/**
* Find the index of the first newline byte in the buffer.
* Returns -1 if not found.
*/
function findNewlineIndex(): number {
let globalIndex = 0;
for (const chunk of chunks) {
for (let i = 0; i < chunk.byteLength; i++) {
if (chunk[i] === NEWLINE_BYTE) {
return globalIndex + i;
}
}
globalIndex += chunk.byteLength;
}
return -1;
}
/**
* Extract bytes from the buffer up to (but not including) the given index,
* and remove those bytes plus the delimiter from the buffer.
*/
function extractLine(newlineIndex: number): Uint8Array {
const fullBuffer = concatenateChunks();
const lineBytes = fullBuffer.slice(0, newlineIndex);
const remaining = fullBuffer.slice(newlineIndex + 1); // Skip the newline
// Reset buffer with remaining bytes
if (remaining.byteLength > 0) {
chunks = [remaining];
totalBytes = remaining.byteLength;
} else {
chunks = [];
totalBytes = 0;
}
return lineBytes;
}
/**
* Parse a line from bytes, handling whitespace trimming.
* Returns the parsed object or null for empty lines.
*/
function parseLine(
lineBytes: Uint8Array,
controller: TransformStreamDefaultController<unknown>
): void {
lineNumber++;
// Decode the line bytes (stream: false since this is a complete line)
let lineText: string;
try {
lineText = decoder.decode(lineBytes, { stream: false });
} catch (err) {
throw new Error(`Invalid UTF-8 at line ${lineNumber}: ${(err as Error).message}`);
}
const trimmed = lineText.trim();
if (!trimmed) {
return; // Skip empty lines
}
try {
const obj = JSON.parse(trimmed);
controller.enqueue(obj);
} catch (err) {
throw new Error(`Invalid JSON at line ${lineNumber}: ${(err as Error).message}`);
}
}
return new TransformStream<Uint8Array, unknown>({
transform(chunk, controller) {
// Append chunk to buffer
chunks.push(chunk);
totalBytes += chunk.byteLength;
// Process all complete lines in the buffer
let newlineIndex: number;
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})`
);
}
const lineBytes = extractLine(newlineIndex);
parseLine(lineBytes, controller);
}
// 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)`
);
}
},
flush(controller) {
// Flush any remaining bytes from the decoder's internal state
// This handles multibyte characters that may have been split across chunks
decoder.decode(new Uint8Array(0), { stream: false });
// Process any remaining buffered data (no trailing newline case)
if (totalBytes === 0) {
return;
}
// 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})`
);
}
const finalBytes = concatenateChunks();
parseLine(finalBytes, controller);
},
});
}
/**
* Convert a ReadableStream into an AsyncIterable.
* Useful for processing streams with for-await-of loops.
*/
export async function* streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
} finally {
reader.releaseLock();
}
}
@@ -34,7 +34,6 @@ import { IdempotencyKeyConcern } from "../concerns/idempotencyKeys.server";
import type {
PayloadProcessor,
QueueManager,
RunNumberIncrementer,
TraceEventConcern,
TriggerRacepoints,
TriggerRacepointSystem,
@@ -54,7 +53,6 @@ export class RunEngineTriggerTaskService {
private readonly validator: TriggerTaskValidator;
private readonly payloadProcessor: PayloadProcessor;
private readonly idempotencyKeyConcern: IdempotencyKeyConcern;
private readonly runNumberIncrementer: RunNumberIncrementer;
private readonly prisma: PrismaClientOrTransaction;
private readonly engine: RunEngine;
private readonly tracer: Tracer;
@@ -69,7 +67,6 @@ export class RunEngineTriggerTaskService {
validator: TriggerTaskValidator;
payloadProcessor: PayloadProcessor;
idempotencyKeyConcern: IdempotencyKeyConcern;
runNumberIncrementer: RunNumberIncrementer;
traceEventConcern: TraceEventConcern;
tracer: Tracer;
metadataMaximumSize: number;
@@ -81,7 +78,6 @@ export class RunEngineTriggerTaskService {
this.validator = opts.validator;
this.payloadProcessor = opts.payloadProcessor;
this.idempotencyKeyConcern = opts.idempotencyKeyConcern;
this.runNumberIncrementer = opts.runNumberIncrementer;
this.tracer = opts.tracer;
this.traceEventConcern = opts.traceEventConcern;
this.metadataMaximumSize = opts.metadataMaximumSize;
@@ -164,10 +160,34 @@ export class RunEngineTriggerTaskService {
}
}
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(body.options?.delay));
// Parse delay from either explicit delay option or debounce.delay
const delaySource = body.options?.delay ?? body.options?.debounce?.delay;
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(delaySource));
if (parseDelayError) {
throw new ServiceValidationError(`Invalid delay ${body.options?.delay}`);
throw new ServiceValidationError(`Invalid delay ${delaySource}`);
}
// Validate debounce options
if (body.options?.debounce) {
if (!delayUntil) {
throw new ServiceValidationError(
`Debounce requires a valid delay duration. Provided: ${body.options.debounce.delay}`
);
}
// Always validate debounce.delay separately since it's used for rescheduling
// This catches the case where options.delay is valid but debounce.delay is invalid
const [debounceDelayError, debounceDelayUntil] = await tryCatch(
parseDelay(body.options.debounce.delay)
);
if (debounceDelayError || !debounceDelayUntil) {
throw new ServiceValidationError(
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
);
}
}
const ttl =
@@ -271,97 +291,129 @@ export class RunEngineTriggerTaskService {
triggerRequest,
parentRun?.taskEventStore,
async (event, store) => {
const result = await this.runNumberIncrementer.incrementRunNumber(
triggerRequest,
async (num) => {
event.setAttribute("queueName", queueName);
span.setAttribute("queueName", queueName);
event.setAttribute("runId", runFriendlyId);
span.setAttribute("runId", runFriendlyId);
event.setAttribute("queueName", queueName);
span.setAttribute("queueName", queueName);
event.setAttribute("runId", runFriendlyId);
span.setAttribute("runId", runFriendlyId);
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
const taskRun = await this.engine.trigger(
{
number: num,
friendlyId: runFriendlyId,
environment: environment,
idempotencyKey,
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
taskIdentifier: taskId,
payload: payloadPacket.data ?? "",
payloadType: payloadPacket.dataType,
context: body.context,
traceContext: this.#propagateExternalTraceContext(
event.traceContext,
parentRun?.traceContext,
event.traceparent?.spanId
),
traceId: event.traceId,
spanId: event.spanId,
parentSpanId:
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
lockedToVersionId: lockedToBackgroundWorker?.id,
taskVersion: lockedToBackgroundWorker?.version,
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
cliVersion: lockedToBackgroundWorker?.cliVersion,
concurrencyKey: body.options?.concurrencyKey,
queue: queueName,
lockedQueueId,
workerQueue,
isTest: body.options?.test ?? false,
delayUntil,
queuedAt: delayUntil ? undefined : new Date(),
maxAttempts: body.options?.maxAttempts,
taskEventStore: store,
ttl,
tags,
oneTimeUseToken: options.oneTimeUseToken,
parentTaskRunId: parentRun?.id,
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
batch: options?.batchId
? {
id: options.batchId,
index: options.batchIndex ?? 0,
}
: undefined,
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
depth,
metadata: metadataPacket?.data,
metadataType: metadataPacket?.dataType,
seedMetadata: metadataPacket?.data,
seedMetadataType: metadataPacket?.dataType,
maxDurationInSeconds: body.options?.maxDuration
? clampMaxDuration(body.options.maxDuration)
: undefined,
machine: body.options?.machine,
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
queueTimestamp:
options.queueTimestamp ??
(parentRun && body.options?.resumeParentOnCompletion
? parentRun.queueTimestamp ?? undefined
: undefined),
scheduleId: options.scheduleId,
scheduleInstanceId: options.scheduleInstanceId,
createdAt: options.overrideCreatedAt,
bulkActionId: body.options?.bulkActionId,
planType,
realtimeStreamsVersion: options.realtimeStreamsVersion,
},
this.prisma
);
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
if (error) {
event.failWithError(error);
}
return { run: taskRun, error, isCached: false };
}
const taskRun = await this.engine.trigger(
{
friendlyId: runFriendlyId,
environment: environment,
idempotencyKey,
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
taskIdentifier: taskId,
payload: payloadPacket.data ?? "",
payloadType: payloadPacket.dataType,
context: body.context,
traceContext: this.#propagateExternalTraceContext(
event.traceContext,
parentRun?.traceContext,
event.traceparent?.spanId
),
traceId: event.traceId,
spanId: event.spanId,
parentSpanId:
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
lockedToVersionId: lockedToBackgroundWorker?.id,
taskVersion: lockedToBackgroundWorker?.version,
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
cliVersion: lockedToBackgroundWorker?.cliVersion,
concurrencyKey: body.options?.concurrencyKey,
queue: queueName,
lockedQueueId,
workerQueue,
isTest: body.options?.test ?? false,
delayUntil,
queuedAt: delayUntil ? undefined : new Date(),
maxAttempts: body.options?.maxAttempts,
taskEventStore: store,
ttl,
tags,
oneTimeUseToken: options.oneTimeUseToken,
parentTaskRunId: parentRun?.id,
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
batch: options?.batchId
? {
id: options.batchId,
index: options.batchIndex ?? 0,
}
: undefined,
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
depth,
metadata: metadataPacket?.data,
metadataType: metadataPacket?.dataType,
seedMetadata: metadataPacket?.data,
seedMetadataType: metadataPacket?.dataType,
maxDurationInSeconds: body.options?.maxDuration
? clampMaxDuration(body.options.maxDuration)
: undefined,
machine: body.options?.machine,
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
queueTimestamp:
options.queueTimestamp ??
(parentRun && body.options?.resumeParentOnCompletion
? parentRun.queueTimestamp ?? undefined
: undefined),
scheduleId: options.scheduleId,
scheduleInstanceId: options.scheduleInstanceId,
createdAt: options.overrideCreatedAt,
bulkActionId: body.options?.bulkActionId,
planType,
realtimeStreamsVersion: options.realtimeStreamsVersion,
debounce: body.options?.debounce,
// When debouncing with triggerAndWait, create a span for the debounced trigger
onDebounced:
body.options?.debounce && body.options?.resumeParentOnCompletion
? async ({ existingRun, waitpoint, debounceKey }) => {
return await this.traceEventConcern.traceDebouncedRun(
triggerRequest,
parentRun?.taskEventStore,
{
existingRun,
debounceKey,
incomplete: waitpoint.status === "PENDING",
isError: waitpoint.outputIsError,
},
async (spanEvent) => {
const spanId =
options?.parentAsLinkType === "replay"
? spanEvent.spanId
: spanEvent.traceparent?.spanId
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
: spanEvent.spanId;
return spanId;
}
);
}
: undefined,
},
this.prisma
);
// If the returned run has a different friendlyId, it was debounced.
// For triggerAndWait: stop the outer span since a replacement debounced span was created via onDebounced.
// For regular trigger: let the span complete normally - no replacement span needed since the
// original run already has its span from when it was first created.
if (
taskRun.friendlyId !== runFriendlyId &&
body.options?.debounce &&
body.options?.resumeParentOnCompletion
) {
event.stop();
}
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
if (error) {
event.failWithError(error);
}
const result = { run: taskRun, error, isCached: false };
if (result?.error) {
throw new ServiceValidationError(
taskRunErrorToString(taskRunErrorEnhancer(result.error))
@@ -374,7 +426,13 @@ export class RunEngineTriggerTaskService {
} catch (error) {
if (error instanceof RunDuplicateIdempotencyKeyError) {
//retry calling this function, because this time it will return the idempotent run
return await this.call({ taskId, environment, body, options, attempt: attempt + 1 });
return await this.call({
taskId,
environment,
body,
options: { ...options, runFriendlyId },
attempt: attempt + 1,
});
}
if (error instanceof RunOneTimeUseTokenError) {
+17 -7
View File
@@ -76,13 +76,6 @@ export interface PayloadProcessor {
process(request: TriggerTaskRequest): Promise<IOPacket>;
}
export interface RunNumberIncrementer {
incrementRunNumber<T>(
request: TriggerTaskRequest,
callback: (num: number) => Promise<T>
): Promise<T | undefined>;
}
export interface TagValidationParams {
tags?: string[] | string;
}
@@ -138,6 +131,12 @@ export type TracedEventSpan = {
};
setAttribute: (key: string, value: string) => void;
failWithError: (error: TaskRunError) => void;
/**
* Stop the span without writing any event.
* Used when a debounced run is returned - the span for the debounced
* trigger is created separately via traceDebouncedRun.
*/
stop: () => void;
};
export interface TraceEventConcern {
@@ -157,6 +156,17 @@ export interface TraceEventConcern {
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T>;
traceDebouncedRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
debounceKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T>;
}
export type TriggerRacepoints = "idempotencyKey";
@@ -61,6 +61,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
"/api/v1/auth/jwt/claims",
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
/^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
/^\/api\/v\d+\/deployments/, // /api/v{1,2,3,n}/deployments/*
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
+10 -1
View File
@@ -2,6 +2,7 @@ import { Authenticator } from "remix-auth";
import type { AuthUser } from "./authUser";
import { addEmailLinkStrategy } from "./emailAuth.server";
import { addGitHubStrategy } from "./gitHubAuth.server";
import { addGoogleStrategy } from "./googleAuth.server";
import { sessionStorage } from "./sessionStorage.server";
import { env } from "~/env.server";
@@ -13,10 +14,18 @@ const isGithubAuthSupported =
typeof env.AUTH_GITHUB_CLIENT_ID === "string" &&
typeof env.AUTH_GITHUB_CLIENT_SECRET === "string";
const isGoogleAuthSupported =
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) {
addGitHubStrategy(authenticator, env.AUTH_GITHUB_CLIENT_ID, env.AUTH_GITHUB_CLIENT_SECRET);
}
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
}
addEmailLinkStrategy(authenticator);
export { authenticator, isGithubAuthSupported };
export { authenticator, isGithubAuthSupported, isGoogleAuthSupported };
@@ -20,7 +20,7 @@ export function addGitHubStrategy(
async ({ extraParams, profile }) => {
const emails = profile.emails;
if (!emails) {
if (!emails?.length) {
throw new Error("GitHub login requires an email address");
}
@@ -0,0 +1,54 @@
import type { Authenticator } from "remix-auth";
import { GoogleStrategy } from "remix-auth-google";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.server";
import { postAuthentication } from "./postAuth.server";
export function addGoogleStrategy(
authenticator: Authenticator<AuthUser>,
clientID: string,
clientSecret: string
) {
const googleStrategy = new GoogleStrategy(
{
clientID,
clientSecret,
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
},
async ({ extraParams, profile }) => {
const emails = profile.emails;
if (!emails?.length) {
throw new Error("Google login requires an email address");
}
try {
logger.debug("Google login", {
emails,
profile,
extraParams,
});
const { user, isNewUser } = await findOrCreateUser({
email: emails[0].value,
authenticationMethod: "GOOGLE",
authenticationProfile: profile,
authenticationExtraParams: extraParams,
});
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
return {
userId: user.id,
};
} catch (error) {
logger.error("Google login failed", { error: JSON.stringify(error) });
throw error;
}
}
);
authenticator.use(googleStrategy);
}
@@ -0,0 +1,25 @@
import { createCookie } from "@remix-run/node";
import { env } from "~/env.server";
export type LastAuthMethod = "github" | "google" | "email";
// Cookie that persists for 1 year to remember the user's last login method
export const lastAuthMethodCookie = createCookie("last-auth-method", {
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
});
export async function getLastAuthMethod(request: Request): Promise<LastAuthMethod | null> {
const cookie = request.headers.get("Cookie");
const value = await lastAuthMethodCookie.parse(cookie);
if (value === "github" || value === "google" || value === "email") {
return value;
}
return null;
}
export async function setLastAuthMethodHeader(method: LastAuthMethod): Promise<string> {
return lastAuthMethodCookie.serialize(method);
}
@@ -17,6 +17,6 @@ function createRequestIdempotencyInstance() {
},
logLevel: env.REQUEST_IDEMPOTENCY_LOG_LEVEL,
ttlInMs: env.REQUEST_IDEMPOTENCY_TTL_IN_MS,
types: ["batch-trigger", "trigger"],
types: ["batch-trigger", "trigger", "create-batch"],
});
}
+22 -6
View File
@@ -288,15 +288,17 @@ export function v3RunPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
run: v3RunForPath,
searchParams?: URLSearchParams
) {
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`;
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
}
export function v3RunRedirectPath(
organization: OrgForPath,
project: ProjectForPath,
run: v3RunForPath
run: v3RunForPath,
) {
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
}
@@ -310,9 +312,12 @@ export function v3RunSpanPath(
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath,
span: v3SpanForPath
span: v3SpanForPath,
searchParams?: URLSearchParams
) {
return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`;
searchParams = searchParams ?? new URLSearchParams();
searchParams.set("span", span.spanId);
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
}
export function v3RunStreamingPath(
@@ -324,6 +329,17 @@ export function v3RunStreamingPath(
return `${v3RunPath(organization, project, environment, run)}/stream`;
}
export function v3RunIdempotencyKeyResetPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
) {
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
project
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
}
export function v3SchedulesPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -407,7 +423,7 @@ export function v3BatchPath(
environment: EnvironmentForPath,
batch: { friendlyId: string }
) {
return `${v3EnvironmentPath(organization, project, environment)}/batches?id=${batch.friendlyId}`;
return `${v3BatchesPath(organization, project, environment)}/${batch.friendlyId}`;
}
export function v3BatchRunsPath(
@@ -4,7 +4,7 @@ import { logger } from "~/services/logger.server";
import { requestIdempotency } from "~/services/requestIdempotencyInstance.server";
import { startActiveSpan } from "~/v3/tracer.server";
type RequestIdempotencyType = "batch-trigger" | "trigger";
type RequestIdempotencyType = "batch-trigger" | "trigger" | "create-batch";
export type IdempotencyConfig<T, R> = {
requestType: RequestIdempotencyType;
@@ -6,8 +6,23 @@ import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { BatchTriggerV3Service } from "./services/batchTriggerV3.server";
// Import engine to ensure it's initialized (which initializes BatchQueue for v2 batches)
import { engine } from "./runEngine.server";
/**
* Legacy batch trigger worker for processing v3 and run engine v1 batches.
*
* NOTE: Run Engine v2 batches (batchVersion: "runengine:v2") use the new BatchQueue
* system with Deficit Round Robin scheduling, which is encapsulated within the RunEngine.
* See runEngine.server.ts for the configuration.
*
* This worker is kept for backwards compatibility with:
* - v3 batches (batchVersion: "v3") - handled by BatchTriggerV3Service
* - Run Engine v1 batches (batchVersion: "runengine:v1") - handled by RunEngineBatchTriggerService
*/
function initializeWorker() {
// Ensure the engine (and its BatchQueue) is initialized
void engine;
const redisOptions = {
keyPrefix: "batch-trigger:worker:",
host: env.BATCH_TRIGGER_WORKER_REDIS_HOST,
@@ -342,7 +342,10 @@ async function getEcrRepository({
return result.repositories[0];
} catch (error) {
if (error instanceof RepositoryNotFoundException) {
if (
error instanceof RepositoryNotFoundException ||
(error instanceof Error && error.message?.includes("does not exist"))
) {
logger.debug("ECR repository not found: RepositoryNotFoundException", {
repositoryName,
region,
+32 -1
View File
@@ -1,11 +1,12 @@
import { RunEngine } from "@internal/run-engine";
import { $replica, prisma } from "~/db.server";
import { env } from "~/env.server";
import { createBatchGlobalRateLimiter } from "~/runEngine/concerns/batchGlobalRateLimiter.server";
import { logger } from "~/services/logger.server";
import { defaultMachine, getCurrentPlan } from "~/services/platform.v3.server";
import { singleton } from "~/utils/singleton";
import { allMachines } from "./machinePresets.server";
import { meter, tracer } from "./tracer.server";
import { logger } from "~/services/logger.server";
export const engine = singleton("RunEngine", createRunEngine);
@@ -155,6 +156,36 @@ function createRunEngine() {
};
},
},
// BatchQueue with DRR scheduling for fair batch processing
// Consumers are controlled by options.worker.disabled (same as main worker)
batchQueue: {
redis: {
keyPrefix: "engine:",
port: env.BATCH_TRIGGER_WORKER_REDIS_PORT ?? undefined,
host: env.BATCH_TRIGGER_WORKER_REDIS_HOST ?? undefined,
username: env.BATCH_TRIGGER_WORKER_REDIS_USERNAME ?? undefined,
password: env.BATCH_TRIGGER_WORKER_REDIS_PASSWORD ?? undefined,
enableAutoPipelining: true,
...(env.BATCH_TRIGGER_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
drr: {
quantum: env.BATCH_QUEUE_DRR_QUANTUM,
maxDeficit: env.BATCH_QUEUE_MAX_DEFICIT,
},
consumerCount: env.BATCH_QUEUE_CONSUMER_COUNT,
consumerIntervalMs: env.BATCH_QUEUE_CONSUMER_INTERVAL_MS,
// Default processing concurrency when no specific limit is set
// This is overridden per-batch based on the plan type at batch creation
defaultConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
// Optional global rate limiter - limits max items/sec processed across all consumers
globalRateLimiter: env.BATCH_QUEUE_GLOBAL_RATE_LIMIT
? createBatchGlobalRateLimiter(env.BATCH_QUEUE_GLOBAL_RATE_LIMIT)
: undefined,
},
// Debounce configuration
debounce: {
maxDebounceDurationMs: env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS,
},
});
return engine;
+213 -4
View File
@@ -1,18 +1,24 @@
import { CompleteBatchResult } from "@internal/run-engine";
import { SpanKind } from "@internal/tracing";
import { tryCatch } from "@trigger.dev/core/utils";
import { createJsonErrorObject, sanitizeError } from "@trigger.dev/core/v3";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import { $replica } from "~/db.server";
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
import { $replica, prisma } from "~/db.server";
import { env } from "~/env.server";
import { findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
import { findEnvironmentById, findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server";
import { reportInvocationUsage } from "~/services/platform.v3.server";
import { MetadataTooLargeError } from "~/utils/packets";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { tracer } from "~/v3/tracer.server";
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
import { recordRunDebugLog, resolveEventRepositoryForStore } from "./eventRepository/index.server";
import { roomFromFriendlyRunId, socketIo } from "./handleSocketIo.server";
import { engine } from "./runEngine.server";
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
import { resolveEventRepositoryForStore, recordRunDebugLog } from "./eventRepository/index.server";
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
export function registerRunEngineEventBusHandlers() {
engine.eventBus.on("runSucceeded", async ({ time, run }) => {
@@ -626,3 +632,206 @@ export function registerRunEngineEventBusHandlers() {
}
});
}
/**
* Set up the BatchQueue processing callbacks.
* These handle creating runs from batch items and completing batches.
*
* Payload handling:
* - If payloadType is "application/store", the payload is an R2 path (already offloaded)
* - DefaultPayloadProcessor in TriggerTaskService will pass it through without re-offloading
* - The run engine will download from R2 when the task executes
*/
export function setupBatchQueueCallbacks() {
// Item processing callback - creates a run for each batch item
engine.setBatchProcessItemCallback(async ({ batchId, friendlyId, itemIndex, item, meta }) => {
return tracer.startActiveSpan(
"batch.processItem",
{
kind: SpanKind.INTERNAL,
attributes: {
"batch.id": friendlyId,
"batch.item_index": itemIndex,
"batch.task": item.task,
"batch.environment_id": meta.environmentId,
"batch.parent_run_id": meta.parentRunId ?? "",
},
},
async (span) => {
try {
const environment = await findEnvironmentById(meta.environmentId);
if (!environment) {
span.setAttribute("batch.result.error", "Environment not found");
span.end();
return {
success: false as const,
error: "Environment not found",
errorCode: "ENVIRONMENT_NOT_FOUND",
};
}
const triggerTaskService = new TriggerTaskService();
// Normalize payload - for application/store (R2 paths), this passes through as-is
const payload = normalizePayload(item.payload, item.payloadType);
const result = await triggerTaskService.call(
item.task,
environment,
{
payload,
options: {
...(item.options as Record<string, unknown>),
payloadType: item.payloadType,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
parentBatch: batchId,
},
},
{
triggerVersion: meta.triggerVersion,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
batchId,
batchIndex: itemIndex,
skipChecks: true, // Already validated at batch level
realtimeStreamsVersion: meta.realtimeStreamsVersion,
planType: meta.planType,
},
"V2"
);
if (result) {
span.setAttribute("batch.result.run_id", result.run.friendlyId);
span.end();
return { success: true as const, runId: result.run.friendlyId };
} else {
span.setAttribute("batch.result.error", "TriggerTaskService returned undefined");
span.end();
return {
success: false as const,
error: "TriggerTaskService returned undefined",
errorCode: "TRIGGER_FAILED",
};
}
} catch (error) {
span.setAttribute(
"batch.result.error",
error instanceof Error ? error.message : String(error)
);
span.recordException(error instanceof Error ? error : new Error(String(error)));
span.end();
return {
success: false as const,
error: error instanceof Error ? error.message : String(error),
errorCode: "TRIGGER_ERROR",
};
}
}
);
});
// Batch completion callback - updates Postgres with results
engine.setBatchCompletionCallback(async (result: CompleteBatchResult) => {
const { batchId, runIds, successfulRunCount, failedRunCount, failures } = result;
// Determine final status
let status: BatchTaskRunStatus;
if (failedRunCount > 0 && successfulRunCount === 0) {
status = "ABORTED";
} else if (failedRunCount > 0) {
status = "PARTIAL_FAILED";
} else {
status = "PENDING"; // All runs created, waiting for completion
}
try {
// Use a transaction to ensure atomicity of batch update and error record creation
// skipDuplicates handles idempotency when callback is retried (relies on unique constraint)
await prisma.$transaction(async (tx) => {
// Update BatchTaskRun
await tx.batchTaskRun.update({
where: { id: batchId },
data: {
status,
runIds,
successfulRunCount,
failedRunCount,
completedAt: status === "ABORTED" ? new Date() : undefined,
processingCompletedAt: new Date(),
},
});
// Create error records if there were failures
if (failures.length > 0) {
await tx.batchTaskRunError.createMany({
data: failures.map((failure) => ({
batchTaskRunId: batchId,
index: failure.index,
taskIdentifier: failure.taskIdentifier,
payload: failure.payload,
options: failure.options as Prisma.InputJsonValue | undefined,
error: failure.error,
errorCode: failure.errorCode,
})),
skipDuplicates: true,
});
}
});
// Try to complete the batch (handles waitpoint completion if all runs are done)
if (status !== "ABORTED") {
await engine.tryCompleteBatch({ batchId });
}
logger.info("Batch completion handled", {
batchId,
status,
successfulRunCount,
failedRunCount,
});
} catch (error) {
logger.error("Failed to handle batch completion", {
batchId,
error: error instanceof Error ? error.message : String(error),
});
// Re-throw to preserve Redis data for retry (BatchQueue expects errors to propagate)
throw error;
}
});
logger.info("BatchQueue callbacks configured");
}
/**
* Normalize the payload from BatchQueue.
*
* Handles different payload types:
* - "application/store": Already offloaded to R2, payload is the path - pass through as-is
* - "application/json": May be a pre-serialized JSON string - parse to avoid double-stringification
* - Other types: Pass through as-is
*
* @param payload - The raw payload from the batch item
* @param payloadType - The payload type (e.g., "application/json", "application/store")
*/
function normalizePayload(payload: unknown, payloadType?: string): unknown {
// Only process "application/json" payloads
// For all other types (including undefined), return as-is
if (payloadType !== "application/json") {
return payload;
}
// For JSON payloads, if payload is a string, try to parse it
// This handles pre-serialized JSON from the SDK
if (typeof payload === "string") {
try {
return JSON.parse(payload);
} catch {
// If it's not valid JSON, return as-is
return payload;
}
}
return payload;
}
@@ -849,7 +849,7 @@ export class BatchTriggerV3Service extends BaseService {
triggerVersion: options?.triggerVersion,
traceContext: options?.traceContext,
spanParentAsLink: options?.spanParentAsLink,
batchId: batch.friendlyId,
batchId: batch.id,
skipChecks: true,
runFriendlyId: task.runId,
realtimeStreamsVersion: options?.realtimeStreamsVersion,
@@ -1,4 +1,4 @@
import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
import { BuildServerMetadata, type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
import { customAlphabet } from "nanoid";
import { env } from "~/env.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
@@ -190,6 +190,21 @@ export class InitializeDeploymentService extends BaseService {
isNativeBuild: payload.isNativeBuild,
});
const buildServerMetadata: BuildServerMetadata | undefined =
payload.isNativeBuild || payload.buildId
? {
buildId: payload.buildId,
...(payload.isNativeBuild
? {
isNativeBuild: payload.isNativeBuild,
artifactKey: payload.artifactKey,
skipPromotion: payload.skipPromotion,
configFilePath: payload.configFilePath,
}
: {}),
}
: undefined;
const deployment = await this._prisma.workerDeployment.create({
data: {
friendlyId: generateFriendlyId("deployment"),
@@ -200,12 +215,14 @@ export class InitializeDeploymentService extends BaseService {
environmentId: environment.id,
projectId: environment.projectId,
externalBuildData,
buildServerMetadata,
triggeredById: triggeredBy?.id,
type: payload.type,
imageReference: imageRef,
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
git: payload.gitMeta ?? undefined,
runtime: payload.runtime ?? undefined,
triggeredVia: payload.triggeredVia ?? undefined,
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
},
});
@@ -0,0 +1,36 @@
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { logger } from "~/services/logger.server";
export class ResetIdempotencyKeyService extends BaseService {
public async call(
idempotencyKey: string,
taskIdentifier: string,
authenticatedEnv: AuthenticatedEnvironment
): Promise<{ id: string }> {
const { count } = await this._prisma.taskRun.updateMany({
where: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
},
data: {
idempotencyKey: null,
idempotencyKeyExpiresAt: null,
},
});
if (count === 0) {
throw new ServiceValidationError(
`No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`,
404
);
}
logger.info(
`Reset idempotency key: ${idempotencyKey} for task: ${taskIdentifier} in env: ${authenticatedEnv.id}, affected ${count} run(s)`
);
return { id: idempotencyKey };
}
}
@@ -4,7 +4,6 @@ import { env } from "~/env.server";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultPayloadProcessor } from "~/runEngine/concerns/payloads.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import { DefaultRunNumberIncrementer } from "~/runEngine/concerns/runNumbers.server";
import { DefaultTraceEventsConcern } from "~/runEngine/concerns/traceEvents.server";
import { RunEngineTriggerTaskService } from "~/runEngine/services/triggerTask.server";
import { DefaultTriggerTaskValidator } from "~/runEngine/validators/triggerTaskValidator";
@@ -106,7 +105,6 @@ export class TriggerTaskService extends WithRunEngine {
this._engine,
traceEventConcern
),
runNumberIncrementer: new DefaultRunNumberIncrementer(),
traceEventConcern,
tracer: tracer,
metadataMaximumSize: env.TASK_RUN_METADATA_MAXIMUM_SIZE,
+3 -2
View File
@@ -14,7 +14,7 @@
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
"start:local": "cross-env node --max-old-space-size=8192 ./build/server.js",
"typecheck": "tsc --noEmit -p ./tsconfig.check.json",
"typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json",
"db:seed": "tsx seed.mts",
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
"test": "vitest --no-file-parallelism",
@@ -191,6 +191,7 @@
"remix-auth": "^3.6.0",
"remix-auth-email-link": "2.0.2",
"remix-auth-github": "^1.6.0",
"remix-auth-google": "^2.0.0",
"remix-typedjson": "0.3.1",
"remix-utils": "^7.7.0",
"seedrandom": "^3.0.5",
@@ -287,4 +288,4 @@
"engines": {
"node": ">=18.19.0 || >=20.6.0"
}
}
}
+158 -39
View File
@@ -1,7 +1,7 @@
import { prisma } from "./app/db.server";
import { createOrganization } from "./app/models/organization.server";
import { createProject } from "./app/models/project.server";
import { AuthenticationMethod } from "@trigger.dev/database";
import { AuthenticationMethod, Organization, Prisma, User } from "@trigger.dev/database";
async function seed() {
console.log("🌱 Starting seed...");
@@ -71,46 +71,11 @@ async function seed() {
// Create or find each project
for (const projectConfig of referenceProjects) {
let project = await prisma.project.findUnique({
where: { externalRef: projectConfig.externalRef },
});
if (!project) {
console.log(`Creating project: ${projectConfig.name}...`);
project = await createProject({
organizationSlug: organization.slug,
name: projectConfig.name,
userId: user.id,
version: "v3",
});
// Update the externalRef to match the expected value
project = await prisma.project.update({
where: { id: project.id },
data: { externalRef: projectConfig.externalRef },
});
console.log(`✅ Created project: ${project.name} (${project.externalRef})`);
} else {
console.log(`✅ Project already exists: ${project.name} (${project.externalRef})`);
}
// List the environments for this project
const environments = await prisma.runtimeEnvironment.findMany({
where: { projectId: project.id },
select: {
slug: true,
type: true,
apiKey: true,
},
});
console.log(` Environments for ${project.name}:`);
for (const env of environments) {
console.log(` - ${env.type.toLowerCase()} (${env.slug}): ${env.apiKey}`);
}
await findOrCreateProject(projectConfig.name, organization, user.id, projectConfig.externalRef);
}
await createBatchLimitOrgs(user);
console.log("\n🎉 Seed complete!\n");
console.log("Summary:");
console.log(`User: ${user.email}`);
@@ -121,6 +86,76 @@ async function seed() {
console.log(` - realtime-streams: TRIGGER_PROJECT_REF=proj_klxlzjnzxmbgiwuuwhvb`);
}
async function createBatchLimitOrgs(user: User) {
const org1 = await findOrCreateOrganization("batch-limit-org-1", user, {
batchQueueConcurrencyConfig: { processingConcurrency: 1 },
});
const org2 = await findOrCreateOrganization("batch-limit-org-2", user, {
batchQueueConcurrencyConfig: { processingConcurrency: 5 },
});
const org3 = await findOrCreateOrganization("batch-limit-org-3", user, {
batchQueueConcurrencyConfig: { processingConcurrency: 10 },
});
// Create 3 projects in each organization
const org1Project1 = await findOrCreateProject("batch-limit-project-1", org1, user.id);
const org1Project2 = await findOrCreateProject("batch-limit-project-2", org1, user.id);
const org1Project3 = await findOrCreateProject("batch-limit-project-3", org1, user.id);
const org2Project1 = await findOrCreateProject("batch-limit-project-1", org2, user.id);
const org2Project2 = await findOrCreateProject("batch-limit-project-2", org2, user.id);
const org2Project3 = await findOrCreateProject("batch-limit-project-3", org2, user.id);
const org3Project1 = await findOrCreateProject("batch-limit-project-1", org3, user.id);
const org3Project2 = await findOrCreateProject("batch-limit-project-2", org3, user.id);
const org3Project3 = await findOrCreateProject("batch-limit-project-3", org3, user.id);
console.log("tenants.json");
console.log(
JSON.stringify({
apiUrl: "http://localhost:3030",
tenants: [
{
id: org1Project1.project.externalRef,
secretKey: org1Project1.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org1Project2.project.externalRef,
secretKey: org1Project2.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org1Project3.project.externalRef,
secretKey: org1Project3.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org2Project1.project.externalRef,
secretKey: org2Project1.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org2Project2.project.externalRef,
secretKey: org2Project2.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org2Project3.project.externalRef,
secretKey: org2Project3.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org3Project1.project.externalRef,
secretKey: org3Project1.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org3Project2.project.externalRef,
secretKey: org3Project2.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
{
id: org3Project3.project.externalRef,
secretKey: org3Project3.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
},
],
})
);
}
seed()
.catch((e) => {
console.error("❌ Seed failed:");
@@ -130,3 +165,87 @@ seed()
.finally(async () => {
await prisma.$disconnect();
});
async function findOrCreateOrganization(
title: string,
user: User,
updates?: Prisma.OrganizationUpdateInput
) {
let organization = await prisma.organization.findFirst({
where: {
title: title,
members: {
some: {
userId: user.id,
},
},
},
});
if (!organization) {
console.log(`Creating organization: ${title}...`);
organization = await createOrganization({
title: title,
userId: user.id,
companySize: "1-10",
});
}
if (updates) {
organization = await prisma.organization.update({
where: { id: organization.id },
data: updates,
});
}
return organization;
}
async function findOrCreateProject(
name: string,
organization: Organization,
userId: string,
externalRef?: string
) {
let project = await prisma.project.findFirst({
where: {
name,
organizationId: organization.id,
},
});
if (!project) {
console.log(`Creating project: ${name}...`);
project = await createProject({
organizationSlug: organization.slug,
name,
userId,
version: "v3",
});
if (externalRef) {
project = await prisma.project.update({
where: { id: project.id },
data: { externalRef },
});
}
}
console.log(`✅ Project ready: ${project.name} (${project.externalRef})`);
// list environments for this project
const environments = await prisma.runtimeEnvironment.findMany({
where: { projectId: project.id },
select: {
slug: true,
type: true,
apiKey: true,
},
});
console.log(` Environments for ${project.name}:`);
for (const env of environments) {
console.log(` - ${env.type.toLowerCase()} (${env.slug}): ${env.apiKey}`);
}
return { project, environments };
}
@@ -0,0 +1,849 @@
import { describe, expect, it, vi } from "vitest";
// Mock the db prisma client - needs to be before other imports
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { PrismaClient } from "@trigger.dev/database";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import {
StreamBatchItemsService,
createNdjsonParserStream,
streamToAsyncIterable,
} from "../../app/runEngine/services/streamBatchItems.server";
import { ServiceValidationError } from "../../app/v3/services/baseService.server";
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
describe("StreamBatchItemsService", () => {
/**
* Helper to create a batch directly in the database
*/
async function createBatch(
prisma: PrismaClient,
environmentId: string,
options: {
runCount: number;
status?: "PENDING" | "PROCESSING" | "COMPLETED" | "ABORTED";
sealed?: boolean;
}
) {
const { id, friendlyId } = BatchId.generate();
const batch = await prisma.batchTaskRun.create({
data: {
id,
friendlyId,
runtimeEnvironmentId: environmentId,
status: options.status ?? "PENDING",
runCount: options.runCount,
expectedCount: options.runCount,
runIds: [],
batchVersion: "runengine:v2",
sealed: options.sealed ?? false,
},
});
return batch;
}
/**
* Helper to create an async iterable from items
*/
async function* itemsToAsyncIterable(
items: Array<{ task: string; payload: string; index: number }>
) {
for (const item of items) {
yield item;
}
}
containerTest(
"should seal batch successfully when no race condition",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
disabled: true,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
batchQueue: {
redis: redisOptions,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Create a batch
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
runCount: 2,
status: "PENDING",
sealed: false,
});
// Initialize the batch in Redis
await engine.initializeBatch({
batchId: batch.id,
friendlyId: batch.friendlyId,
environmentId: authenticatedEnvironment.id,
environmentType: authenticatedEnvironment.type,
organizationId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
runCount: 2,
processingConcurrency: 10,
});
// Enqueue items directly to Redis (bypassing the service's item processing)
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
task: "test-task",
payload: JSON.stringify({ data: "item1" }),
payloadType: "application/json",
});
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
task: "test-task",
payload: JSON.stringify({ data: "item2" }),
payloadType: "application/json",
});
// Create service with our test engine and prisma
const service = new StreamBatchItemsService({
prisma,
engine,
});
// Create an empty items iterator since items are already enqueued
const items = itemsToAsyncIterable([]);
const result = await service.call(authenticatedEnvironment, batch.friendlyId, items, {
maxItemBytes: 1024 * 1024,
});
expect(result.sealed).toBe(true);
expect(result.id).toBe(batch.friendlyId);
// Verify the batch is sealed in the database
const updatedBatch = await prisma.batchTaskRun.findUnique({
where: { id: batch.id },
});
expect(updatedBatch?.sealed).toBe(true);
expect(updatedBatch?.status).toBe("PROCESSING");
expect(updatedBatch?.sealedAt).toBeDefined();
expect(updatedBatch?.processingStartedAt).toBeDefined();
await engine.quit();
}
);
containerTest(
"should handle race condition when batch already sealed by another request",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
disabled: true,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
batchQueue: {
redis: redisOptions,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Create a batch that is already sealed and PROCESSING (simulating another request won the race)
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
runCount: 2,
status: "PROCESSING",
sealed: true,
});
// Initialize the batch in Redis with full count
await engine.initializeBatch({
batchId: batch.id,
friendlyId: batch.friendlyId,
environmentId: authenticatedEnvironment.id,
environmentType: authenticatedEnvironment.type,
organizationId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
runCount: 2,
processingConcurrency: 10,
});
// Enqueue items directly
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
task: "test-task",
payload: JSON.stringify({ data: "item1" }),
payloadType: "application/json",
});
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
task: "test-task",
payload: JSON.stringify({ data: "item2" }),
payloadType: "application/json",
});
const service = new StreamBatchItemsService({
prisma,
engine,
});
// This should fail because the batch is already sealed
await expect(
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
maxItemBytes: 1024 * 1024,
})
).rejects.toThrow(ServiceValidationError);
await engine.quit();
}
);
containerTest(
"should return sealed=true when concurrent request already sealed the batch during seal attempt",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
disabled: true,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
batchQueue: {
redis: redisOptions,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Create a batch in PENDING state
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
runCount: 2,
status: "PENDING",
sealed: false,
});
// Initialize the batch in Redis
await engine.initializeBatch({
batchId: batch.id,
friendlyId: batch.friendlyId,
environmentId: authenticatedEnvironment.id,
environmentType: authenticatedEnvironment.type,
organizationId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
runCount: 2,
processingConcurrency: 10,
});
// Enqueue items
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
task: "test-task",
payload: JSON.stringify({ data: "item1" }),
payloadType: "application/json",
});
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
task: "test-task",
payload: JSON.stringify({ data: "item2" }),
payloadType: "application/json",
});
// Create a custom prisma client that simulates a race condition:
// When updateMany is called on batchTaskRun, it returns count: 0 (as if another request beat us)
// but the subsequent findUnique shows the batch is sealed and PROCESSING
const racingPrisma = {
...prisma,
batchTaskRun: {
...prisma.batchTaskRun,
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
updateMany: async () => {
// Simulate another request winning the race - seal the batch first
await prisma.batchTaskRun.update({
where: { id: batch.id },
data: {
sealed: true,
sealedAt: new Date(),
status: "PROCESSING",
processingStartedAt: new Date(),
},
});
// Return 0 as if the conditional update failed
return { count: 0 };
},
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
},
} as unknown as PrismaClient;
const service = new StreamBatchItemsService({
prisma: racingPrisma,
engine,
});
// Call the service - it should detect the race and return success since batch is sealed
const result = await service.call(
authenticatedEnvironment,
batch.friendlyId,
itemsToAsyncIterable([]),
{
maxItemBytes: 1024 * 1024,
}
);
// Should return sealed=true because the batch was sealed (by the "other" request)
expect(result.sealed).toBe(true);
expect(result.id).toBe(batch.friendlyId);
// Verify the batch is sealed in the database
const updatedBatch = await prisma.batchTaskRun.findUnique({
where: { id: batch.id },
});
expect(updatedBatch?.sealed).toBe(true);
expect(updatedBatch?.status).toBe("PROCESSING");
await engine.quit();
}
);
containerTest(
"should throw error when race condition leaves batch in unexpected state",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
disabled: true,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
batchQueue: {
redis: redisOptions,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Create a batch in PENDING state
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
runCount: 2,
status: "PENDING",
sealed: false,
});
// Initialize the batch in Redis
await engine.initializeBatch({
batchId: batch.id,
friendlyId: batch.friendlyId,
environmentId: authenticatedEnvironment.id,
environmentType: authenticatedEnvironment.type,
organizationId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
runCount: 2,
processingConcurrency: 10,
});
// Enqueue items
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
task: "test-task",
payload: JSON.stringify({ data: "item1" }),
payloadType: "application/json",
});
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
task: "test-task",
payload: JSON.stringify({ data: "item2" }),
payloadType: "application/json",
});
// Create a custom prisma client that simulates a race condition where
// the batch ends up in an unexpected state (ABORTED instead of PROCESSING)
const racingPrisma = {
...prisma,
batchTaskRun: {
...prisma.batchTaskRun,
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
updateMany: async () => {
// Simulate the batch being aborted by another process
await prisma.batchTaskRun.update({
where: { id: batch.id },
data: {
sealed: true,
status: "ABORTED",
},
});
// Return 0 as if the conditional update failed
return { count: 0 };
},
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
},
} as unknown as PrismaClient;
const service = new StreamBatchItemsService({
prisma: racingPrisma,
engine,
});
// Call the service - it should throw because the batch is in an unexpected state
await expect(
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
maxItemBytes: 1024 * 1024,
})
).rejects.toThrow(/unexpected state/);
await engine.quit();
}
);
containerTest(
"should return sealed=false when item count does not match",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
disabled: true,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
batchQueue: {
redis: redisOptions,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Create a batch expecting 3 items
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
runCount: 3,
status: "PENDING",
sealed: false,
});
// Initialize the batch in Redis
await engine.initializeBatch({
batchId: batch.id,
friendlyId: batch.friendlyId,
environmentId: authenticatedEnvironment.id,
environmentType: authenticatedEnvironment.type,
organizationId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
runCount: 3,
processingConcurrency: 10,
});
// Only enqueue 2 items (1 short of expected)
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
task: "test-task",
payload: JSON.stringify({ data: "item1" }),
payloadType: "application/json",
});
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
task: "test-task",
payload: JSON.stringify({ data: "item2" }),
payloadType: "application/json",
});
const service = new StreamBatchItemsService({
prisma,
engine,
});
const result = await service.call(
authenticatedEnvironment,
batch.friendlyId,
itemsToAsyncIterable([]),
{
maxItemBytes: 1024 * 1024,
}
);
// Should return sealed=false because item count doesn't match
expect(result.sealed).toBe(false);
expect(result.enqueuedCount).toBe(2);
expect(result.expectedCount).toBe(3);
// Verify the batch is NOT sealed in the database
const updatedBatch = await prisma.batchTaskRun.findUnique({
where: { id: batch.id },
});
expect(updatedBatch?.sealed).toBe(false);
expect(updatedBatch?.status).toBe("PENDING");
await engine.quit();
}
);
});
describe("createNdjsonParserStream", () => {
/**
* Helper to collect all items from a ReadableStream
*/
async function collectStream<T>(stream: ReadableStream<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of streamToAsyncIterable(stream)) {
results.push(item);
}
return results;
}
/**
* Helper to create a ReadableStream from an array of Uint8Array chunks
*/
function chunksToStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let index = 0;
return new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(chunks[index++]);
} else {
controller.close();
}
},
});
}
it("should parse basic NDJSON correctly", async () => {
const ndjson = '{"name":"alice"}\n{"name":"bob"}\n{"name":"charlie"}\n';
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(ndjson)]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]);
});
it("should handle NDJSON without trailing newline", async () => {
const ndjson = '{"id":1}\n{"id":2}';
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(ndjson)]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ id: 1 }, { id: 2 }]);
});
it("should skip empty lines", async () => {
const ndjson = '{"a":1}\n\n{"b":2}\n \n{"c":3}\n';
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(ndjson)]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
});
it("should handle chunks split mid-line", async () => {
// Split '{"split":"value"}\n' across multiple chunks
const encoder = new TextEncoder();
const chunks = [encoder.encode('{"spl'), encoder.encode('it":"va'), encoder.encode('lue"}\n')];
const stream = chunksToStream(chunks);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ split: "value" }]);
});
it("should handle multibyte UTF-8 characters split across chunks", async () => {
// Test with emoji and other multibyte characters
// The emoji "😀" is 4 bytes: 0xF0 0x9F 0x98 0x80
const json = '{"emoji":"😀"}\n';
const fullBytes = new TextEncoder().encode(json);
// Split in the middle of the emoji (between byte 2 and 3 of the 4-byte sequence)
// Find where the emoji starts
const emojiStart = fullBytes.indexOf(0xf0);
expect(emojiStart).toBeGreaterThan(0);
// Split after first 2 bytes of the emoji
const chunk1 = fullBytes.slice(0, emojiStart + 2);
const chunk2 = fullBytes.slice(emojiStart + 2);
const stream = chunksToStream([chunk1, chunk2]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ emoji: "😀" }]);
});
it("should handle multiple multibyte characters across chunks", async () => {
// Japanese text: "こんにちは" (each hiragana is 3 bytes in UTF-8)
const json = '{"greeting":"こんにちは"}\n';
const fullBytes = new TextEncoder().encode(json);
// Split into many small chunks to stress test UTF-8 handling
const chunkSize = 3; // Deliberately misaligned with UTF-8 boundaries
const chunks: Uint8Array[] = [];
for (let i = 0; i < fullBytes.length; i += chunkSize) {
chunks.push(fullBytes.slice(i, i + chunkSize));
}
const stream = chunksToStream(chunks);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ greeting: "こんにちは" }]);
});
it("should reject lines exceeding maxItemBytes", async () => {
const maxBytes = 50;
// Create a line that exceeds the limit
const largeJson = JSON.stringify({ data: "x".repeat(100) }) + "\n";
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(largeJson)]);
const parser = createNdjsonParserStream(maxBytes);
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(/exceeds maximum size/);
});
it("should reject unbounded accumulation without newlines", async () => {
const maxBytes = 50;
// Send data without any newlines that exceeds the buffer limit
const encoder = new TextEncoder();
const chunks = [
encoder.encode('{"start":"'),
encoder.encode("x".repeat(60)), // This will push buffer over 50 bytes
];
const stream = chunksToStream(chunks);
const parser = createNdjsonParserStream(maxBytes);
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(
/exceeds maximum size.*no newline found/
);
});
it("should check byte size before decoding to prevent OOM", async () => {
// This test verifies that size is checked on raw bytes, not decoded string length
// Unicode characters like "🎉" are 4 bytes but 2 UTF-16 code units (string length 2)
const maxBytes = 30;
// Create a line with emojis - each emoji is 4 bytes
// {"e":"🎉🎉🎉🎉🎉"} = 5 + 20 (5 emojis * 4 bytes) + 2 = 27 bytes - should pass
const smallJson = '{"e":"🎉🎉🎉🎉🎉"}\n';
const smallBytes = new TextEncoder().encode(smallJson);
expect(smallBytes.length).toBeLessThan(maxBytes);
// {"e":"🎉🎉🎉🎉🎉🎉🎉"} = 7 emojis * 4 bytes + overhead = 35 bytes - should fail
const largeJson = '{"e":"🎉🎉🎉🎉🎉🎉🎉"}\n';
const largeBytes = new TextEncoder().encode(largeJson);
expect(largeBytes.length).toBeGreaterThan(maxBytes);
// Small one should succeed
const stream1 = chunksToStream([smallBytes]);
const parser1 = createNdjsonParserStream(maxBytes);
const results1 = await collectStream(stream1.pipeThrough(parser1));
expect(results1).toHaveLength(1);
// Large one should fail
const stream2 = chunksToStream([largeBytes]);
const parser2 = createNdjsonParserStream(maxBytes);
await expect(collectStream(stream2.pipeThrough(parser2))).rejects.toThrow(/exceeds maximum/);
});
it("should handle final line in flush without trailing newline", async () => {
const ndjson = '{"first":1}\n{"second":2}'; // No trailing newline
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(ndjson)]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ first: 1 }, { second: 2 }]);
});
it("should reject invalid JSON", async () => {
const ndjson = '{"valid":true}\n{invalid json}\n';
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(ndjson)]);
const parser = createNdjsonParserStream(1024);
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(
/Invalid JSON at line 2/
);
});
it("should reject invalid UTF-8 sequences", async () => {
// Invalid UTF-8: 0xFF is never valid in UTF-8
const invalidBytes = new Uint8Array([
0x7b,
0x22,
0x78,
0x22,
0x3a,
0xff,
0x7d,
0x0a, // {"x":}\n with invalid byte
]);
const stream = chunksToStream([invalidBytes]);
const parser = createNdjsonParserStream(1024);
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(/Invalid UTF-8/);
});
it("should handle many small chunks efficiently", async () => {
// Simulate streaming byte-by-byte
const json = '{"test":"value"}\n';
const bytes = new TextEncoder().encode(json);
const chunks = Array.from(bytes).map((b) => new Uint8Array([b]));
const stream = chunksToStream(chunks);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ test: "value" }]);
});
it("should handle multiple lines per chunk", async () => {
const ndjson = '{"a":1}\n{"b":2}\n{"c":3}\n{"d":4}\n{"e":5}\n';
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(ndjson)]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 }]);
});
it("should handle empty stream", async () => {
const stream = chunksToStream([]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([]);
});
it("should handle stream with only whitespace", async () => {
const encoder = new TextEncoder();
const stream = chunksToStream([encoder.encode(" \n\n \n")]);
const parser = createNdjsonParserStream(1024);
const results = await collectStream(stream.pipeThrough(parser));
expect(results).toEqual([]);
});
});
+455 -14
View File
@@ -27,7 +27,6 @@ import {
MaxAttemptsValidationParams,
ParentRunValidationParams,
PayloadProcessor,
RunNumberIncrementer,
TagValidationParams,
TracedEventSpan,
TraceEventConcern,
@@ -43,15 +42,6 @@ import { setTimeout } from "node:timers/promises";
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
class MockRunNumberIncrementer implements RunNumberIncrementer {
async incrementRunNumber<T>(
request: TriggerTaskRequest,
callback: (num: number) => Promise<T>
): Promise<T | undefined> {
return await callback(1);
}
}
class MockPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
return {
@@ -90,6 +80,7 @@ class MockTraceEventConcern implements TraceEventConcern {
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
},
"test"
);
@@ -114,6 +105,32 @@ class MockTraceEventConcern implements TraceEventConcern {
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
},
"test"
);
}
async traceDebouncedRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
debounceKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return await callback(
{
traceId: "test",
spanId: "test",
traceContext: {},
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
},
"test"
);
@@ -192,7 +209,6 @@ describe("RunEngineTriggerTaskService", () => {
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
runNumberIncrementer: new MockRunNumberIncrementer(),
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
@@ -283,7 +299,6 @@ describe("RunEngineTriggerTaskService", () => {
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
runNumberIncrementer: new MockRunNumberIncrementer(),
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
@@ -463,7 +478,6 @@ describe("RunEngineTriggerTaskService", () => {
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
runNumberIncrementer: new MockRunNumberIncrementer(),
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
@@ -647,7 +661,6 @@ describe("RunEngineTriggerTaskService", () => {
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
runNumberIncrementer: new MockRunNumberIncrementer(),
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
@@ -731,4 +744,432 @@ describe("RunEngineTriggerTaskService", () => {
await engine.quit();
}
);
containerTest(
"should preserve runFriendlyId across retries when RunDuplicateIdempotencyKeyError is thrown",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
logLevel: "debug",
});
const parentTask = "parent-task";
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
// Create background worker
await setupBackgroundWorker(engine, authenticatedEnvironment, [parentTask, taskIdentifier]);
// Create parent runs and start their attempts (required for resumeParentOnCompletion)
const parentRun1 = await engine.trigger(
{
number: 1,
friendlyId: "run_p1",
environment: authenticatedEnvironment,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12345",
spanId: "s12345",
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued = await engine.dequeueFromWorkerQueue({
consumerId: "test_12345",
workerQueue: "main",
});
await engine.startRunAttempt({
runId: parentRun1.id,
snapshotId: dequeued[0].snapshot.id,
});
const parentRun2 = await engine.trigger(
{
number: 2,
friendlyId: "run_p2",
environment: authenticatedEnvironment,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12346",
spanId: "s12346",
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued2 = await engine.dequeueFromWorkerQueue({
consumerId: "test_12345",
workerQueue: "main",
});
await engine.startRunAttempt({
runId: parentRun2.id,
snapshotId: dequeued2[0].snapshot.id,
});
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerRacepointSystem = new MockTriggerRacepointSystem();
// Track all friendlyIds passed to the payload processor
const processedFriendlyIds: string[] = [];
class TrackingPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
processedFriendlyIds.push(request.friendlyId);
return {
data: JSON.stringify(request.body.payload),
dataType: "application/json",
};
}
}
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new TrackingPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
triggerRacepointSystem,
});
const idempotencyKey = "test-preserve-friendly-id";
const racepoint = triggerRacepointSystem.registerRacepoint("idempotencyKey", idempotencyKey);
// Trigger two concurrent requests with same idempotency key
// One will succeed, one will fail with RunDuplicateIdempotencyKeyError and retry
const childTriggerPromise1 = triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test1" },
options: {
idempotencyKey,
parentRunId: parentRun1.friendlyId,
resumeParentOnCompletion: true,
},
},
});
const childTriggerPromise2 = triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test2" },
options: {
idempotencyKey,
parentRunId: parentRun2.friendlyId,
resumeParentOnCompletion: true,
},
},
});
await setTimeout(500);
// Resolve the racepoint to allow both requests to proceed
racepoint.resolve();
const result1 = await childTriggerPromise1;
const result2 = await childTriggerPromise2;
// Both should return the same run (one created, one cached)
expect(result1).toBeDefined();
expect(result2).toBeDefined();
expect(result1?.run.friendlyId).toBe(result2?.run.friendlyId);
// The key assertion: When a retry happens due to RunDuplicateIdempotencyKeyError,
// the same friendlyId should be used. We expect exactly 2 calls to payloadProcessor
// (one for each concurrent request), not 3 (which would indicate a new friendlyId on retry)
// Since the retry returns early from the idempotency cache, payloadProcessor is not called again.
expect(processedFriendlyIds.length).toBe(2);
// Verify that we have exactly 2 unique friendlyIds (one per original request)
const uniqueFriendlyIds = new Set(processedFriendlyIds);
expect(uniqueFriendlyIds.size).toBe(2);
await engine.quit();
}
);
containerTest(
"should reject invalid debounce.delay when no explicit delay is provided",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Invalid debounce.delay format (ms not supported)
await expect(
triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
debounce: {
key: "test-key",
delay: "300ms", // Invalid - ms not supported
},
},
},
})
).rejects.toThrow("Debounce requires a valid delay duration");
await engine.quit();
}
);
containerTest(
"should reject invalid debounce.delay even when explicit delay is valid",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Valid explicit delay but invalid debounce.delay
// This is the bug case: the explicit delay passes validation,
// but debounce.delay would fail later when rescheduling
await expect(
triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
delay: "5m", // Valid explicit delay
debounce: {
key: "test-key",
delay: "invalid-delay", // Invalid debounce delay
},
},
},
})
).rejects.toThrow("Invalid debounce delay");
await engine.quit();
}
);
containerTest(
"should accept valid debounce.delay formats",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Valid debounce.delay format
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
debounce: {
key: "test-key",
delay: "5s", // Valid format
},
},
},
});
expect(result).toBeDefined();
expect(result?.run.friendlyId).toBeDefined();
await engine.quit();
}
);
});
@@ -0,0 +1,710 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 1,
"panels": [],
"title": "Processing Health",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 },
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_items_processed_total[5m]))",
"legendFormat": "Items/sec",
"refId": "A"
}
],
"title": "Items Processed Rate",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 },
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_items_failed_total[5m]))",
"legendFormat": "Failed/sec",
"refId": "A"
}
],
"title": "Items Failed Rate",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"max": 1,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 0.9 },
{ "color": "green", "value": 0.95 }
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 },
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_items_processed_total[5m])) / (sum(rate(triggerdotdev_batch_queue_items_processed_total[5m])) + sum(rate(triggerdotdev_batch_queue_items_failed_total[5m])))",
"legendFormat": "Success Rate",
"refId": "A"
}
],
"title": "Success Rate",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 },
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_batches_completed_total[5m]))",
"legendFormat": "Batches/sec",
"refId": "A"
}
],
"title": "Batches Completed Rate",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"id": 6,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_items_processed_total[5m])) by (envId)",
"legendFormat": "Processed - {{envId}}",
"refId": "A"
},
{
"expr": "sum(rate(triggerdotdev_batch_queue_items_failed_total[5m])) by (envId)",
"legendFormat": "Failed - {{envId}}",
"refId": "B"
}
],
"title": "Items Processed/Failed by Environment",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
"id": 7,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_batches_enqueued_total[5m]))",
"legendFormat": "Enqueued",
"refId": "A"
},
{
"expr": "sum(rate(triggerdotdev_batch_queue_batches_completed_total[5m]))",
"legendFormat": "Completed",
"refId": "B"
}
],
"title": "Batches Enqueued vs Completed",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 },
"id": 8,
"panels": [],
"title": "Latency",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "ms"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"id": 9,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(triggerdotdev_batch_queue_item_queue_time_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, sum(rate(triggerdotdev_batch_queue_item_queue_time_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, sum(rate(triggerdotdev_batch_queue_item_queue_time_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "Item Queue Time (time from enqueue to processing)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "ms"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"id": 10,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(triggerdotdev_batch_queue_batch_processing_duration_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, sum(rate(triggerdotdev_batch_queue_batch_processing_duration_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, sum(rate(triggerdotdev_batch_queue_batch_processing_duration_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "Batch Processing Duration (creation to completion)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 24 },
"id": 11,
"panels": [],
"title": "Queue Depth",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 25 },
"id": 12,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "sum(triggerdotdev_batch_queue_inflight_count_messages)",
"legendFormat": "In-flight",
"refId": "A"
}
],
"title": "Messages In-Flight",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 25 },
"id": 13,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "sum(triggerdotdev_batch_queue_master_queue_length_queues)",
"legendFormat": "Active Queues",
"refId": "A"
}
],
"title": "Active Queues in Master Queue",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 10 }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 25 },
"id": 14,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "sum(triggerdotdev_batch_queue_dlq_length_messages)",
"legendFormat": "DLQ Size",
"refId": "A"
}
],
"title": "Dead Letter Queue (should be 0)",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 },
"id": 15,
"panels": [],
"title": "FairQueue Internals",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
"id": 16,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_messages_completed_total[5m]))",
"legendFormat": "Completed",
"refId": "A"
},
{
"expr": "sum(rate(triggerdotdev_batch_queue_messages_failed_total[5m]))",
"legendFormat": "Failed",
"refId": "B"
},
{
"expr": "sum(rate(triggerdotdev_batch_queue_messages_retried_total[5m]))",
"legendFormat": "Retried",
"refId": "C"
}
],
"title": "FairQueue Message Processing",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "ms"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
"id": 17,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(triggerdotdev_batch_queue_message_processing_time_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, sum(rate(triggerdotdev_batch_queue_message_processing_time_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, sum(rate(triggerdotdev_batch_queue_message_processing_time_milliseconds_bucket[5m])) by (le))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "FairQueue Message Processing Time",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 42 },
"id": 18,
"panels": [],
"title": "Items Enqueued",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 43 },
"id": 19,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "sum(rate(triggerdotdev_batch_queue_items_enqueued_total[5m])) by (envId)",
"legendFormat": "Enqueued - {{envId}}",
"refId": "A"
}
],
"title": "Items Enqueued Rate by Environment",
"type": "timeseries"
}
],
"refresh": "10s",
"schemaVersion": 39,
"tags": ["trigger.dev", "batch-queue"],
"templating": { "list": [] },
"time": { "from": "now-15m", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "Batch Queue Metrics",
"uid": "batch-queue-metrics",
"version": 3
}
@@ -0,0 +1,17 @@
# Grafana dashboard provisioning
# Automatically loads dashboard JSON files from the dashboards folder
apiVersion: 1
providers:
- name: "Trigger.dev Dashboards"
orgId: 1
folder: "Trigger.dev"
folderUid: "triggerdotdev"
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
@@ -0,0 +1,446 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 1,
"panels": [],
"title": "Event Loop Health",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"max": 1,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.7 },
{ "color": "red", "value": 0.9 }
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 },
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "triggerdotdev_nodejs_event_loop_utilization_ratio",
"legendFormat": "ELU",
"refId": "A"
}
],
"title": "Event Loop Utilization",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.5 }
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 },
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "triggerdotdev_nodejs_eventloop_lag_p99_seconds",
"legendFormat": "p99 Lag",
"refId": "A"
}
],
"title": "Event Loop Lag (p99)",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.05 },
{ "color": "red", "value": 0.1 }
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 },
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "triggerdotdev_nodejs_eventloop_lag_mean_seconds",
"legendFormat": "Mean Lag",
"refId": "A"
}
],
"title": "Event Loop Lag (Mean)",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 },
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.3.0",
"targets": [
{
"expr": "triggerdotdev_nodejs_uv_threadpool_size_threads",
"legendFormat": "UV Threadpool",
"refId": "A"
}
],
"title": "UV Threadpool Size",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"max": 1,
"min": 0,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "percentunit"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"id": 6,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "triggerdotdev_nodejs_event_loop_utilization_ratio",
"legendFormat": "Event Loop Utilization",
"refId": "A"
}
],
"title": "Event Loop Utilization Over Time",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
"id": 7,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "triggerdotdev_nodejs_eventloop_lag_p50_seconds",
"legendFormat": "p50",
"refId": "A"
},
{
"expr": "triggerdotdev_nodejs_eventloop_lag_p90_seconds",
"legendFormat": "p90",
"refId": "B"
},
{
"expr": "triggerdotdev_nodejs_eventloop_lag_p99_seconds",
"legendFormat": "p99",
"refId": "C"
},
{
"expr": "triggerdotdev_nodejs_eventloop_lag_max_seconds",
"legendFormat": "max",
"refId": "D"
}
],
"title": "Event Loop Lag Percentiles",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 },
"id": 8,
"panels": [],
"title": "Handles & Requests",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"id": 9,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "triggerdotdev_nodejs_active_handles_total_handles",
"legendFormat": "Total Handles",
"refId": "A"
}
],
"title": "Active Handles (Total)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"id": 10,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "triggerdotdev_nodejs_active_requests_total_requests",
"legendFormat": "Total Requests",
"refId": "A"
}
],
"title": "Active Requests (Total)",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"id": 11,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "triggerdotdev_nodejs_active_handles_handles",
"legendFormat": "{{type}}",
"refId": "A"
}
],
"title": "Active Handles by Type",
"type": "timeseries"
}
],
"refresh": "10s",
"schemaVersion": 39,
"tags": ["trigger.dev", "nodejs", "runtime"],
"templating": { "list": [] },
"time": { "from": "now-15m", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "Node.js Runtime",
"uid": "nodejs-runtime",
"version": 1
}
@@ -0,0 +1,18 @@
# Grafana datasource provisioning
# Automatically configures Prometheus as the default datasource
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
manageAlerts: true
prometheusType: Prometheus
+36
View File
@@ -0,0 +1,36 @@
# OpenTelemetry Collector configuration for local development
# Receives OTLP metrics from the webapp and exposes them in Prometheus format
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 10s
send_batch_size: 1024
exporters:
prometheus:
endpoint: 0.0.0.0:8889
namespace: triggerdotdev
const_labels:
source: otel_collector
resource_to_telemetry_conversion:
enabled: true
# Debug exporter for troubleshooting (optional, uncomment to enable)
# debug:
# verbosity: detailed
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
+33
View File
@@ -0,0 +1,33 @@
# Prometheus configuration for local development
# Scrapes metrics from OTEL Collector and the webapp /metrics endpoint
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Scrape OpenTelemetry Collector's Prometheus exporter
# This includes all OTel metrics (batch queue, fair queue, etc.)
- job_name: "otel-collector"
static_configs:
- targets: ["otel-collector:8889"]
metrics_path: /metrics
# Scrape webapp's /metrics endpoint
# This includes Prisma metrics and prom-client default metrics
# Note: The webapp runs on host machine, not in Docker network
# Use host.docker.internal on Mac/Windows, or the actual host IP on Linux
- job_name: "webapp"
static_configs:
- targets: ["host.docker.internal:3030"]
metrics_path: /metrics
# Uncomment if you set TRIGGER_METRICS_AUTH_PASSWORD
# authorization:
# type: Bearer
# credentials: your-password-here
# Prometheus self-monitoring
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
+50 -12
View File
@@ -6,6 +6,8 @@ volumes:
redis-data:
clickhouse-data:
clickhouse-logs:
prometheus-data:
grafana-data:
networks:
app_network:
@@ -164,15 +166,51 @@ services:
- ./config/nginx.conf:/etc/nginx/nginx.conf:ro
- ./config/certs:/etc/nginx/certs:ro
# otel-collector:
# container_name: otel-collector
# image: otel/opentelemetry-collector-contrib:latest
# restart: always
# command: ["--config", "/etc/otel-collector-config.yaml"]
# volumes:
# - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
# ports:
# - "55680:55680"
# - "55681:55681"
# - "4317:4317" # OTLP gRPC receiver
# - "4318:4318" # OTLP http receiver
# Observability stack for local development
otel-collector:
container_name: otel-collector
image: otel/opentelemetry-collector-contrib:0.96.0
restart: always
command: ["--config", "/etc/otel-collector-config.yaml"]
volumes:
- ./config/otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC receiver
- "4318:4318" # OTLP HTTP receiver
- "8889:8889" # Prometheus exporter
networks:
- app_network
prometheus:
container_name: prometheus
image: prom/prometheus:v2.54.1
restart: always
volumes:
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
networks:
- app_network
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.enable-lifecycle"
grafana:
container_name: grafana
image: grafana/grafana:11.3.0
restart: always
volumes:
- grafana-data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "3001:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: false
networks:
- app_network
depends_on:
- prometheus
+252
View File
@@ -0,0 +1,252 @@
# Batch Queue & Fair Queue Metrics Guide
This document provides a comprehensive breakdown of all metrics emitted by the Batch Queue and Fair Queue systems, including what they mean and how to identify degraded system states.
## Overview
The batch queue system consists of two layers:
1. **BatchQueue** (`batch_queue.*`) - High-level batch processing metrics
2. **FairQueue** (`batch-queue.*`) - Low-level message queue metrics (with `name: "batch-queue"`)
Both layers emit metrics that together provide full observability into batch processing.
---
## BatchQueue Metrics
These metrics track batch-level operations.
### Counters
| Metric | Description | Labels |
|--------|-------------|--------|
| `batch_queue.batches_enqueued` | Number of batches initialized for processing | `envId`, `itemCount`, `streaming` |
| `batch_queue.items_enqueued` | Number of individual batch items enqueued | `envId` |
| `batch_queue.items_processed` | Number of batch items successfully processed (turned into runs) | `envId` |
| `batch_queue.items_failed` | Number of batch items that failed processing | `envId`, `errorCode` |
| `batch_queue.batches_completed` | Number of batches that completed (all items processed) | `envId`, `hasFailures` |
### Histograms
| Metric | Description | Unit | Labels |
|--------|-------------|------|--------|
| `batch_queue.batch_processing_duration` | Time from batch creation to completion | ms | `envId`, `itemCount` |
| `batch_queue.item_queue_time` | Time from item enqueue to processing start | ms | `envId` |
---
## FairQueue Metrics (batch-queue namespace)
These metrics track the underlying message queue operations. With the batch queue configuration, they are prefixed with `batch-queue.`.
### Counters
| Metric | Description |
|--------|-------------|
| `batch-queue.messages.enqueued` | Number of messages (batch items) added to the queue |
| `batch-queue.messages.completed` | Number of messages successfully processed |
| `batch-queue.messages.failed` | Number of messages that failed processing |
| `batch-queue.messages.retried` | Number of message retry attempts |
| `batch-queue.messages.dlq` | Number of messages sent to dead letter queue |
### Histograms
| Metric | Description | Unit |
|--------|-------------|------|
| `batch-queue.message.processing_time` | Time to process a single message | ms |
| `batch-queue.message.queue_time` | Time a message spent waiting in queue | ms |
### Observable Gauges
| Metric | Description | Labels |
|--------|-------------|--------|
| `batch-queue.queue.length` | Current number of messages in a queue | `fairqueue.queue_id` |
| `batch-queue.master_queue.length` | Number of active queues in the master queue shard | `fairqueue.shard_id` |
| `batch-queue.inflight.count` | Number of messages currently being processed | `fairqueue.shard_id` |
| `batch-queue.dlq.length` | Number of messages in the dead letter queue | `fairqueue.tenant_id` |
---
## Key Relationships
Understanding how metrics relate helps diagnose issues:
```
batches_enqueued × avg_items_per_batch ≈ items_enqueued
items_enqueued = items_processed + items_failed + items_pending
batches_completed ≤ batches_enqueued (lag indicates processing backlog)
```
---
## Degraded State Indicators
### 🔴 Critical Issues
#### 1. Processing Stopped
**Symptoms:**
- `batch_queue.items_processed` rate drops to 0
- `batch-queue.inflight.count` is 0
- `batch-queue.master_queue.length` is growing
**Likely Causes:**
- Consumer loops crashed
- Redis connection issues
- All consumers blocked by concurrency limits
**Actions:**
- Check webapp logs for "BatchQueue consumers started" message
- Verify Redis connectivity
- Check for "Unknown concurrency group" errors
#### 2. Items Stuck in Queue
**Symptoms:**
- `batch_queue.item_queue_time` p99 > 60 seconds
- `batch-queue.queue.length` growing continuously
- `batch-queue.inflight.count` at max capacity
**Likely Causes:**
- Processing is slower than ingestion
- Concurrency limits too restrictive
- Global rate limiter bottleneck
**Actions:**
- Increase `BATCH_QUEUE_CONSUMER_COUNT`
- Review concurrency limits per environment
- Check `BATCH_QUEUE_GLOBAL_RATE_LIMIT` setting
#### 3. High Failure Rate
**Symptoms:**
- `batch_queue.items_failed` rate > 5% of `items_processed`
- `batch-queue.messages.dlq` increasing
**Likely Causes:**
- TriggerTaskService errors
- Invalid task identifiers
- Downstream service issues
**Actions:**
- Check `errorCode` label distribution on `items_failed`
- Review batch error records in database
- Check TriggerTaskService logs
### 🟡 Warning Signs
#### 4. Growing Backlog
**Symptoms:**
- `batch_queue.batches_enqueued` - `batch_queue.batches_completed` is increasing over time
- `batch-queue.master_queue.length` trending upward
**Likely Causes:**
- Sustained high load
- Processing capacity insufficient
- Specific tenants monopolizing resources
**Actions:**
- Monitor DRR deficit distribution across tenants
- Consider scaling consumers
- Review per-tenant concurrency settings
#### 5. Uneven Tenant Processing
**Symptoms:**
- Some `envId` labels show much higher `item_queue_time` than others
- DRR logs show "tenants blocked by concurrency" frequently
**Likely Causes:**
- Concurrency limits too low for high-volume tenants
- DRR quantum/maxDeficit misconfigured
**Actions:**
- Review `BATCH_CONCURRENCY_*` environment settings
- Adjust DRR parameters if needed
#### 6. Rate Limit Impact
**Symptoms:**
- `batch_queue.item_queue_time` has periodic spikes
- Logs show "Global rate limit reached, waiting"
**Likely Causes:**
- `BATCH_QUEUE_GLOBAL_RATE_LIMIT` is set too low
**Actions:**
- Increase global rate limit if system can handle more throughput
- Or accept as intentional throttling
---
## Recommended Dashboards
### Processing Health
```
# Throughput
rate(batch_queue_items_processed_total[5m])
rate(batch_queue_items_failed_total[5m])
# Success Rate
rate(batch_queue_items_processed_total[5m]) /
(rate(batch_queue_items_processed_total[5m]) + rate(batch_queue_items_failed_total[5m]))
# Batch Completion Rate
rate(batch_queue_batches_completed_total[5m]) / rate(batch_queue_batches_enqueued_total[5m])
```
### Latency
```
# Item Queue Time (p50, p95, p99)
histogram_quantile(0.50, rate(batch_queue_item_queue_time_bucket[5m]))
histogram_quantile(0.95, rate(batch_queue_item_queue_time_bucket[5m]))
histogram_quantile(0.99, rate(batch_queue_item_queue_time_bucket[5m]))
# Batch Processing Duration
histogram_quantile(0.95, rate(batch_queue_batch_processing_duration_bucket[5m]))
```
### Queue Depth
```
# Current backlog
batch_queue_master_queue_length
batch_queue_inflight_count
# DLQ (should be 0)
batch_queue_dlq_length
```
---
## Alert Thresholds (Suggested)
| Condition | Severity | Threshold |
|-----------|----------|-----------|
| Processing stopped | Critical | `items_processed` rate = 0 for 5min |
| High failure rate | Warning | `items_failed` / `items_processed` > 0.05 |
| Queue time p99 | Warning | > 30 seconds |
| Queue time p99 | Critical | > 120 seconds |
| DLQ length | Warning | > 0 |
| Batch completion lag | Warning | `batches_enqueued - batches_completed` > 100 |
---
## Environment Variables Affecting Metrics
| Variable | Impact |
|----------|--------|
| `BATCH_QUEUE_CONSUMER_COUNT` | More consumers = higher throughput, lower queue time |
| `BATCH_QUEUE_CONSUMER_INTERVAL_MS` | Lower = more frequent polling, higher throughput |
| `BATCH_QUEUE_GLOBAL_RATE_LIMIT` | Caps max items/sec, increases queue time if too low |
| `BATCH_CONCURRENCY_FREE/PAID/ENTERPRISE` | Per-tenant concurrency limits |
| `BATCH_QUEUE_DRR_QUANTUM` | Credits per tenant per round (fairness tuning) |
| `BATCH_QUEUE_MAX_DEFICIT` | Max accumulated credits (prevents starvation) |
---
## Debugging Checklist
When investigating batch queue issues:
1. **Check consumer status**: Look for "BatchQueue consumers started" in logs
2. **Check Redis**: Verify connection and inspect keys with prefix `engine:batch-queue:`
3. **Check concurrency**: Look for "tenants blocked by concurrency" debug logs
4. **Check rate limits**: Look for "Global rate limit reached" debug logs
5. **Check DRR state**: Query `batch:drr:deficit` hash in Redis
6. **Check batch status**: Query `BatchTaskRun` table for stuck `PROCESSING` batches
File diff suppressed because it is too large Load Diff
+53 -42
View File
@@ -71,33 +71,40 @@ export default defineConfig({
The `syncVercelEnvVars` build extension syncs environment variables from your Vercel project to Trigger.dev.
<Note>
You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
team settings. You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
[dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
the project with the environment variables you want to sync.
</Note>
<AccordionGroup>
<Accordion title="Setting up authentication including team projects">
You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
team settings.
<Note>
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
environment variable values will be read from `process.env` instead of fetching them from the
Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
API is still used to determine which environment variables are configured for your project, but
the actual values come from the local environment. Reading values from `process.env` allows the
extension to use values that Vercel integrations (such as the Neon integration) set per preview
deployment in the "Provisioning Integrations" phase that happens just before the Vercel build
starts.
</Note>
You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
[dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
the project with the environment variables you want to sync.
</Accordion>
<Note>
If you have the Neon database Vercel integration installed and are running builds outside of the
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
database environment variables. This ensures that the correct database connection strings are used for your
selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
branch-specific database credentials when run locally.
</Note>
<Accordion title="Running in Vercel build environment">
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
environment variable values will be read from `process.env` instead of fetching them from the
Vercel API. This is determined by checking if the `VERCEL` environment variable is present.
The API is still used to determine which environment variables are configured for your project, but
the actual values come from the local environment. Reading values from `process.env` allows the
extension to use values that Vercel integrations (such as the Neon integration) set per preview
deployment in the "Provisioning Integrations" phase that happens just before the Vercel build
starts.
</Accordion>
<Accordion title="Using with Neon database Vercel integration">
If you have the Neon database Vercel integration installed and are running builds outside of the
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
database environment variables.
This ensures that the correct database connection strings are used for your
selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
branch-specific database credentials when run locally.
</Accordion>
</AccordionGroup>
```ts
import { defineConfig } from "@trigger.dev/sdk";
@@ -138,25 +145,29 @@ export default defineConfig({
The `syncNeonEnvVars` build extension syncs environment variables from your Neon database project to Trigger.dev. It automatically detects branches and builds the appropriate database connection strings for your environment.
<Note>
You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
as arguments to the `syncNeonEnvVars` build extension. You can generate a `NEON_ACCESS_TOKEN` in
your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
</Note>
<AccordionGroup>
<Accordion title="Setting up authentication">
You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
as arguments to the `syncNeonEnvVars` build extension.
<Note>
When running the build from a Vercel environment (determined by checking if the `VERCEL`
environment variable is present), this extension is skipped entirely. This is because Neon's
Vercel integration already handles environment variable synchronization in Vercel environments.
</Note>
You can generate a `NEON_ACCESS_TOKEN` in your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
</Accordion>
<Note>
If you have the Neon database Vercel integration installed and are running builds outside of the
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
database environment variables. This ensures that the correct database connection strings are used for your
selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
branch-specific database credentials when run locally.
</Note>
<Accordion title="Running in Vercel environment">
When running the build from a Vercel environment (determined by checking if the `VERCEL`
environment variable is present), this extension is skipped entirely.
This is because Neon's Vercel integration already handles environment variable synchronization in Vercel environments.
</Accordion>
<Accordion title="Using with Neon database Vercel integration">
If you have the Neon database Vercel integration installed and are running builds outside of the
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
database environment variables.
This ensures that the correct database connection strings are used for your selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect branch-specific database credentials when run locally.
</Accordion>
</AccordionGroup>
<Note>
This extension is skipped for `prod` environments. It is designed to sync branch-specific

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