Compare commits

...

67 Commits

Author SHA1 Message Date
Matt Aitken 3900ddadce Release 3.0.0-beta.35 2024-05-30 12:17:23 +01:00
github-actions[bot] ca9e827bd3 chore: Update version for release (beta) (#1130)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-30 12:15:10 +01:00
Matt Aitken 04e936b69b Set the server-side indexing timeout to 180s (from 50s) 2024-05-30 12:08:52 +01:00
Matt Aitken 98ef170299 Set the CLI deploy timeout to 3mins from 1min 2024-05-30 12:08:14 +01:00
Eric Allam e69ffd314a v3: Refactor attempt creation to be worker requested (#1077)
* WIP worker TaskRunAttempt creation

* Handling failing task runs that cannot create an attempt for whatever reason

* Move the visibility queue stuff into a graphile job

* Fixed task runs with unsanitized queue names

* “Borrow” the code from alerts PR to get self hosted deployments working

* Add an admin API endpoint to get info about the shared marqs queue

* Allow admins to view any project metrics

* start adding lazy attempts to prod

* lazy attempt creation for prod workers

* resurrect prod stack traces

* add exception event to failed run spans

* simplify dependency resumes

* fix typecheck

* fix merge

* fresh process for all attempts

* always try sigterm first

* stop heartbeat timeout on non-inplace replace message

* add missing ack on checkpoint creation service failure

* bypass dequeue for retries with running worker

* respect retry delays

* crash runs with invalid run status for execution

* remove debug logs

* fix nack message

* fix version locking

* fresh attempt processes in dev and prod

* improve handling of ipc timeouts

* consider checkpoint failures on cancellation

* add basic chaos monkey to checkpointer

* changeset

* control forced checkpoint simulation via env var

* fix merge

* kill old attempt processes before checkpointing

* detailed perf logging for checkpointing

* add coordinator otlp endpoint example

* improve prod run cancellation

* rename supports lazy attempts migration

* fix graceful exit

* fix retry mechanics

* clear paused state before retry

* remove checkpoint image after push

* crash worker on unrecoverable errors

* refactor unrecoverable error emit

* switch to do hosted busybox image

* increase wait for duration ipc timeout

* add changeset for misc fixes

* fix merge

* fix retry delay span runId

* fix dev retries

* improve prod worker logging

* log checkpoint sizes

* add lazy attempts catalog entries

* Fixed merge issue: use zodFetch, not wrapZodFetch

* Revert "Fixed merge issue: use zodFetch, not wrapZodFetch"

This reverts commit d137e4e1fe.

* importEnvVars uses wrapZodFetch now

* add backwards compat for retries without checkpoints

* handle more cases of unrecoverable runs

* don't kill the child process if it shouldn't be killed

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-30 11:05:52 +01:00
Parker 782d4f75ae Added tip for setting up github actions using npm run to simplify version pinning (#1132)
* added arg to install puppeteer deps to base image

* added tip on deploy setup

* removed docker changes
2024-05-28 12:10:24 +01:00
Matt Aitken b6de469d07 Proxy rate limit (#1131)
* Updated worker types

* Cloudflare rate limiter applied to sendEvent/sendEvents

* Latest wrangler

* Updated to the latest compatibility_date and nest the unsafe bindings properly

* Added some types from a Discord members

* Better logging and added a reset header so the SDK can use it from inside the run function

* Set staging proxy rate limit to 100/60s
2024-05-28 12:09:05 +01:00
Matt Aitken 0dd3447c31 Improved the migration from v2 to v3 guide 2024-05-28 09:39:21 +01:00
Matt Aitken a5a5d3ae21 We could already disable the queue by not setting AWS_SQS_QUEUE_URL
This reverts commit ee3619bbb1.
2024-05-27 11:13:55 +01:00
Matt Aitken ee3619bbb1 Added env var to enable/disable the v2 SqsEventConsumer 2024-05-27 11:04:12 +01:00
Eric Allam d9ad72446e Abort v2 runs when the job version they’re associated with is disabled 2024-05-26 21:30:05 +01:00
Eric Allam a56f9af9fe Safeguard against out of control v2 run executions 2024-05-26 20:06:10 +01:00
Eric Allam ece6ca678a Fix issue when using SDK in non-node environments by scoping the stream import with node: 2024-05-25 21:43:16 +01:00
Matt Aitken 6243ae30bb Send a “sign-up” event to Loops (#1129) 2024-05-24 18:05:37 +01:00
Matt Aitken cec20b96fd Release 3.0.0-beta.34 2024-05-24 15:32:04 +01:00
github-actions[bot] ea4d15536c chore: Update version for release (beta) (#1115)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-24 14:55:09 +01:00
Matt Aitken 2fa5780076 At the root path, if you have no projects yet but you do have an org then go to the new project page 2024-05-24 12:35:33 +01:00
Matt Aitken 914a394d15 Fix for create/edit schedule form blank page when an error is thrown from the service 2024-05-24 10:54:04 +01:00
Matt Aitken 80b1d8c6b1 Task list page: if there’s a newer version then show the icon/type etc for that 2024-05-24 10:54:04 +01:00
Matt Aitken 129c6022a0 Make the resolveEnvVars fn optional in the v3-catalog/trigger.config file 2024-05-24 10:54:04 +01:00
Eric Allam 1ca3b99ace Remove “PAUSED” status from being filterable 2024-05-24 09:44:17 +01:00
Matt Aitken dbbc7760f3 When logging out clear the projectId from the session if there is one 2024-05-23 18:27:31 +01:00
Matt Aitken ff3cd97a3b Fixed the syntax highlighting for JSON and TypeScript with the new Prisma renderer 2024-05-23 17:41:25 +01:00
Eric Allam 3a1b0c486a v3: env var management API (#1116)
* WIP env var management API

* Add import env var API endpoint

* Adding docs and support for using both API keys and PATs when interacting with the env var endpoints

* WIP envvar SDK

* Uploading env vars in a variety of formats now works

* Finish env var endpoints and add resolveEnvVars hook

* Add changeset
2024-05-23 16:11:25 +01:00
Jacob Paris 1f462eaa2f fix: secret store options was a circular import (#1117) 2024-05-23 16:04:21 +01:00
Jacob Paris f537778dc7 Separate classes from client code (#1119)
* fix: move route to route folder

* fix: move service class to its own file
2024-05-23 16:03:45 +01:00
Matt Aitken 974bcc49e8 Speed up the Run log queries (#1126)
* Added traceId index to TaskEvent

* Get less data and get the trace from the run

* Use the read replica in the EventRepository

* Use the regular client to get the run because the redirect from test can be very fast
2024-05-23 15:32:43 +01:00
Matt Aitken 60cbb87b08 New TaskRunNumberCounter that uses taskIdentifier + environmentId (#1124)
* Switch to new TaskRunNumberCounter that uses taskIdentifier + environmentId

* Drop old TaskRunCounter table (not used anymore)

* Revert "Drop old TaskRunCounter table (not used anymore)"

This reverts commit d7311a3d03cd8020df02105da155ceec4fa24b1b.
2024-05-23 13:28:23 +01:00
Matt Aitken 1281d40e4b v2: When a run hits the rate limit reschedule the re-execution (#1125)
* Fix: API rate limit error has the correct seconds until reset

* When a v2 run hits the rate limit, reschedule using the reset timestamp

* Still throw AutoYieldRateLimitErrors

* Reschedule runs from the rate limit

* The stress test timeout should be inside the task

* If the rate limit error is thrown, don’t retry the API request
2024-05-23 13:26:03 +01:00
Matt Aitken 116766f398 Request v3 access (from the app) and disable v2 projects by default (#1123)
* Added v2Enabled and hasRequestedV3 columns to Organization

* Don’t create a project when you create an org

* Form for requesting v3 access

* Reworked the new project form with the different version states. Refined copy on early access

* If the project isn’t in the org then redirect to the new project page

* Better message for existing users

* Tidy imports

* If it’s not the managed cloud then allow them to create v2 projects
2024-05-22 19:38:42 +01:00
Émile Ré c815f28c84 Update contributing instructions (#1121)
* Add OPEN_API_KEY env var in example file

* Update prerequisites and first-time setup
2024-05-22 16:12:40 +01:00
Eric Allam c092c0f9db v3: Prevent legacy-peer-deps=true from breaking deploys 2024-05-22 15:46:13 +01:00
Alexandre Costa a86f36cefa Fix TypeScript inclusion in tsconfig.json for cli-v3 init (#1105)
* Fix TypeScript inclusion in tsconfig.json for cli-v3 init

Fixed an issue where TypeScript files were included in the project directory when no include directive was present in tsconfig.json. Previously, the CLI added trigger.config.ts to the inclusion list by default, causing TypeScript compilation errors for other files. The fix ensures that trigger.config.ts is only added to the inclusion list if there's an existing include directive present in tsconfig.json

* Create hot-fishes-retire.md

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2024-05-21 14:40:49 +01:00
Eric Allam 5a6e79e0c0 Fix missing logs when client includes their own @opentelemetry/api package (#1106) 2024-05-21 14:38:06 +01:00
Jacob Paris 7cb20fbead fix: upgrade prism react renderer (#1111)
* fix: upgrade prism react renderer

* Update CodeBlock.tsx
2024-05-21 14:36:21 +01:00
Eric Allam a561bca315 Merge branch 'jacobparis-vite-inline-prisma-values' 2024-05-21 14:26:59 +01:00
Eric Allam 3f45738612 Fixed typescript errors 2024-05-21 14:19:33 +01:00
Eric Allam 05d3b08ae5 Merge branch 'vite-inline-prisma-values' of github.com:jacobparis/trigger.dev into jacobparis-vite-inline-prisma-values 2024-05-21 14:12:13 +01:00
Eric Allam 3f8b6d8fce v2: Better handle recovering from platform communication errors by auto-yielding back to the platform in case of temporary API failures 2024-05-21 14:07:16 +01:00
Jacob Paris 0a7ad232fb fix: reuse taskrunattemptstatus 2024-05-21 03:49:58 -06:00
Jacob Paris 04f863c42a Merge branch 'main' into vite-inline-prisma-values 2024-05-21 03:46:39 -06:00
Jacob Paris ae5ec86d0b fix: ensure enums satisfy prisma types 2024-05-21 03:44:22 -06:00
nicktrn f243eab9c9 v3: fix version locking (#1113)
* fix prod version locking

* fix worker version display
2024-05-21 08:35:35 +01:00
Matt Aitken a5cba375ae Bulk replaying and canceling from the runs list (#1109)
* WIP on multi-select

* WIP on simple checkbox

* CheckboxWIthLabel and Checkbox

* Multi-selection of runs across pages is working

* Fix for selection on seconds page

* Focus the run filter on page load

* Don’t focus the checkbox

* BulkActionBar now shows/hides and has buttons

* Some state to stop escape clearing the selection when the modals are open

* Delete unused formData util

* Improvements to the page

* Created the replay resource action. It doesn’t do anything useful yet.

* Database schema created for BulkActionGroup/BulkActionItem

* The BulkActionService is creating the right data, now we need to process it

* WIP on bulk processing

* Added failed state and made the sourceRun required

* Bulk replaying is working

* WIP on bulk action filtering

* Fixed bulk filters displaying

* Filtering by batch is working

* Some fixes for the bulk id filtering

* Style tweaks

* Load the extra info in parallel

* Bulk canceling working

* Get the most recent 20 bulk actions to display in the filter menu

* Even if the run isn’t cancelable add it to the final list

* Maximum of 250 runs can be bulk actioned

* Don’t let them select more than the maximum (250 currently)

* Separate each bulk item action into it’s own separate graphile job to increase resiliency

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2024-05-20 17:17:33 +01:00
Eric Allam c9ebe7f114 Update contributing guide for local v3 development 2024-05-20 15:43:31 +01:00
Eric Allam f4f87ad573 Defer the enqueuing of events.deliverScheduled events, to keep the graphile_jobs table smaller 2024-05-20 14:27:20 +01:00
Jacob Paris a98b62652c fix: vite cant resolve real values from prisma, only types are ok 2024-05-18 01:33:47 -06:00
Jacob Paris 38965d8921 fix: vite cant resolve real values from prisma, only types are ok 2024-05-18 01:31:22 -06:00
Matt Aitken 14cffd4e5c Fix for the EnabledStatus component icons and improved the style of the private Slack channel callout 2024-05-17 16:09:59 +01:00
Eric Allam 68107565a4 v3: Fix issues with posting alerts to public slack channels (#1108)
* Fix issues with posting alerts to public slack channels

* Use the actual values in the new environmentTypes column to display the environment type labels in the alerts list

* Implement environment alert options
2024-05-17 15:24:47 +01:00
Alec Dilanchian 8a263c8ab9 Adding note for supabase integration (#1101)
* Update management.mdx

Adding note for custom domains and initializing db object.

* Update management.mdx

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-17 15:11:29 +01:00
James Ritchie 6fecf2dbc0 More info about environments on the alerts page and alerts modal (#1107) 2024-05-17 14:22:40 +01:00
Matt Aitken a8db8ce4e4 Latest lockfile 2024-05-16 19:13:16 +01:00
github-actions[bot] a7ca1222d7 chore: Update version for release (beta) (#1104)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-16 19:00:06 +01:00
Eric Allam c9733f357f Fix issues with special characters in queue/task names causing runs to get stuck in queued 2024-05-16 17:35:50 +01:00
Matt Aitken ba61bfe3b9 Fixed feedback panel links (status page and docs) 2024-05-16 17:30:44 +01:00
James Ritchie 89eaef495f New alerts platform status + Alerts docs (#1098)
* debug tooltip now scrolls

* Added a link to subscribe to alerts and improved the project alerts blank state

* Better external link icon

* Docs: Removed webhook tasks

* Docs: removed limits performance

* Docs: removed FAQs

* Docs: Removed Architecture section

* Docs: Removed API reference: CLI

* Docs: Removed API reference: Objects

* Docs: Removed API reference: Functions

* Docs: removed automated tests

* Docs: removed Middleware

* Docs: removed Using APIs

* Docs: removed Rollbacks

* Docs: removed Trigger Filters

* Docs: removed Webhook Tasks

* Docs: removed Zod Tasks

* Docs: Renamed Community page

* Docs: Added a new Troubleshooting section and Alerts docs page

* Hide the New Alerts button again if list is greater than 10 items

* Customers now have to contact us for Slack Connect Support.

* Fix the alerts docs link

* Added env vars for a different alert email address, and whether the feature is enabled or not

* Only show the alerts sidemenu item if the feature is enabled

* Use a separate email client for sending alerts

* Removed the link to Context from v3 docs

* Removed obvious docs links that are now missing pages

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-16 16:03:25 +01:00
Eric Allam 6a379e4e91 Fix 3rd party otel propagation from breaking our Task Events data from being properly correlated to the correct trace 2024-05-16 12:07:21 +01:00
Eric Allam c0b815c363 Check for an existing restore event before trying to restore a checkpoint 2024-05-16 09:27:44 +01:00
Matt Aitken 598906fc4c Fix for typo in v3 CLI login command 2024-05-16 09:23:45 +01:00
Eric Allam b77ece1567 Log when disabling an endpoint 2024-05-15 21:51:08 +01:00
Eric Allam a73dc80177 Failing jobs shouldn’t log as an error 2024-05-15 21:49:52 +01:00
Eric Allam 2496917a7a v2: Graphile auto-cleanup and auto-endpoint disabling (#1103)
* Auto-cleanup failed graphile jobs instead of keeping them around

* Disable endpoint after a period of sequential indexing failures

* Remove log
2024-05-15 21:15:06 +01:00
Matt Aitken dc53f0f432 Added steps on how to test local tasks 2024-05-15 18:56:48 +01:00
Matt Aitken 87788f29c9 A v3 catalog readme 2024-05-15 18:53:38 +01:00
Eric Allam 6ede68648a Remove alerts side menu (again) 2024-05-15 17:25:41 +01:00
Eric Allam f24b5b770d Protect against doWork getting called mulitple times per consumer 2024-05-15 17:01:03 +01:00
355 changed files with 12769 additions and 4095 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fixing missing logs when importing client @opentelemetry/api
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
v3: Environment variable management API and SDK, along with resolveEnvVars CLI hook
+5
View File
@@ -0,0 +1,5 @@
---
trigger.dev: patch
---
Fix TypeScript inclusion in tsconfig.json for `cli-v3 init`
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Fix issue when using SDK in non-node environments by scoping the stream import with node:
+13 -1
View File
@@ -55,6 +55,7 @@
"clever-apes-collect",
"clever-carrots-travel",
"clever-donkeys-hunt",
"cool-comics-burn",
"cool-glasses-bake",
"cuddly-feet-approve",
"dry-walls-check",
@@ -63,9 +64,11 @@
"eleven-paws-join",
"famous-boats-tease",
"few-students-share",
"five-toes-destroy",
"funny-swans-destroy",
"gorgeous-gorillas-compete",
"green-bags-wink",
"hot-fishes-retire",
"khaki-apricots-design",
"khaki-poems-lay",
"late-icons-lie",
@@ -82,6 +85,7 @@
"mighty-camels-joke",
"mighty-flowers-train",
"nasty-jars-pump",
"new-pants-beg",
"new-rivers-tell",
"nice-bulldogs-turn",
"ninety-pets-travel",
@@ -104,12 +108,15 @@
"shiny-coats-cry",
"silly-suits-switch",
"six-ligers-exist",
"sixty-insects-watch",
"slow-buses-own",
"smart-needles-move",
"smart-olives-eat",
"spicy-lamps-smoke",
"strange-ghosts-matter",
"strange-sheep-pull",
"strong-lemons-add",
"strong-owls-know",
"stupid-bulldogs-applaud",
"sweet-lizards-press",
"swift-dragons-peel",
@@ -118,12 +125,17 @@
"tender-moose-tell",
"tender-oranges-rhyme",
"thin-parents-heal",
"thirty-islands-kiss",
"tidy-balloons-suffer",
"tidy-dryers-sleep",
"tidy-tomatoes-explain",
"tiny-doors-type",
"tiny-elephants-scream",
"tricky-bulldogs-heal",
"tricky-keys-attack",
"tricky-ladybugs-unite",
"two-pumas-wait"
"two-pumas-wait",
"warm-olives-provide",
"warm-planes-taste"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Set the deploy timeout to 3mins from 1min
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
v2: Better handle recovering from platform communication errors by auto-yielding back to the platform in case of temporary API failures
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
When a v2 run hits the rate limit, reschedule with the reset date
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix for typo in v3 CLI login command
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Fix 3rd party otel propagation from breaking our Task Events data from being properly correlated to the correct trace
+14
View File
@@ -0,0 +1,14 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Clear paused states before retry
- Detect and handle unrecoverable worker errors
- Remove checkpoints after successful push
- Permanently switch to DO hosted busybox image
- Fix IPC timeout issue, or at least handle it more gracefully
- Handle checkpoint failures
- Basic chaos monkey for checkpoint testing
- Stack traces are back in the dashboard
- Display final errors on root span
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Improve handling of IPC timeouts and fix checkpoint cancellation after failures
+7
View File
@@ -0,0 +1,7 @@
---
"trigger.dev": patch
---
v3: Prevent legacy-peer-deps=true from breaking deploys
When a global `.npmrc` file includes `legacy-peer-deps=true`, deploys would fail on the `npm ci` step because the package-lock.json wouldn't match the `package.json` file. This is because inside the image build, the `.npmrc` file would not be picked up and so `legacy-peer-deps` would end up being false (which is the default). This change forces the `package-lock.json` file to be created using `legacy-peer-deps=false`
+5
View File
@@ -11,12 +11,17 @@ REMIX_APP_PORT=3030
APP_ENV=development
APP_ORIGIN=http://localhost:3030
NODE_ENV=development
CLOUD_ENV=development
V3_ENABLED=true
# Redis is used for the v3 queuing and v2 concurrency control
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_TLS_DISABLED="true"
DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:3030/otel"
DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
+8
View File
@@ -45,6 +45,14 @@
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 Management",
"command": "pnpm run management",
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
{
"type": "node",
"request": "attach",
+106 -88
View File
@@ -10,13 +10,14 @@ Thank you for helping us make Trigger.dev even better! 🤩
The development branch is `main`. This is the branch that all pull
requests should be made against. The changes on the `main`
branch are tagged into a release monthly.
branch are tagged into a release periodically.
### Prerequisites
- [Node.js](https://nodejs.org/en) version >=16.x
- [pnpm package manager](https://pnpm.io/installation) version 7
- [Node.js](https://nodejs.org/en) version 20.11.1
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
- [Docker](https://www.docker.com/get-started/)
- [protobuf](https://github.com/protocolbuffers/protobuf)
### Setup
@@ -33,15 +34,19 @@ branch are tagged into a release monthly.
```
cd trigger.dev
```
3. Install the required packages using pnpm.
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
5. Install the required packages using pnpm.
```
pnpm i
```
4. Create your `.env` file
6. Create your `.env` file
```
cp .env.example .env
```
5. Open it and generate a new value for `ENCRYPTION_KEY`:
7. Open it and generate a new value for `ENCRYPTION_KEY`:
`ENCRYPTION_KEY` is used to two-way encrypt OAuth access tokens and so you'll probably want to actually generate a unique value, and it must be a random 16 byte hex string. You can generate one with the following command:
@@ -51,7 +56,7 @@ branch are tagged into a release monthly.
Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method.
6. Start Docker. This starts the required services like Postgres. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
8. Start Docker. This starts the required services like Postgres & Redis. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
```
pnpm run docker
@@ -59,19 +64,15 @@ branch are tagged into a release monthly.
This will also start and run a local instance of [pgAdmin](https://www.pgadmin.org/) on [localhost:5480](http://localhost:5480), preconfigured with email `admin@example.com` and pwd `admin`. Then use `postgres` as the password to the Trigger.dev server.
7. Migrate the database
9. Migrate the database
```
pnpm run db:migrate
```
8. Build the app
```
pnpm run build --filter webapp
```
9. Run the seed script
```
pnpm run db:seed
```
10. Run the app. See the section below.
10. Build the server app
```
pnpm run build --filter webapp
```
11. Run the app. See the section below.
## Running
@@ -83,99 +84,96 @@ branch are tagged into a release monthly.
It should run on port `3030`: [http://localhost:3030](http://localhost:3030/)
2. Once the app is running click the magic link button and enter your email.
3. Check your terminal, the magic link email should have printed out as following:
2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard.
```sh
webapp:dev: Log in to Trigger.dev
webapp:dev:
webapp:dev: Click here to log in with this magic link
webapp:dev: [http://localhost:3030/magic?token=U2FsdGVkX18OvB0JxgaswTLCSbaRz%2FY82TN0EZWhSzFyZYwgG%2BIzKVTkeiaOtWfotPw7F8RwFzCHh53aBpMEu%2B%2B%2FItb%2FcJYh89MSjc3Pz92bevoEjqxSQ%2Ff%2BZbks09JOpqlBbYC3FzGWC8vuSVFBlxqLXxteSDLthZSUaC%2BS2LaA%2BJgp%2BLO7hgjAaC2lXbCHrM7MTgTdXOFt7i0Dvvuwz6%2BWY25RnfomZOPqDsyH0xz8Q2rzPTz0Xu53WSXrZ1hd]
webapp:dev:
webapp:dev: If you didn't try to log in, you can safely ignore this email.
```
## Manual testing using v3-catalog
Paste the magic link shown in your terminal into your browser to login.
We use the `<root>/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3).
## Adding and running migrations
### First-time setup
1. Modify packages/database/prisma/schema.prisma file
2. Change directory to the packages/database folder
```sh
cd packages/database
```
3. Generate the Prisma client
First, make sure you are running the webapp according to the instructions above. Then:
```sh
pnpm run generate
```
1. In Postgres go to the "Organizations" table and on your org set the `v3Enabled` column to `true`.
The above updates the prisma client generated into node_modules/.prisma/client folder. This helps with typing of relevant prisma models. It ensures typescript
recognizes fields added or removed from a model and type-checks appropriately.
2. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog". If you don't see an option for V3, you haven't set the `v3Enabled` flag to true.
4. Create and apply the migrations
3. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`.
```
pnpm run db:migrate:dev
```
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
5. Commit generated migrations as well as changes to the schema.prisma file
6. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
## Testing CLI changes
To test CLI changes, follow the steps below:
1. Build the CLI and watch for changes
4. Build the CLI
```sh
cd packages/cli
pnpm run dev
```
2. Open a new Terminal window and run the webapp locally and then create a new project in the dashboard. Copy out the dev API key.
3. Create a new temporary Next.js app in references directory
```sh
cd ./references
pnpm create next-app@latest test-cli --ts --no-eslint --tailwind --app --src-dir --import-alias "@/*"
```
4. Then once that's finished, add the `@trigger.dev/cli` to the `devDependencies` of the newly created Next.js app's `package.json` file, like so:
```json
{
// other package.json properties
"devDependencies": { "@trigger.dev/cli": "workspace:*" }
}
```
5. Back in the terminal, navigate into the reference, and initialize the CLI. When prompted, select `self-hosted` and enter `localhost:3030` if you are testing against the local instance of Trigger.dev, or you can just use the Trigger.dev cloud. When asked for an API key, use the key you copied earlier.
```sh
cd ./test-cli
# Build the CLI
pnpm run build --filter trigger.dev
# Make it accessible to `pnpm exec`
pnpm i
pnpm exec trigger-cli init
```
6. If you are just testing the `init` command, you can stop here. If you'd like to test the `dev` command, first start the Next.js app on port 3000:
5. Change into the `<root>/references/v3-catalog` directory and authorize the CLI to the local server:
```sh
pnpm run dev
cd references/v3-catalog
cp .env.example .env
pnpm exec triggerdev login -a http://localhost:3030
```
7. Open a new terminal window, and then run the `dev` command like so:
This will open a new browser window and authorize the CLI against your local user account.
You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development:
```sh
pnpm exec trigger-cli dev
cd references/v3-catalog
pnpm exec triggerdev login -a http://localhost:3030 --profile local
# later when you run the dev or deploy command:
pnpm exec triggerdev dev --profile local
pnpm exec triggerdev deploy --profile local
```
8. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR.
### Running
## Running end-to-end webapp tests
The following steps should be followed any time you start working on a new feature you want to test in v3:
1. Make sure the webapp is running on localhost:3030
2. Open a terminal window and build the CLI and watch for changes
```sh
pnpm run dev --filter trigger.dev
```
2. Open a new terminal window, and anytime changes are made to the `@trigger.dev/core` package, you'll need to manually rebuild the CLI:
```sh
pnpm run build --filter trigger.dev
```
Note: You do not need to do the same for `@trigger.dev/sdk`, just core.
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
```sh
# in <root>/references/v3-catalog
pnpm exec triggerdev dev
```
If you want additional debug logging, you can use the `--log-level debug` flag:
```sh
# in <root>/references/v3-catalog
pnpm exec triggerdev dev --log-level debug
```
5. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command.
6. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
7. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload.
8. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases.
## Running end-to-end webapp tests (deprecated)
To run the end-to-end tests, follow the steps below:
@@ -223,6 +221,26 @@ The end-to-end tests use a `setup` and `teardown` script to seed the database wi
pnpm run db:studio
```
## Adding and running migrations
1. Modify packages/database/prisma/schema.prisma file
2. Change directory to the packages/database folder
```sh
cd packages/database
```
3. Create and apply the migrations
```
pnpm run db:migrate:dev
```
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
4. Commit generated migrations as well as changes to the schema.prisma file
5. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
## Add sample jobs
The [references/job-catalog](./references/job-catalog/) project defines simple jobs you can get started with.
+296 -50
View File
@@ -1,5 +1,6 @@
import { createServer } from "node:http";
import { $ } from "execa";
import fs from "node:fs/promises";
import { $, type ExecaChildProcess } from "execa";
import { nanoid } from "nanoid";
import { Server } from "socket.io";
import {
@@ -19,6 +20,11 @@ collectDefaultMetrics();
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8020);
const NODE_NAME = process.env.NODE_NAME || "coordinator";
const DEFAULT_RETRY_DELAY_THRESHOLD_IN_MS = 30_000;
const CHAOS_MONKEY_ENABLED = !!process.env.CHAOS_MONKEY_ENABLED;
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
);
const REGISTRY_HOST = process.env.REGISTRY_HOST || "localhost:5000";
const CHECKPOINT_PATH = process.env.CHECKPOINT_PATH || "/checkpoints";
@@ -32,6 +38,10 @@ const SECURE_CONNECTION = ["1", "true"].includes(process.env.SECURE_CONNECTION ?
const logger = new SimpleLogger(`[${NODE_NAME}]`);
if (CHAOS_MONKEY_ENABLED) {
logger.log("🍌 Chaos monkey enabled");
}
type CheckpointerInitializeReturn = {
canCheckpoint: boolean;
willSimulate: boolean;
@@ -49,6 +59,40 @@ type CheckpointData = {
docker: boolean;
};
function isExecaChildProcess(maybeExeca: unknown): maybeExeca is Awaited<ExecaChildProcess> {
return typeof maybeExeca === "object" && maybeExeca !== null && "escapedCommand" in maybeExeca;
}
async function getFileSize(filePath: string): Promise<number> {
try {
const stats = await fs.stat(filePath);
return stats.size;
} catch (error) {
console.error("Error getting file size:", error);
return -1;
}
}
async function getParsedFileSize(filePath: string) {
const sizeInBytes = await getFileSize(filePath);
let message = `Size in bytes: ${sizeInBytes}`;
if (sizeInBytes > 1024 * 1024) {
const sizeInMB = (sizeInBytes / 1024 / 1024).toFixed(2);
message = `Size in MB (rounded): ${sizeInMB}`;
} else if (sizeInBytes > 1024) {
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
message = `Size in KB (rounded): ${sizeInKB}`;
}
return {
path: filePath,
sizeInBytes,
message,
};
}
class Checkpointer {
#initialized = false;
#canCheckpoint = false;
@@ -56,6 +100,7 @@ class Checkpointer {
#logger = new SimpleLogger("[checkptr]");
#abortControllers = new Map<string, AbortController>();
#failedCheckpoints = new Map<string, unknown>();
constructor(private opts = { forceSimulate: false }) {}
@@ -150,7 +195,11 @@ class Checkpointer {
success: !!result,
});
return result;
if (!result.success) {
return;
}
return result.checkpoint;
}
isCheckpointing(runId: string) {
@@ -158,6 +207,13 @@ class Checkpointer {
}
cancelCheckpoint(runId: string): boolean {
// If the last checkpoint failed, pretend we canceled it
// This ensures tasks don't wait for external resume messages to continue
if (this.#hasFailedCheckpoint(runId)) {
this.#clearFailedCheckpoint(runId);
return true;
}
const controller = this.#abortControllers.get(runId);
if (!controller) {
@@ -176,44 +232,58 @@ class Checkpointer {
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
}: CheckpointAndPushOptions): Promise<CheckpointData | undefined> {
}: CheckpointAndPushOptions): Promise<
{ success: true; checkpoint: CheckpointData } | { success: false; reason?: "CANCELED" }
> {
await this.initialize();
const options = {
runId,
leaveRunning,
projectRef,
deploymentVersion,
};
if (!this.#dockerMode && !this.#canCheckpoint) {
this.#logger.error("No checkpoint support. Simulation requires docker.");
return;
return { success: false };
}
if (this.#abortControllers.has(runId)) {
logger.error("Checkpoint procedure already in progress", {
options: {
runId,
leaveRunning,
projectRef,
deploymentVersion,
},
});
return;
logger.error("Checkpoint procedure already in progress", { options });
return { success: false };
}
// This is a new checkpoint, clear any last failure for this run
this.#clearFailedCheckpoint(runId);
const controller = new AbortController();
this.#abortControllers.set(runId, controller);
const $$ = $({ signal: controller.signal });
try {
if (CHAOS_MONKEY_ENABLED) {
console.log("🍌 Chaos monkey wreaking havoc");
const random = Math.random();
if (random < 0.33) {
// Fake long checkpoint duration
await $$`sleep 300`;
} else if (random < 0.66) {
// Fake checkpoint error
await $$`false`;
} else {
// no-op
}
}
const shortCode = nanoid(8);
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
this.#logger.log("Checkpointing:", {
options: {
runId,
leaveRunning,
projectRef,
deploymentVersion,
},
});
this.#logger.log("Checkpointing:", { options });
const containterName = this.#getRunContainerName(runId);
@@ -234,9 +304,9 @@ class Checkpointer {
);
}
}
} catch (error: any) {
this.#logger.error(error.stderr);
return;
} catch (error) {
this.#logger.error("Failed while creating docker checkpoint", { exportLocation });
throw error;
}
this.#logger.log("checkpoint created:", {
@@ -245,8 +315,11 @@ class Checkpointer {
});
return {
location: exportLocation,
docker: true,
success: true,
checkpoint: {
location: exportLocation,
docker: true,
},
};
}
@@ -266,54 +339,104 @@ class Checkpointer {
throw new Error("could not find container id");
}
const start = performance.now();
// Create checkpoint
this.#logger.debug(await $$`crictl checkpoint --export=${exportLocation} ${containerId}`);
const postCheckpoint = performance.now();
// Print checkpoint size
const size = await getParsedFileSize(exportLocation);
this.#logger.log("checkpoint archive created", { size, options });
// Create image from checkpoint
const container = this.#logger.debug(await $$`buildah from scratch`);
const postFrom = performance.now();
this.#logger.debug(await $$`buildah add ${container} ${exportLocation} /`);
const postAdd = performance.now();
this.#logger.debug(
await $$`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
);
const postConfig = performance.now();
this.#logger.debug(await $$`buildah commit ${container} ${imageRef}`);
const postCommit = performance.now();
this.#logger.debug(await $$`buildah rm ${container}`);
const postRm = performance.now();
// Push checkpoint image
this.#logger.debug(await $$`buildah push --tls-verify=${REGISTRY_TLS_VERIFY} ${imageRef}`);
const postPush = performance.now();
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef });
const perf = {
"crictl checkpoint": postCheckpoint - start,
"buildah from": postFrom - postCheckpoint,
"buildah add": postAdd - postFrom,
"buildah config": postConfig - postAdd,
"buildah commit": postCommit - postConfig,
"buildah rm": postRm - postCommit,
"buildah push": postPush - postRm,
};
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef, perf });
try {
await $$`rm ${exportLocation}`;
this.#logger.log("Deleted checkpoint archive", { exportLocation });
// Disabled for now as this will increase restore time by having to pull the image again
// await $`buildah rmi ${imageRef}`;
// this.#logger.log("Deleted checkpoint image", { imageRef });
await $`buildah rmi ${imageRef}`;
this.#logger.log("Deleted checkpoint image", { imageRef });
} catch (error) {
this.#logger.error("Failed during checkpoint cleanup", { exportLocation });
this.#logger.debug(error);
throw error;
}
return {
location: imageRef,
docker: false,
success: true,
checkpoint: {
location: imageRef,
docker: false,
},
};
} catch (error) {
this.#logger.error("checkpoint failed", {
options: {
runId,
leaveRunning,
projectRef,
deploymentVersion,
},
error,
});
return;
if (isExecaChildProcess(error)) {
if (error.isCanceled) {
this.#logger.error("Checkpoint canceled", { options, error });
return { success: false, reason: "CANCELED" };
}
// Everything that's not a cancellation is a failure
this.#failCheckpoint(runId, error);
this.#logger.error("Checkpoint command error", { options, error });
return { success: false };
}
this.#failCheckpoint(runId, error);
this.#logger.error("Unhandled checkpoint error", { options, error });
return { success: false };
} finally {
this.#abortControllers.delete(runId);
}
}
#failCheckpoint(runId: string, error: unknown) {
this.#failedCheckpoints.set(runId, error);
}
#clearFailedCheckpoint(runId: string) {
this.#failedCheckpoints.delete(runId);
}
#hasFailedCheckpoint(runId: string) {
return this.#failedCheckpoints.has(runId);
}
#getRunContainerName(suffix: string) {
return `task-run-${suffix}`;
}
@@ -321,7 +444,7 @@ class Checkpointer {
class TaskCoordinator {
#httpServer: ReturnType<typeof createServer>;
#checkpointer = new Checkpointer({ forceSimulate: true });
#checkpointer = new Checkpointer({ forceSimulate: FORCE_CHECKPOINT_SIMULATION });
#prodWorkerNamespace: ZodNamespace<
typeof ProdWorkerToCoordinatorMessages,
@@ -442,6 +565,28 @@ class TaskCoordinator {
taskSocket.emit("REQUEST_ATTEMPT_CANCELLATION", message);
},
REQUEST_RUN_CANCELLATION: async (message) => {
const taskSocket = await this.#getRunSocket(message.runId);
if (!taskSocket) {
logger.log("Socket for run not found", {
runId: message.runId,
});
return;
}
if (message.delayInMs) {
taskSocket.emit("REQUEST_EXIT", {
version: "v2",
delayInMs: message.delayInMs,
});
} else {
// If there's no delay, assume the worker doesn't support non-v1 messages
taskSocket.emit("REQUEST_EXIT", {
version: "v1",
});
}
},
READY_FOR_RETRY: async (message) => {
const taskSocket = await this.#getRunSocket(message.runId);
@@ -528,6 +673,20 @@ class TaskCoordinator {
onConnection: async (socket, handler, sender) => {
const logger = new SimpleLogger(`[prod-worker][${socket.id}]`);
const crashRun = async (error: { name: string; message: string; stack?: string }) => {
try {
this.#platformSocket?.send("RUN_CRASHED", {
version: "v1",
runId: socket.data.runId,
error,
});
} finally {
socket.emit("REQUEST_EXIT", {
version: "v1",
});
}
};
const checkpointInProgress = () => {
return this.#checkpointableTasks.has(socket.data.runId);
};
@@ -596,8 +755,9 @@ class TaskCoordinator {
if (!executionAck) {
logger.error("no execution ack", { runId: socket.data.runId });
socket.emit("REQUEST_EXIT", {
version: "v1",
await crashRun({
name: "ReadyForExecutionError",
message: "No execution ack",
});
return;
@@ -606,8 +766,9 @@ class TaskCoordinator {
if (!executionAck.success) {
logger.error("failed to get execution payload", { runId: socket.data.runId });
socket.emit("REQUEST_EXIT", {
version: "v1",
await crashRun({
name: "ReadyForExecutionError",
message: "Failed to get execution payload",
});
return;
@@ -624,6 +785,46 @@ class TaskCoordinator {
}
});
socket.on("READY_FOR_LAZY_ATTEMPT", async (message) => {
logger.log("[READY_FOR_LAZY_ATTEMPT]", message);
try {
const lazyAttempt = await this.#platformSocket?.sendWithAck("READY_FOR_LAZY_ATTEMPT", {
...message,
envId: socket.data.envId,
});
if (!lazyAttempt) {
logger.error("no lazy attempt ack", { runId: socket.data.runId });
await crashRun({
name: "ReadyForLazyAttemptError",
message: "No lazy attempt ack",
});
return;
}
if (!lazyAttempt.success) {
logger.error("failed to get lazy attempt payload", { runId: socket.data.runId });
await crashRun({
name: "ReadyForLazyAttemptError",
message: "Failed to get lazy attempt payload",
});
return;
}
socket.emit("EXECUTE_TASK_RUN_LAZY_ATTEMPT", {
version: "v1",
lazyPayload: lazyAttempt.lazyPayload,
});
} catch (error) {
logger.error("Error", { error });
}
});
socket.on("READY_FOR_RESUME", async (message) => {
logger.log("[READY_FOR_RESUME]", message);
@@ -714,6 +915,19 @@ class TaskCoordinator {
}
});
socket.on("TASK_RUN_FAILED_TO_RUN", async ({ completion }) => {
logger.log("completed task", { completionId: completion.id });
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
version: "v1",
completion,
});
socket.emit("REQUEST_EXIT", {
version: "v1",
});
});
socket.on("READY_FOR_CHECKPOINT", async (message) => {
logger.log("[READY_FOR_CHECKPOINT]", message);
@@ -890,7 +1104,7 @@ class TaskCoordinator {
logger.log("[INDEX_TASKS]", message);
const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", {
version: "v1",
version: "v2",
projectRef: socket.data.projectRef,
envId: socket.data.envId,
deploymentId: message.deploymentId,
@@ -899,6 +1113,7 @@ class TaskCoordinator {
packageVersion: message.packageVersion,
tasks: message.tasks,
},
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
});
if (!workerAck) {
@@ -917,6 +1132,34 @@ class TaskCoordinator {
error: message.error,
});
});
socket.on("CREATE_TASK_RUN_ATTEMPT", async (message, callback) => {
logger.log("[CREATE_TASK_RUN_ATTEMPT]", message);
const createAttempt = await this.#platformSocket?.sendWithAck("CREATE_TASK_RUN_ATTEMPT", {
runId: message.runId,
envId: socket.data.envId,
});
if (!createAttempt?.success) {
logger.debug("no ack while creating attempt", message);
callback({ success: false });
return;
}
socket.data.attemptFriendlyId = createAttempt.executionPayload.execution.attempt.id;
callback({
success: true,
executionPayload: createAttempt.executionPayload,
});
});
socket.on("UNRECOVERABLE_ERROR", async (message) => {
logger.log("[UNRECOVERABLE_ERROR]", message);
await crashRun(message.error);
});
},
onDisconnect: async (socket, handler, sender, logger) => {
this.#platformSocket?.send("LOG", {
@@ -928,13 +1171,16 @@ class TaskCoordinator {
TASK_HEARTBEAT: async (message) => {
this.#platformSocket?.send("TASK_HEARTBEAT", message);
},
TASK_RUN_HEARTBEAT: async (message) => {
this.#platformSocket?.send("TASK_RUN_HEARTBEAT", message);
},
},
});
return provider;
}
#cancelCheckpoint(runId: string) {
#cancelCheckpoint(runId: string): boolean {
const checkpointWait = this.#checkpointableTasks.get(runId);
if (checkpointWait) {
+2
View File
@@ -4,6 +4,8 @@ PLATFORM_WS_PORT=3030
PLATFORM_SECRET=provider-secret
SECURE_CONNECTION=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://0.0.0.0:3030/otel
# Use this if you are on macOS
# COORDINATOR_HOST="host.docker.internal"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://host.docker.internal:4318"
+6 -1
View File
@@ -13,9 +13,14 @@ import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
const OTEL_EXPORTER_OTLP_ENDPOINT =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318";
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
);
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
type InitializeReturn = {
@@ -278,7 +283,7 @@ class DockerTaskOperations implements TaskOperations {
}
const provider = new ProviderShell({
tasks: new DockerTaskOperations({ forceSimulate: true }),
tasks: new DockerTaskOperations({ forceSimulate: FORCE_CHECKPOINT_SIMULATION }),
type: "docker",
});
+1 -1
View File
@@ -212,7 +212,7 @@ class KubernetesTaskOperations implements TaskOperations {
},
{
name: "populate-taskinfo",
image: "docker.io/library/busybox",
image: "registry.digitalocean.com/trigger/busybox",
imagePullPolicy: "IfNotPresent",
command: ["/bin/sh", "-c"],
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
+2 -2
View File
@@ -7,9 +7,9 @@
"dev": "wrangler dev"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230419.0",
"@cloudflare/workers-types": "^4.20240512.0",
"typescript": "^5.0.4",
"wrangler": "^3.0.0"
"wrangler": "^3.57.1"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.445.0",
+6 -2
View File
@@ -1,5 +1,7 @@
import { queueEvent } from "./events/queueEvent";
import { queueEvents } from "./events/queueEvents";
import { applyRateLimit } from "./rateLimit";
import { Ratelimit } from "./rateLimiter";
export interface Env {
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
@@ -9,6 +11,8 @@ export interface Env {
AWS_SQS_SECRET_ACCESS_KEY: string;
AWS_SQS_QUEUE_URL: string;
AWS_SQS_REGION: string;
//rate limiter
API_RATE_LIMITER: Ratelimit;
}
export default {
@@ -25,13 +29,13 @@ export default {
switch (url.pathname) {
case "/api/v1/events": {
if (request.method === "POST") {
return queueEvent(request, env);
return applyRateLimit(request, env, () => queueEvent(request, env));
}
break;
}
case "/api/v1/events/bulk": {
if (request.method === "POST") {
return queueEvents(request, env);
return applyRateLimit(request, env, () => queueEvents(request, env));
}
break;
}
+46
View File
@@ -0,0 +1,46 @@
import { Env } from "src";
import { getApiKeyFromRequest } from "./apikey";
import { json } from "./json";
export async function applyRateLimit(
request: Request,
env: Env,
fn: () => Promise<Response>
): Promise<Response> {
const apiKey = getApiKeyFromRequest(request);
if (apiKey) {
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
const { success } = result;
console.log(`Rate limiter`, {
success,
key: `${apiKey.apiKey.substring(0, 12)}...`,
});
if (!success) {
//60s in the future
const reset = Date.now() + 60 * 1000;
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return json(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
reset,
},
{
status: 429,
headers: {
"x-ratelimit-reset": reset.toString(),
},
}
);
}
} else {
console.log(`Rate limiter: no API key for request`);
}
//call the original function
return fn();
}
+23
View File
@@ -0,0 +1,23 @@
export interface Ratelimit {
/*
* The ratelimit function
* @param {RatelimitOptions} options
* @returns {Promise<RatelimitResponse>}
*/
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
}
export interface RatelimitOptions {
/*
* The key to identify the user, can be an IP address, user ID, etc.
*/
key: string;
}
export interface RatelimitResponse {
/*
* The ratelimit success status
* @returns {boolean}
*/
success: boolean;
}
+28 -2
View File
@@ -1,7 +1,33 @@
name = "proxy"
main = "src/index.ts"
compatibility_date = "2023-10-30"
compatibility_date = "2024-05-13"
compatibility_flags = [ "nodejs_compat" ]
[env.staging]
[env.prod]
# The rate limiting API is in open beta.
[[env.staging.unsafe.bindings]]
name = "API_RATE_LIMITER"
type = "ratelimit"
# An identifier you define, that is unique to your Cloudflare account.
# Must be an integer.
namespace_id = "1"
# Limit: the number of tokens allowed within a given period in a single
# Cloudflare location
# Period: the duration of the period, in seconds. Must be either 10 or 60
simple = { limit = 100, period = 60 }
[env.prod]
# The rate limiting API is in open beta.
[[env.prod.unsafe.bindings]]
name = "API_RATE_LIMITER"
type = "ratelimit"
# An identifier you define, that is unique to your Cloudflare account.
# Must be an integer.
namespace_id = "2"
# Limit: the number of tokens allowed within a given period in a single
# Cloudflare location
# Period: the duration of the period, in seconds. Must be either 10 or 60
simple = { limit = 300, period = 60 }
+4
View File
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.861435" y="0.861435" width="30.2771" height="30.2771" rx="15.1386" stroke="#D7D9DD" stroke-width="1.72287"/>
<path d="M14.9669 12.642L11.6417 21.7732H9.8155L6.49036 12.642H8.04094L10.7286 20.2571L13.4163 12.642H14.9669ZM20.534 14.5544C21.6884 14.5716 23.0666 14.0375 23.0666 12.4525C23.0666 11.3843 22.1018 10.7124 20.534 10.7124C19.2591 10.7124 18.3632 11.3326 18.2082 12.3146L16.6576 12.2113C16.8815 10.4884 18.4838 9.26516 20.534 9.26516C22.946 9.26516 24.6345 10.5745 24.6345 12.4525C24.6345 13.8135 23.8592 14.7267 22.3775 15.1574C24.0831 15.657 25.0479 16.8286 25.0479 18.4136C25.0479 20.55 23.1872 22.0489 20.534 22.0489C18.0876 22.0489 16.313 20.6361 16.2096 18.6203L17.743 18.517C17.8808 20.0159 19.3108 20.6016 20.534 20.6016C21.9812 20.6016 23.4974 19.947 23.4974 18.293C23.4974 16.6563 21.9812 15.9499 20.534 15.9844L19.5865 16.0016V14.5371L20.534 14.5544Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 999 B

+1 -8
View File
@@ -115,14 +115,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
Docs
</LinkButton>
<LinkButton
to={docsPath("v3/introduction")}
variant="tertiary/medium"
LeadingIcon={BookOpenIcon}
>
v3 Docs (Developer preview)
</LinkButton>
<LinkButton
to={"https://trigger.openstatus.dev/"}
to={"https://status.trigger.dev/"}
variant="tertiary/medium"
LeadingIcon={ActivityIcon}
>
@@ -20,9 +20,11 @@ export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<ShieldCheckIcon className="h-5 w-5" />
<ShieldCheckIcon className="size-5" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1">{children}</TooltipContent>
<TooltipContent className="flex max-h-[90vh] items-center gap-1 overflow-y-auto">
{children}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
+14 -5
View File
@@ -1,14 +1,23 @@
import { Clipboard, ClipboardCheck } from "lucide-react";
import type { Language, PrismTheme } from "prism-react-renderer";
import Highlight, { defaultProps } from "prism-react-renderer";
import { Highlight, Prism } from "prism-react-renderer";
import { forwardRef, useCallback, useState } from "react";
import { cn } from "~/utils/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { Paragraph } from "../primitives/Paragraph";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
async function setup() {
(typeof global !== "undefined" ? global : window).Prism = Prism;
//@ts-ignore
await import("prismjs/components/prism-json");
//@ts-ignore
await import("prismjs/components/prism-typescript");
}
setup();
type CodeBlockProps = {
/** Code which will be highlighted */
code: string;
@@ -238,7 +247,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
)}
{shouldHighlight ? (
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
<Highlight theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
@@ -283,7 +292,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
return (
<div
key={lineProps.key}
key={lineNumber}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
@@ -312,7 +321,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
const tokenProps = getTokenProps({ token, key });
return (
<span
key={tokenProps.key}
key={key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
@@ -9,6 +9,30 @@ const variants = {
large: "h-6 text-xs px-1.5 rounded",
};
export function EnvironmentTypeLabel({
environment,
size = "small",
className,
}: {
environment: Environment;
size?: keyof typeof variants;
className?: string;
}) {
return (
<span
className={cn(
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName(environment),
environmentTextClassName(environment),
variants[size],
className
)}
>
{environmentTypeTitle(environment)}
</span>
);
}
export function EnvironmentLabel({
environment,
size = "small",
@@ -116,6 +140,19 @@ export function environmentTitle(environment: Environment, username?: string) {
}
}
export function environmentTypeTitle(environment: Environment) {
switch (environment.type) {
case "PRODUCTION":
return "Prod";
case "STAGING":
return "Staging";
case "DEVELOPMENT":
return "Dev";
case "PREVIEW":
return "Preview";
}
}
export function environmentColorClassName(environment: Environment) {
switch (environment.type) {
case "PRODUCTION":
@@ -12,7 +12,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { Checkbox } from "../primitives/Checkbox";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
@@ -123,7 +123,7 @@ export function ConnectToOAuthForm({
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert the details.
</Paragraph>
<Checkbox
<CheckboxWithLabel
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
@@ -200,7 +200,7 @@ export function ConnectToOAuthForm({
)}
{authMethod.scopes.map((s) => {
return (
<Checkbox
<CheckboxWithLabel
key={s.name}
id={s.name}
value={s.name}
@@ -10,7 +10,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { Checkbox } from "../primitives/Checkbox";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
@@ -113,7 +113,7 @@ export function UpdateOAuthForm({
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert the details.
</Paragraph>
<Checkbox
<CheckboxWithLabel
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
@@ -189,7 +189,7 @@ export function UpdateOAuthForm({
)}
{authMethod.scopes.map((s) => {
return (
<Checkbox
<CheckboxWithLabel
key={s.name}
id={s.name}
value={s.name}
@@ -233,31 +233,22 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</div>
<hr className="border-charcoal-800" />
<div>
<StepNumber stepNumber="1" title="Create a new Slack channel" />
<StepNumber stepNumber="1" title="Email us" />
<StepContentContainer>
<Paragraph>
In your Slack app, create a new channel from the main menu by going to File{" "}
<ArrowRightIcon className="inline h-4 w-4 text-text-dimmed" /> New Channel
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Setup your channel" />
<StepContentContainer>
<Paragraph>
Name your channel, set its visibility and click 'Create'.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="3" title="Invite Trigger.dev" />
<StepContentContainer>
<Paragraph>
Invite this email address to your channel:{" "}
Send us an email to this address from your Trigger.dev account email
address:
<ClipboardField
variant="primary/medium"
value="james@trigger.dev"
value="priority-support@trigger.dev"
className="my-2"
/>
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
<StepContentContainer>
<Paragraph>
As soon as we can, we'll accept your invitation and say hello!
As soon as we can, we'll setup a Slack Connect channel and say hello!
</Paragraph>
</StepContentContainer>
</div>
@@ -551,6 +542,8 @@ function V3ProjectSideMenu({
project: SideMenuProject;
organization: MatchedOrganization;
}) {
const { alertsEnabled } = useFeatures();
return (
<>
<SideMenuHeader title={"Project (v3)"} />
@@ -603,13 +596,15 @@ function V3ProjectSideMenu({
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
iconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
{alertsEnabled && (
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
iconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
)}
<SideMenuItem
name="Project settings"
icon="settings"
@@ -1,5 +1,5 @@
import * as React from "react";
import { useEffect, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import { cn } from "~/utils/cn";
import { Badge } from "./Badge";
import { Paragraph } from "./Paragraph";
@@ -53,18 +53,18 @@ export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"checked" | "onChange"
> & {
id: string;
id?: string;
name?: string;
value?: string;
variant?: keyof typeof variants;
label?: React.ReactNode;
label: React.ReactNode;
description?: string;
badges?: string[];
className?: string;
onChange?: (isChecked: boolean) => void;
};
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProps>(
(
{
id,
@@ -172,3 +172,21 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
);
}
);
type SimpleCheckboxProps = Omit<React.ComponentProps<"input">, "type">;
export const Checkbox = forwardRef<HTMLInputElement, SimpleCheckboxProps>(
({ className, ...props }: SimpleCheckboxProps, ref) => {
return (
<input
type="checkbox"
className={cn(
props.readOnly || props.disabled ? "cursor-default" : "cursor-pointer",
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-600 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
)}
{...props}
ref={ref}
/>
);
}
);
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-3.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-2.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
@@ -74,7 +74,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col text-left font-medium text-text-dimmed", className)}
className={cn("flex flex-col text-left font-medium text-text-bright", className)}
{...props}
/>
);
@@ -0,0 +1,146 @@
"use client";
import { createContext, useCallback, useContext, useReducer } from "react";
type SelectedItemsContext = {
selectedItems: Set<string>;
select: (items: string | string[]) => void;
deselect: (items: string | string[]) => void;
toggle: (items: string | string[]) => void;
deselectAll: () => void;
has: (item: string) => boolean;
hasAll: (items: string[]) => boolean;
};
const SelectedItemsContext = createContext<SelectedItemsContext>({} as SelectedItemsContext);
export function useSelectedItems(enabled = true) {
const context = useContext(SelectedItemsContext);
if (!context && enabled) {
throw new Error("useSelectedItems must be used within a SelectedItemsProvider");
}
return context;
}
export function SelectedItemsProvider({
initialSelectedItems,
maxSelectedItemCount,
children,
}: {
initialSelectedItems: string[];
maxSelectedItemCount?: number;
children: React.ReactNode | ((context: SelectedItemsContext) => React.ReactNode);
}) {
const [state, dispatch] = useReducer(selectedItemsReducer, {
items: new Set<string>(initialSelectedItems),
maxSelectedItemCount,
});
const select = useCallback((items: string | string[]) => {
dispatch({ type: "select", items: Array.isArray(items) ? items : [items] });
}, []);
const deselect = useCallback((items: string | string[]) => {
dispatch({ type: "deselect", items: Array.isArray(items) ? items : [items] });
}, []);
const toggle = useCallback((items: string | string[]) => {
dispatch({ type: "toggle", items: Array.isArray(items) ? items : [items] });
}, []);
const deselectAll = useCallback(() => {
dispatch({ type: "deselectAll" });
}, []);
const has = useCallback((item: string) => state.items.has(item), [state]);
const hasAll = useCallback(
(items: string[]) => items.every((item) => state.items.has(item)),
[state]
);
return (
<SelectedItemsContext.Provider
value={{ selectedItems: state.items, select, deselect, toggle, deselectAll, has, hasAll }}
>
{typeof children === "function"
? children({
selectedItems: state.items,
select,
deselect,
toggle,
deselectAll,
has,
hasAll,
})
: children}
</SelectedItemsContext.Provider>
);
}
type SelectItemsAction = {
type: "select";
items: string[];
};
type DeSelectItemsAction = {
type: "deselect";
items: string[];
};
type DeselectAllItemsAction = {
type: "deselectAll";
};
type ToggleItemsAction = {
type: "toggle";
items: string[];
};
type Action = SelectItemsAction | DeSelectItemsAction | ToggleItemsAction | DeselectAllItemsAction;
function selectedItemsReducer(
state: { items: Set<string>; maxSelectedItemCount?: number },
action: Action
) {
switch (action.type) {
case "select":
const items = new Set([...state.items, ...action.items]);
return { ...state, items: cappedSet(items, state.maxSelectedItemCount) };
case "deselect":
const newItems = new Set(state.items);
action.items.forEach((item) => {
newItems.delete(item);
});
return { ...state, items: cappedSet(newItems, state.maxSelectedItemCount) };
case "toggle":
let newSet = new Set(state.items);
action.items.forEach((item) => {
if (newSet.has(item)) {
newSet.delete(item);
} else {
newSet.add(item);
}
});
return { ...state, items: cappedSet(newSet, state.maxSelectedItemCount) };
case "deselectAll":
return { ...state, items: new Set<string>() };
default:
return state;
}
}
function cappedSet(set: Set<string>, max?: number) {
if (!max) {
return set;
}
if (set.size <= max) {
return set;
}
console.warn(`Selected items exceeded the maximum count of ${max}.`);
return new Set([...set].slice(0, max));
}
@@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
ref={ref}
scope="col"
className={cn(
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
"px-3 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
alignmentClassName,
className
)}
@@ -155,7 +155,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
const flexClasses = cn(
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-text-dimmed",
"flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed",
alignment === "left"
? "justify-start text-left"
: alignment === "center"
@@ -170,7 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
"text-xs text-charcoal-400",
to || onClick || hasAction
? "cursor-pointer group-hover/table-row:bg-charcoal-900"
: "px-4 py-3 align-middle",
: "px-3 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
className
@@ -0,0 +1,73 @@
import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
import { BulkActionType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { cn } from "~/utils/cn";
export function BulkActionStatusCombo({
type,
className,
iconClassName,
}: {
type: BulkActionType;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BulkActionIcon type={type} className={cn("h-4 w-4", iconClassName)} />
<BulkActionLabel type={type} />
</span>
);
}
export function BulkActionLabel({ type }: { type: BulkActionType }) {
return <span className={bulkActionClassName(type)}>{bulkActionTitle(type)}</span>;
}
export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) {
switch (type) {
case "REPLAY":
return <ArrowPathIcon className={cn(bulkActionClassName(type), className)} />;
case "CANCEL":
return <NoSymbolIcon className={cn(bulkActionClassName(type), className)} />;
default: {
assertNever(type);
}
}
}
export function bulkActionClassName(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "text-indigo-500";
case "CANCEL":
return "text-rose-500";
default: {
assertNever(type);
}
}
}
export function bulkActionTitle(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "Replay";
case "CANCEL":
return "Cancel";
default: {
assertNever(type);
}
}
}
export function bulkActionVerb(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "Replaying";
case "CANCEL":
return "Canceling";
default: {
assertNever(type);
}
}
}
@@ -1,18 +1,31 @@
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
export function EnabledStatus({ enabled }: { enabled: boolean }) {
type EnabledStatusProps = {
enabled: boolean;
enabledIcon?: React.ComponentType<any>;
disabledIcon?: React.ComponentType<any>;
};
export function EnabledStatus({
enabled,
enabledIcon = CheckCircleIcon,
disabledIcon = BoltSlashIcon,
}: EnabledStatusProps) {
const EnabledIcon = enabledIcon;
const DisabledIcon = disabledIcon;
switch (enabled) {
case true:
return (
<div className="flex items-center gap-1 text-xs text-success">
<CheckCircleIcon className="h-4 w-4" />
<EnabledIcon className="size-4" />
Enabled
</div>
);
case false:
return (
<div className="text-dimmed flex items-center gap-1 text-xs">
<BoltSlashIcon className="h-4 w-4" />
<DisabledIcon className="size-4" />
Disabled
</div>
);
@@ -1,9 +1,21 @@
import * as Ariakit from "@ariakit/react";
import { CalendarIcon, CpuChipIcon, XMarkIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
CalendarIcon,
CpuChipIcon,
InboxStackIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { RuntimeEnvironment, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import type {
RuntimeEnvironment,
TaskTriggerSource,
TaskRunStatus,
BulkActionType,
} from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import { ReactNode, startTransition, useCallback, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { startTransition, useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
@@ -32,12 +44,15 @@ import { Button } from "../../primitives/Buttons";
import {
TaskRunStatusCombo,
allTaskRunStatuses,
filterableTaskRunStatuses,
descriptionForTaskRunStatus,
runStatusTitle,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { DateTime } from "~/components/primitives/DateTime";
import { BulkActionStatusCombo } from "./BulkAction";
export const TaskAttemptStatus = z.nativeEnum(TaskRunStatus);
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
export const TaskRunListSearchFilters = z.object({
cursor: z.string().optional(),
@@ -59,6 +74,7 @@ export const TaskRunListSearchFilters = z.object({
TaskAttemptStatus.array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
bulkId: z.string().optional(),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
@@ -72,6 +88,11 @@ type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
type RunFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
bulkActions: {
id: string;
type: BulkActionType;
createdAt: Date;
}[];
hasFilters: boolean;
};
@@ -82,7 +103,8 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("tasks") ||
searchParams.has("period");
searchParams.has("period") ||
searchParams.has("bulkId");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -112,7 +134,8 @@ const filterTypes = [
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
];
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -123,6 +146,7 @@ function FilterMenu(props: RunFiltersProps) {
const filterTrigger = (
<SelectTrigger
autoFocus
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
@@ -180,13 +204,14 @@ function FilterMenuProvider({
);
}
function AppliedFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedPeriodFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
</>
);
}
@@ -211,6 +236,8 @@ function Menu(props: MenuProps) {
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "bulk":
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
@@ -246,7 +273,7 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men
);
}
const statuses = allTaskRunStatuses.map((status) => ({
const statuses = filterableTaskRunStatuses.map((status) => ({
title: runStatusTitle(status),
value: status,
}));
@@ -529,6 +556,99 @@ function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTas
);
}
function BulkActionsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
bulkActions,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
bulkActions: RunFiltersProps["bulkActions"];
}) {
const { value, replace } = useSearchParams();
const handleChange = (value: string) => {
clearSearchValue();
replace({ bulkId: value, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return bulkActions.filter((item) => {
return (
item.type.toLowerCase().includes(searchValue.toLowerCase()) ||
item.createdAt.toISOString().includes(searchValue)
);
});
}, [searchValue, bulkActions]);
return (
<SelectProvider value={value("bulkId")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by bulk action..."} value={searchValue} />
<SelectList>
<SelectItem value={""}>None</SelectItem>
{filtered.map((item, index) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex gap-3">
<BulkActionStatusCombo type={item.type} iconClassName="size-4" />
<DateTime date={item.createdAt} />
</div>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkActions">) {
const { value, del } = useSearchParams();
const bulkId = value("bulkId");
if (!bulkId) {
return null;
}
const action = bulkActions.find((action) => action.id === bulkId);
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BulkActionsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Bulk action"
value={bulkId}
onRemove={() => del(["bulkId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
bulkActions={bulkActions}
/>
)}
</FilterMenuProvider>
);
}
const timePeriods = [
{
label: "All periods",
@@ -7,7 +7,7 @@ import {
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
import { TaskRunAttemptStatus } from "@trigger.dev/database";
import { TaskRunAttemptStatus } from "~/database-types";
import assertNever from "assert-never";
import { SnowflakeIcon } from "lucide-react";
import { Spinner } from "~/components/primitives/Spinner";
@@ -17,7 +17,7 @@ export const allTaskRunAttemptStatuses = Object.values(
TaskRunAttemptStatus
) as TaskRunAttemptStatusType[];
export type ExtendedTaskAttemptStatus = (typeof allTaskRunAttemptStatuses)[number] | "ENQUEUED";
export type ExtendedTaskAttemptStatus = TaskRunAttemptStatusType | "ENQUEUED";
export function TaskRunAttemptStatusCombo({
status,
@@ -25,9 +25,24 @@ export const allTaskRunStatuses = [
"CANCELED",
"COMPLETED_WITH_ERRORS",
"CRASHED",
"PAUSED",
"INTERRUPTED",
"SYSTEM_FAILURE",
] as TaskRunStatus[];
] as const satisfies Readonly<Array<TaskRunStatus>>;
export const filterableTaskRunStatuses = [
"WAITING_FOR_DEPLOY",
"PENDING",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"CRASHED",
"INTERRUPTED",
"SYSTEM_FAILURE",
] as const satisfies Readonly<Array<TaskRunStatus>>;
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
PENDING: "Task is waiting to be executed",
@@ -28,6 +28,11 @@ import { CancelRunDialog } from "./CancelRunDialog";
import { ReplayRunDialog } from "./ReplayRunDialog";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { LiveTimer } from "./LiveTimer";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { Checkbox } from "~/components/primitives/Checkbox";
import { useCallback, useRef } from "react";
import { run } from "@remix-run/dev/dist/cli/run";
import { formatNumber } from "~/utils/numberFormatter";
type RunsTableProps = {
total: number;
@@ -36,6 +41,7 @@ type RunsTableProps = {
showJob?: boolean;
runs: RunListItem[];
isLoading?: boolean;
allowSelection?: boolean;
};
export function TaskRunsTable({
@@ -44,15 +50,66 @@ export function TaskRunsTable({
filters,
runs,
isLoading = false,
allowSelection = false,
}: RunsTableProps) {
const organization = useOrganization();
const project = useProject();
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const navigateCheckboxes = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
//indexes are out by one because of the header row
if (event.key === "ArrowUp" && index > 0) {
checkboxes.current[index - 1]?.focus();
if (event.shiftKey) {
const oldItem = runs.at(index - 1);
const newItem = runs.at(index - 2);
const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean);
select(itemsIds);
}
} else if (event.key === "ArrowDown" && index < checkboxes.current.length - 1) {
checkboxes.current[index + 1]?.focus();
if (event.shiftKey) {
const oldItem = runs.at(index - 1);
const newItem = runs.at(index);
const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean);
select(itemsIds);
}
}
},
[checkboxes, runs]
);
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Run</TableHeaderCell>
{allowSelection && (
<TableHeaderCell className="pl-2 pr-0">
{runs.length > 0 && (
<Checkbox
checked={hasAll(runs.map((r) => r.id))}
onChange={(element) => {
const ids = runs.map((r) => r.id);
const checked = element.currentTarget.checked;
if (checked) {
select(ids);
} else {
deselect(ids);
}
}}
ref={(r) => {
checkboxes.current[0] = r;
}}
onKeyDown={(event) => navigateCheckboxes(event, 0)}
/>
)}
</TableHeaderCell>
)}
<TableHeaderCell alignment="right">Run #</TableHeaderCell>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
@@ -74,11 +131,27 @@ export function TaskRunsTable({
) : runs.length === 0 ? (
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run) => {
runs.map((run, index) => {
const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId });
return (
<TableRow key={run.id}>
<TableCell to={path}>#{run.number}</TableCell>
{allowSelection && (
<TableCell className="pl-2 pr-0">
<Checkbox
checked={has(run.id)}
onChange={(element) => {
toggle(run.id);
}}
ref={(r) => {
checkboxes.current[index + 1] = r;
}}
onKeyDown={(event) => navigateCheckboxes(event, index + 1)}
/>
</TableCell>
)}
<TableCell to={path} alignment="right">
{formatNumber(run.number)}
</TableCell>
<TableCell to={path}>{run.taskIdentifier}</TableCell>
<TableCell to={path}>{run.version ?? ""}</TableCell>
<TableCell to={path}>
@@ -194,7 +267,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
) {
const environment = envs?.find((env) => env.id === filters.environments[0]);
return (
<TableBlankRow colSpan={9}>
<TableBlankRow colSpan={10}>
<div className="py-14">
<Paragraph className="w-auto" variant="base/bright" spacing>
There are no runs for {filters.tasks[0]}
@@ -235,8 +308,21 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
}
return (
<TableBlankRow colSpan={9}>
<NoRuns title="No runs match your filters" />
<TableBlankRow colSpan={10}>
<div className="flex flex-col items-center justify-center gap-2">
<Paragraph className="w-auto" variant="small">
No runs currently match your filters. Try refreshing or modifying your filters.
</Paragraph>
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/small"
onClick={() => {
window.location.reload();
}}
>
Refresh
</Button>
</div>
</TableBlankRow>
);
}
+2
View File
@@ -12,3 +12,5 @@ export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
export const MAX_BATCH_TRIGGER_ITEMS = 100;
export const MAX_TASK_RUN_ATTEMPTS = 250;
export const BULK_ACTION_RUN_LIMIT = 250;
export const MAX_JOB_RUN_EXECUTION_COUNT = 250;
+68
View File
@@ -0,0 +1,68 @@
// There's a weird issue with importing values from the prisma client
// when using Remix Vite + pnpm + prisma
// As long as they're only used as types it's ok
// Import types here and validate hardcoded enums
import type {
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
TaskRunAttemptStatus as TaskRunAttemptStatusType,
TaskRunStatus as TaskRunStatusType,
JobRunStatus as JobRunStatusType,
RuntimeEnvironmentType as RuntimeEnvironmentTypeType,
} from "@trigger.dev/database";
export const BatchTaskRunItemStatus = {
PENDING: "PENDING",
FAILED: "FAILED",
CANCELED: "CANCELED",
COMPLETED: "COMPLETED",
} as const satisfies Record<BatchTaskRunItemStatusType, BatchTaskRunItemStatusType>;
export const TaskRunAttemptStatus = {
PENDING: "PENDING",
EXECUTING: "EXECUTING",
PAUSED: "PAUSED",
FAILED: "FAILED",
CANCELED: "CANCELED",
COMPLETED: "COMPLETED",
} as const satisfies Record<TaskRunAttemptStatusType, TaskRunAttemptStatusType>;
export const TaskRunStatus = {
PENDING: "PENDING",
WAITING_FOR_DEPLOY: "WAITING_FOR_DEPLOY",
EXECUTING: "EXECUTING",
WAITING_TO_RESUME: "WAITING_TO_RESUME",
RETRYING_AFTER_FAILURE: "RETRYING_AFTER_FAILURE",
PAUSED: "PAUSED",
CANCELED: "CANCELED",
INTERRUPTED: "INTERRUPTED",
COMPLETED_SUCCESSFULLY: "COMPLETED_SUCCESSFULLY",
COMPLETED_WITH_ERRORS: "COMPLETED_WITH_ERRORS",
SYSTEM_FAILURE: "SYSTEM_FAILURE",
CRASHED: "CRASHED",
} as const satisfies Record<TaskRunStatusType, TaskRunStatusType>;
export const JobRunStatus = {
PENDING: "PENDING",
QUEUED: "QUEUED",
WAITING_ON_CONNECTIONS: "WAITING_ON_CONNECTIONS",
PREPROCESSING: "PREPROCESSING",
STARTED: "STARTED",
EXECUTING: "EXECUTING",
WAITING_TO_CONTINUE: "WAITING_TO_CONTINUE",
WAITING_TO_EXECUTE: "WAITING_TO_EXECUTE",
SUCCESS: "SUCCESS",
FAILURE: "FAILURE",
TIMED_OUT: "TIMED_OUT",
ABORTED: "ABORTED",
CANCELED: "CANCELED",
UNRESOLVED_AUTH: "UNRESOLVED_AUTH",
INVALID_PAYLOAD: "INVALID_PAYLOAD",
} as const satisfies Record<JobRunStatusType, JobRunStatusType>;
export const RuntimeEnvironmentType = {
PRODUCTION: "PRODUCTION",
STAGING: "STAGING",
DEVELOPMENT: "DEVELOPMENT",
PREVIEW: "PREVIEW",
} as const satisfies Record<RuntimeEnvironmentTypeType, RuntimeEnvironmentTypeType>;
+3 -1
View File
@@ -70,7 +70,9 @@ export { Prisma };
export const prisma = singleton("prisma", getClient);
export const $replica: Omit<PrismaClient, "$transaction"> = singleton(
export type PrismaReplicaClient = Omit<PrismaClient, "$transaction">;
export const $replica: PrismaReplicaClient = singleton(
"replica",
() => getReplicaClient() ?? prisma
);
+9 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server";
import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
import { isValidRegex } from "./utils/regex";
import { isValidDatabaseUrl } from "./utils/db";
@@ -158,6 +158,14 @@ const EnvironmentSchema = z.object({
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
/** These enable the alerts feature in v3 */
ALERT_FROM_EMAIL: z.string().optional(),
ALERT_RESEND_API_KEY: z.string().optional(),
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
LOOPS_API_KEY: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+2
View File
@@ -4,6 +4,7 @@ import { requestUrl } from "./utils/requestUrl.server";
export type TriggerFeatures = {
isManagedCloud: boolean;
v3Enabled: boolean;
alertsEnabled: boolean;
};
// If the request host is cloud.trigger.dev then we are on the managed cloud
@@ -20,5 +21,6 @@ export function featuresForRequest(request: Request): TriggerFeatures {
return {
isManagedCloud,
v3Enabled: env.V3_ENABLED === "true",
alertsEnabled: env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined,
};
}
+1 -1
View File
@@ -5,5 +5,5 @@ import type { TriggerFeatures } from "~/features.server";
export function useFeatures(): TriggerFeatures {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false };
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false, alertsEnabled: false };
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { RuntimeEnvironmentType, type RuntimeEnvironment } from "@trigger.dev/database";
import type { RuntimeEnvironment } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { customAlphabet } from "nanoid";
import { RuntimeEnvironmentType } from "~/database-types";
const apiKeyId = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
@@ -27,7 +27,7 @@ type SlackSecret = z.infer<typeof SlackSecretSchema>;
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
export type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
AuthenticatableIntegration,
"service"
> & {
@@ -83,7 +83,14 @@ export class OrgIntegrationRepository {
static slackAuthorizationUrl(
state: string,
scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"],
scopes: string[] = [
"channels:read",
"groups:read",
"im:read",
"mpim:read",
"chat:write",
"chat:write.public",
],
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
) {
return `https://slack.com/oauth/v2/authorize?client_id=${
+2 -15
View File
@@ -21,16 +21,12 @@ export async function createOrganization(
{
title,
userId,
projectName,
companySize,
projectVersion,
}: Pick<Organization, "title" | "companySize"> & {
userId: User["id"];
projectName: string;
projectVersion: "v2" | "v3";
},
attemptCount = 0
): Promise<Organization & { projects: Project[] }> {
): Promise<Organization> {
if (typeof process.env.BLOCKED_USERS === "string" && process.env.BLOCKED_USERS.includes(userId)) {
throw new Error("Organization could not be created.");
}
@@ -50,9 +46,7 @@ export async function createOrganization(
{
title,
userId,
projectName,
companySize,
projectVersion,
},
attemptCount + 1
);
@@ -76,14 +70,7 @@ export async function createOrganization(
},
});
const project = await createProject({
organizationSlug: organization.slug,
name: projectName,
userId,
version: projectVersion,
});
return { ...organization, projects: [project] };
return { ...organization };
}
export async function createEnvironment(
+12 -8
View File
@@ -1,17 +1,20 @@
import {
TaskRunError,
import type {
TaskRunExecutionResult,
TaskRunFailedExecutionResult,
TaskRunSuccessfulExecutionResult,
} from "@trigger.dev/core/v3";
import {
BatchTaskRunItemStatus,
import { TaskRunError } from "@trigger.dev/core/v3";
import type {
TaskRun,
TaskRunAttempt,
TaskRunAttemptStatus,
TaskRunStatus,
TaskRunAttemptStatus as TaskRunAttemptStatusType,
TaskRunStatus as TaskRunStatusType,
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
} from "@trigger.dev/database";
import { assertNever } from "assert-never";
import { BatchTaskRunItemStatus, TaskRunAttemptStatus, TaskRunStatus } from "~/database-types";
import { logger } from "~/services/logger.server";
const SUCCESSFUL_STATUSES = [TaskRunStatus.COMPLETED_SUCCESSFULLY];
@@ -104,7 +107,9 @@ export function executionResultForTaskRun(
}
}
export function batchTaskRunItemStatusForRunStatus(status: TaskRunStatus): BatchTaskRunItemStatus {
export function batchTaskRunItemStatusForRunStatus(
status: TaskRunStatusType
): BatchTaskRunItemStatusType {
switch (status) {
case TaskRunStatus.COMPLETED_SUCCESSFULLY:
return BatchTaskRunItemStatus.COMPLETED;
@@ -113,7 +118,6 @@ export function batchTaskRunItemStatusForRunStatus(status: TaskRunStatus): Batch
case TaskRunStatus.COMPLETED_WITH_ERRORS:
case TaskRunStatus.SYSTEM_FAILURE:
case TaskRunStatus.CRASHED:
case TaskRunStatus.COMPLETED_WITH_ERRORS:
return BatchTaskRunItemStatus.FAILED;
case TaskRunStatus.PENDING:
case TaskRunStatus.WAITING_FOR_DEPLOY:
@@ -577,6 +577,17 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
span.recordException(new Error(String(error)));
}
if (job.attempts >= job.max_attempts) {
logger.debug("Job failed after max attempts", {
job,
attempts: job.attempts,
max_attempts: job.max_attempts,
error: error instanceof Error ? error.message : error,
});
return;
}
throw error;
} finally {
span.end();
@@ -1,8 +1,8 @@
import { PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
getCurrentProjectId,
setCurrentProjectId,
@@ -10,8 +10,6 @@ import {
import { logger } from "~/services/logger.server";
import { newProjectPath } from "~/utils/pathBuilder";
import { ProjectPresenter } from "./ProjectPresenter.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { match } from "assert";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -64,6 +62,10 @@ export class OrganizationsPresenter {
);
}
if (project.organizationId !== organization.id) {
throw redirect(newProjectPath({ slug: organizationSlug }), request);
}
return { organizations, organization, project };
}
@@ -16,6 +16,10 @@ export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "de
export type ApiAlertType = z.infer<typeof ApiAlertType>;
export const ApiAlertEnvironmentType = z.enum(["STAGING", "PRODUCTION"]);
export type ApiAlertEnvironmentType = z.infer<typeof ApiAlertEnvironmentType>;
export const ApiAlertChannel = z.enum(["email", "webhook"]);
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
@@ -34,6 +38,7 @@ export const ApiCreateAlertChannel = z.object({
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
environmentTypes: ApiAlertEnvironmentType.array().default(["STAGING", "PRODUCTION"]),
});
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
@@ -87,7 +87,7 @@ export class EnvironmentVariablesPresenter {
);
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
const variables = await repository.getProject(project.id, userId);
const variables = await repository.getProject(project.id);
return {
environmentVariables: environmentVariables.map((environmentVariable) => {
@@ -17,6 +17,7 @@ type RunListOptions = {
environments?: string[];
scheduleId?: string;
period?: string;
bulkId?: string;
from?: number;
to?: number;
//pagination
@@ -25,7 +26,7 @@ type RunListOptions = {
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 25;
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
export type RunListItem = RunList["runs"][0];
@@ -41,6 +42,7 @@ export class RunListPresenter extends BasePresenter {
environments,
scheduleId,
period,
bulkId,
from,
to,
direction = "forward",
@@ -55,6 +57,7 @@ export class RunListPresenter extends BasePresenter {
hasStatusFilters ||
(environments !== undefined && environments.length > 0) ||
(period !== undefined && period !== "all") ||
(bulkId !== undefined && bulkId !== "") ||
from !== undefined ||
to !== undefined;
@@ -87,13 +90,55 @@ export class RunListPresenter extends BasePresenter {
});
//get all possible tasks
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
const possibleTasksAsync = this._replica.backgroundWorkerTask.findMany({
distinct: ["slug"],
where: {
projectId: project.id,
},
});
//get possible bulk actions
const bulkActionsAsync = this._replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
type: true,
createdAt: true,
},
where: {
projectId: project.id,
},
orderBy: {
createdAt: "desc",
},
take: 20,
});
const [possibleTasks, bulkActions] = await Promise.all([possibleTasksAsync, bulkActionsAsync]);
//we can restrict to specific runs using bulkId, or batchId
let restrictToRunIds: undefined | string[] = undefined;
//bulk id
if (bulkId) {
const bulkAction = await this._replica.bulkActionGroup.findUnique({
select: {
items: {
select: {
destinationRunId: true,
},
},
},
where: {
friendlyId: bulkId,
},
});
if (bulkAction) {
const runIds = bulkAction.items.map((item) => item.destinationRunId).filter(Boolean);
restrictToRunIds = runIds;
}
}
const periodMs = period ? parse(period) : undefined;
//get the runs
@@ -142,6 +187,13 @@ export class RunListPresenter extends BasePresenter {
: Prisma.empty
}
-- filters
${
restrictToRunIds
? restrictToRunIds.length === 0
? Prisma.sql`AND tr.id = ''`
: Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})`
: Prisma.empty
}
${
tasks && tasks.length > 0
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
@@ -240,6 +292,11 @@ export class RunListPresenter extends BasePresenter {
.sort((a, b) => {
return a.slug.localeCompare(b.slug);
}),
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
})),
filters: {
tasks: tasks || [],
versions: versions || [],
@@ -1,29 +1,26 @@
import { prettyPrintPacket } from "@trigger.dev/core/v3";
import { PrismaClient, prisma } from "~/db.server";
import { eventRepository } from "~/v3/eventRepository.server";
import { BasePresenter } from "./basePresenter.server";
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
export type Span = NonNullable<Result>["event"];
export class SpanPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
export class SpanPresenter extends BasePresenter {
public async call({
userId,
projectSlug,
organizationSlug,
spanId,
runFriendlyId,
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
spanId: string;
runFriendlyId: string;
}) {
const project = await this.#prismaClient.project.findUnique({
const project = await this._replica.project.findUnique({
where: {
slug: projectSlug,
},
@@ -33,7 +30,20 @@ export class SpanPresenter {
throw new Error("Project not found");
}
const span = await eventRepository.getSpan(spanId);
const run = await this._prisma.taskRun.findFirst({
select: {
traceId: true,
},
where: {
friendlyId: runFriendlyId,
},
});
if (!run) {
return;
}
const span = await eventRepository.getSpan(spanId, run.traceId);
if (!span) {
return;
@@ -1,18 +1,19 @@
import {
Prisma,
import type {
RuntimeEnvironmentType,
TaskRunStatus,
TaskTriggerSource,
TaskRunStatus as TaskRunStatusType,
} from "@trigger.dev/database";
import { Prisma } from "@trigger.dev/database";
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import type { Organization } from "~/models/organization.server";
import type { Project } from "~/models/project.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { User } from "~/models/user.server";
import type { User } from "~/models/user.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { TaskRunStatus } from "~/database-types";
export type Task = {
slug: string;
@@ -110,6 +111,14 @@ export class TaskListPresenter extends BasePresenter {
acc.push(existingTask);
}
//favour newer tasks
if (task.createdAt > existingTask.createdAt) {
existingTask.createdAt = task.createdAt;
existingTask.exportName = task.exportName;
existingTask.filePath = task.filePath;
existingTask.triggerSource = task.triggerSource;
}
existingTask.environments.push(displayableEnvironments(environment, userId));
//order the environments
@@ -150,7 +159,7 @@ export class TaskListPresenter extends BasePresenter {
const activity = await this._replica.$queryRaw<
{
taskIdentifier: string;
status: TaskRunStatus;
status: TaskRunStatusType;
day: Date;
count: BigInt;
}[]
@@ -193,7 +202,7 @@ export class TaskListPresenter extends BasePresenter {
existingTask.push({
day: day.toISOString(),
[TaskRunStatus.COMPLETED_SUCCESSFULLY]: 0,
} as { day: string } & Record<TaskRunStatus, number>);
} as { day: string } & Record<TaskRunStatusType, number>);
}
acc[a.taskIdentifier] = existingTask;
@@ -214,7 +223,7 @@ export class TaskListPresenter extends BasePresenter {
day[a.status] = Number(a.count);
return acc;
}, {} as Record<string, ({ day: string } & Record<TaskRunStatus, number>)[]>);
}, {} as Record<string, ({ day: string } & Record<TaskRunStatusType, number>)[]>);
}
async #getRunningStats(tasks: string[], projectId: string) {
@@ -225,7 +234,7 @@ export class TaskListPresenter extends BasePresenter {
const statuses = await this._replica.$queryRaw<
{
taskIdentifier: string;
status: TaskRunStatus;
status: TaskRunStatusType;
count: BigInt;
}[]
>`
+20 -1
View File
@@ -1,8 +1,9 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { getUsersInvites } from "~/models/member.server";
import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server";
import { requireUser } from "~/services/session.server";
import { invitesPath, newOrganizationPath, projectPath } from "~/utils/pathBuilder";
import { invitesPath, newOrganizationPath, newProjectPath, projectPath } from "~/utils/pathBuilder";
//this loader chooses the best project to redirect you to, ideally based on the cookie
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -20,6 +21,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
//redirect them to the most appropriate project
return redirect(projectPath(organization, project));
} catch (e) {
const organization = await prisma.organization.findFirst({
where: {
members: {
some: {
userId: user.id,
},
},
deletedAt: null,
},
orderBy: {
createdAt: "desc",
},
});
if (organization) {
return redirect(newProjectPath(organization));
}
//this should only happen if the user has no projects, and no invites
return redirect(newOrganizationPath());
}
@@ -10,8 +10,8 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -20,14 +20,17 @@ 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 SegmentedControl from "~/components/primitives/SegmentedControl";
import { Select, SelectItem } from "~/components/primitives/Select";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
import {
CreateAlertChannelOptions,
@@ -40,6 +43,10 @@ const FormSchema = z
.array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
.min(1)
.or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
environmentTypes: z
.array(z.enum(["STAGING", "PRODUCTION"]))
.min(1)
.or(z.enum(["STAGING", "PRODUCTION"])),
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
channelValue: z.string().nonempty(),
integrationId: z.string().optional(),
@@ -81,6 +88,9 @@ function formDataToCreateAlertChannelOptions(
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
environmentTypes: Array.isArray(formData.environmentTypes)
? formData.environmentTypes
: [formData.environmentTypes],
channel: {
type: "WEBHOOK",
url: formData.channelValue,
@@ -93,6 +103,9 @@ function formDataToCreateAlertChannelOptions(
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
environmentTypes: Array.isArray(formData.environmentTypes)
? formData.environmentTypes
: [formData.environmentTypes],
channel: {
type: "EMAIL",
email: formData.channelValue,
@@ -107,6 +120,9 @@ function formDataToCreateAlertChannelOptions(
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
environmentTypes: Array.isArray(formData.environmentTypes)
? formData.environmentTypes
: [formData.environmentTypes],
channel: {
type: "SLACK",
channelId,
@@ -193,20 +209,27 @@ export default function Page() {
const project = useProject();
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
const selectedSlackChannel = slack.channels?.find(
(s) => selectedSlackChannelValue === `${s.id}/${s.name}`
);
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "create";
const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({
id: "create-alert",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
const [form, { channelValue: channelValue, alertTypes, environmentTypes, type, integrationId }] =
useForm({
id: "create-alert",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
useEffect(() => {
setIsOpen(true);
@@ -271,6 +294,9 @@ export default function Page() {
dropdownIcon
variant="tertiary/medium"
items={slack.channels}
setValue={(value) => {
typeof value === "string" && setSelectedSlackChannelValue(value);
}}
filter={(channel, search) =>
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
}
@@ -290,10 +316,19 @@ export default function Page() {
</>
)}
</Select>
<Hint className="leading-relaxed">
If selecting a private channel, you will need to invite the bot to the channel
using <InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>
</Hint>
{selectedSlackChannel && selectedSlackChannel.is_private && (
<Callout
variant="warning"
className={cn("text-sm", variantClasses.warning.textColor)}
>
To receive alerts in the{" "}
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
Slack and type:{" "}
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
</Callout>
)}
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
<input type="hidden" name="integrationId" value={slack.integrationId} />
</>
@@ -324,39 +359,63 @@ export default function Page() {
</InputGroup>
)}
<InputGroup fullWidth>
<Label>Events</Label>
<InputGroup>
<Label>Alert me when</Label>
<Checkbox
name={alertTypes.name}
id="TASK_RUN_ATTEMPT"
value="TASK_RUN_ATTEMPT"
variant="simple/small"
label="Task run failure"
defaultChecked
/>
<div className="flex items-center gap-1">
<CheckboxWithLabel
name={alertTypes.name}
id="TASK_RUN_ATTEMPT"
value="TASK_RUN_ATTEMPT"
variant="simple/small"
label="Task run attempts fail"
defaultChecked
className="pr-0"
/>
<InfoIconTooltip content="You'll receive an alert every time an attempt fails on a run." />
</div>
<Checkbox
<CheckboxWithLabel
name={alertTypes.name}
id="DEPLOYMENT_FAILURE"
value="DEPLOYMENT_FAILURE"
variant="simple/small"
label="Deployment failure"
label="Deployments fail"
defaultChecked
/>
<Checkbox
<CheckboxWithLabel
name={alertTypes.name}
id="DEPLOYMENT_SUCCESS"
value="DEPLOYMENT_SUCCESS"
variant="simple/small"
label="Deployment success"
label="Deployments succeed"
defaultChecked
/>
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Environments</Label>
<CheckboxWithLabel
name={environmentTypes.name}
id="PRODUCTION"
value="PRODUCTION"
variant="simple/small"
label="PROD"
defaultChecked
/>
<CheckboxWithLabel
name={environmentTypes.name}
id="STAGING"
value="STAGING"
variant="simple/small"
label="STAGING"
defaultChecked
/>
<FormError id={environmentTypes.errorId}>{environmentTypes.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="border-t border-grid-bright pt-3">
<FormButtons
@@ -1,8 +1,9 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
BoltIcon,
BoltSlashIcon,
ArrowUpRightIcon,
BellAlertIcon,
BellSlashIcon,
BookOpenIcon,
EnvelopeIcon,
GlobeAltIcon,
@@ -17,10 +18,12 @@ import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { EnvironmentTypeLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -155,7 +158,7 @@ export default function Page() {
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/project-alerts")}
to={docsPath("v3/troubleshooting-alerts")}
variant="minimal/small"
>
Alerts docs
@@ -164,21 +167,25 @@ export default function Page() {
</NavBar>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
<div className="flex items-center justify-end gap-2">
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
{alertChannels.length > 0 && alertChannels.length < 10 && (
<div className="flex items-end justify-between">
<Header2 className="">Project alerts</Header2>
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Alert Types</TableHeaderCell>
<TableHeaderCell>Alert types</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
@@ -194,11 +201,25 @@ export default function Page() {
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
</TableCell>
<TableCell
className={cn("space-x-2", alertChannel.enabled ? "" : "opacity-50")}
>
{alertChannel.environmentTypes.map((environmentType) => (
<EnvironmentTypeLabel
key={environmentType}
environment={{ type: environmentType }}
/>
))}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<EnabledStatus enabled={alertChannel.enabled} />
<EnabledStatus
enabled={alertChannel.enabled}
enabledIcon={BellAlertIcon}
disabledIcon={BellSlashIcon}
/>
</TableCell>
<TableCellMenu isSticky>
{alertChannel.enabled ? (
@@ -214,14 +235,44 @@ export default function Page() {
) : (
<TableRow>
<TableCell colSpan={5}>
<div className="flex items-center justify-center">
<Paragraph>No alerts have been created</Paragraph>
<div className="flex flex-col items-center justify-center py-6">
<Header2 spacing className="text-text-bright">
You haven't created any project alerts yet
</Header2>
<Paragraph variant="small" className="mb-4">
Get alerted when runs or deployments fail, or when deployments succeed in
both Prod and Staging environments.
</Paragraph>
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<Header2 className="mb-1">Platform alerts</Header2>
<Paragraph variant="small" className="mb-4">
Subscribe to get email notifications when Trigger.dev creates, updates or resolves a
platform incident.
</Paragraph>
<LinkButton
variant="tertiary/medium"
TrailingIcon={ArrowUpRightIcon}
to="https://status.trigger.dev/"
target="_blank"
className="inline-flex"
>
Subscribe
</LinkButton>
</div>
</div>
<Outlet />
</PageBody>
@@ -294,7 +345,7 @@ function DisableAlertChannelButton(props: { id: string }) {
value="disable"
type="submit"
variant="small-menu-item"
LeadingIcon={BoltSlashIcon}
LeadingIcon={BellSlashIcon}
leadingIconClassName="text-dimmed"
className="text-xs"
>
@@ -332,7 +383,7 @@ function EnableAlertChannelButton(props: { id: string }) {
value="enable"
type="submit"
variant="small-menu-item"
LeadingIcon={BoltIcon}
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-success"
className="text-xs"
>
@@ -18,7 +18,7 @@ import {
environmentTitle,
} from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -70,7 +70,7 @@ const Variable = z.object({
type Variable = z.infer<typeof Variable>;
const schema = z.object({
overwrite: z.preprocess((i) => {
override: z.preprocess((i) => {
if (i === "true") return true;
if (i === "false") return false;
return;
@@ -115,6 +115,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
@@ -126,7 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.create(project.id, userId, submission.value);
const result = await repository.create(project.id, submission.value);
if (!result.success) {
if (result.variableErrors) {
@@ -195,7 +202,7 @@ export default function Page() {
<Label>Environments</Label>
<div className="flex flex-wrap items-center gap-2">
{environments.map((environment) => (
<Checkbox
<CheckboxWithLabel
key={environment.id}
id={environment.id}
value={environment.id}
@@ -249,7 +256,7 @@ export default function Page() {
type="submit"
variant="primary/small"
disabled={isLoading}
name="overwrite"
name="override"
value="false"
>
{isLoading ? "Saving" : "Save"}
@@ -257,10 +264,10 @@ export default function Page() {
<Button
variant="secondary/small"
disabled={isLoading}
name="overwrite"
name="override"
value="true"
>
{isLoading ? "Overwriting" : "Overwrite"}
{isLoading ? "Overriding" : "Override"}
</Button>
</div>
}
@@ -106,6 +106,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
@@ -119,7 +126,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (submission.value.action) {
case "edit": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.edit(project.id, userId, submission.value);
const result = await repository.edit(project.id, submission.value);
if (!result.success) {
submission.error.key = result.error;
@@ -138,7 +145,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
case "delete": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.delete(project.id, userId, submission.value);
const result = await repository.delete(project.id, submission.value);
if (!result.success) {
submission.error.key = result.error;
@@ -334,6 +341,7 @@ function EditEnvironmentVariablePanel({
name={`values[${index}].value`}
placeholder="Not set"
defaultValue={value}
type="password"
/>
</Fragment>
);
@@ -1,16 +1,33 @@
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { Form, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { TypedAwait, typeddefer, typedjson, useTypedLoaderData } from "remix-typedjson";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, useState } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { StepContentContainer } from "~/components/StepContentContainer";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header1 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
SelectedItemsProvider,
useSelectedItems,
} from "~/components/primitives/SelectedItemsProvider";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import { TextLink } from "~/components/primitives/TextLink";
import { RunsFilters, TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -19,11 +36,9 @@ import { useUser } from "~/hooks/useUser";
import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3ProjectPath, v3TestPath } from "~/utils/pathBuilder";
import { ProjectParamSchema, v3ProjectPath, v3RunsPath, v3TestPath } from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { TextLink } from "~/components/primitives/TextLink";
import { Spinner } from "~/components/primitives/Spinner";
import { Suspense } from "react";
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -37,8 +52,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
environments: url.searchParams.getAll("environments"),
tasks: url.searchParams.getAll("tasks"),
period: url.searchParams.get("period") ?? undefined,
bulkId: url.searchParams.get("bulkId") ?? undefined,
};
const { tasks, versions, statuses, environments, period, from, to, cursor, direction } =
const { tasks, versions, statuses, environments, period, bulkId, from, to, cursor, direction } =
TaskRunListSearchFilters.parse(s);
const presenter = new RunListPresenter();
@@ -50,6 +66,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
statuses,
environments,
period,
bulkId,
from,
to,
direction: direction,
@@ -73,60 +90,246 @@ export default function Page() {
<NavBar>
<PageTitle title="Runs" />
</NavBar>
<PageBody>
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
</div>
}
<PageBody scrollable={false}>
<SelectedItemsProvider
initialSelectedItems={[]}
maxSelectedItemCount={BULK_ACTION_RUN_LIMIT}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
<div className="mb-2 flex items-start justify-between gap-x-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
hasFilters={list.hasFilters}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
{({ selectedItems }) => (
<div
className={cn(
"grid h-full max-h-full overflow-hidden",
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_2.5rem]"
)}
>
<div className="overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</div>
)}
</>
)}
</TypedAwait>
</Suspense>
}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
<div className="mb-2 flex items-start justify-between gap-x-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
allowSelection
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</div>
)}
</>
)}
</TypedAwait>
</Suspense>
</div>
<BulkActionBar />
</div>
)}
</SelectedItemsProvider>
</PageBody>
</>
);
}
function BulkActionBar() {
const { selectedItems, deselectAll } = useSelectedItems();
const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none");
const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT;
return (
<AnimatePresence>
{selectedItems.size > 0 && (
<motion.div
initial={{ translateY: "100%" }}
animate={{ translateY: 0 }}
exit={{ translateY: "100%" }}
className="flex items-center justify-between gap-2 border-t border-grid-bright bg-background-bright px-3"
>
<div className="flex items-center gap-1.5 text-sm text-text-bright">
<span className="font-medium">Bulk actions:</span>
{hasSelectedMaximum ? (
<span className="text-warning">Maximum of {selectedItems.size} runs selected</span>
) : (
<span>{selectedItems.size} runs selected</span>
)}
</div>
<div className="flex items-center gap-1 divide-x divide-charcoal-700">
<CancelRuns
onOpen={(o) => {
if (o) {
setBarState("cancel");
} else {
setBarState("none");
}
}}
/>
<ReplayRuns
onOpen={(o) => {
if (o) {
setBarState("replay");
} else {
setBarState("none");
}
}}
/>
<Button
variant="minimal/medium"
shortcut={{ key: "esc", enabledOnInputElements: true }}
onClick={() => {
if (barState !== "none") return;
deselectAll();
}}
>
Clear selection
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
const { selectedItems } = useSelectedItems();
const organization = useOrganization();
const project = useProject();
const failedRedirect = v3RunsPath(organization, project);
const formAction = `/resources/taskruns/bulk/cancel`;
const navigation = useNavigation();
const isLoading = navigation.formAction === formAction;
return (
<Dialog onOpenChange={(o) => onOpen(o)}>
<DialogTrigger asChild>
<Button
type="button"
variant="minimal/medium"
shortcut={{ key: "c", enabledOnInputElements: true }}
LeadingIcon={StopCircleIcon}
>
Cancel runs
</Button>
</DialogTrigger>
<DialogContent key="replay">
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
<DialogDescription>
Canceling these runs will stop them from running. Only runs that are not already finished
will be canceled, the others will remain in their existing state.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
<Button
type="submit"
variant="danger/small"
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Canceling..." : `Cancel ${selectedItems.size} runs`}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
const { selectedItems } = useSelectedItems();
const organization = useOrganization();
const project = useProject();
const failedRedirect = v3RunsPath(organization, project);
const formAction = `/resources/taskruns/bulk/replay`;
const navigation = useNavigation();
const isLoading = navigation.formAction === formAction;
return (
<Dialog onOpenChange={(o) => onOpen(o)}>
<DialogTrigger asChild>
<Button
type="button"
variant="minimal/medium"
shortcut={{ key: "r", enabledOnInputElements: true }}
LeadingIcon={ArrowPathIcon}
>
Replay {selectedItems.size} runs
</Button>
</DialogTrigger>
<DialogContent key="replay">
<DialogHeader>Replay runs?</DialogHeader>
<DialogDescription>
Replaying these runs will create a new run for each with the same payload and environment
as the original. It will use the latest version of the code for each task.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
<Button
type="submit"
variant="primary/small"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Replaying..." : `Replay ${selectedItems.size} runs`}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CreateFirstTaskInstructions() {
const organization = useOrganization();
const project = useProject();
@@ -16,13 +16,17 @@ import { FormTitle } from "~/components/primitives/FormTitle";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { useFeatures } from "~/hooks/useFeatures";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { createProject } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
import { RequestV3Access } from "../resources.orgs.$organizationSlug.v3-access";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -34,10 +38,14 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
id: true,
title: true,
v3Enabled: true,
v2Enabled: true,
hasRequestedV3: true,
_count: {
select: {
projects: {
where: { deletedAt: null },
where: {
deletedAt: null,
},
},
},
},
@@ -57,6 +65,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
slug: organizationSlug,
projectsCount: organization._count.projects,
v3Enabled: organization.v3Enabled,
v2Enabled: organization.v2Enabled,
hasRequestedV3: organization.hasRequestedV3,
},
defaultVersion: url.searchParams.get("version") ?? "v2",
});
@@ -98,11 +108,23 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function NewOrganizationPage() {
const { organization, defaultVersion } = useTypedLoaderData<typeof loader>();
const { organization } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { v3Enabled } = useFeatures();
const { v3Enabled, isManagedCloud } = useFeatures();
const canCreateV3Projects = organization.v3Enabled && v3Enabled;
const canCreateV2Projects = organization.v2Enabled || !isManagedCloud;
const canCreateProjects = canCreateV2Projects || canCreateV3Projects;
if (!canCreateProjects) {
return (
<RequestV3Access
hasRequestedV3={organization.hasRequestedV3}
organizationSlug={organization.slug}
projectsCount={organization.projectsCount}
/>
);
}
const [form, { projectName, projectVersion }] = useForm({
id: "create-project",
@@ -119,7 +141,7 @@ export default function NewOrganizationPage() {
<FormTitle
LeadingIcon="folder"
title="Create a new project"
description={`This will create a new project in your "${organization.title}" organization. `}
description={`This will create a new project in your "${organization.title}" organization.`}
/>
<Form method="post" {...form.props}>
{organization.projectsCount === 0 && (
@@ -138,7 +160,7 @@ export default function NewOrganizationPage() {
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
{canCreateV3Projects ? (
{canCreateV2Projects && canCreateV3Projects ? (
<InputGroup>
<Label htmlFor={projectVersion.id}>Project version</Label>
<Select
@@ -161,8 +183,16 @@ export default function NewOrganizationPage() {
</Select>
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
</InputGroup>
) : canCreateV3Projects ? (
<>
<Callout variant="info">This will be a v3 project</Callout>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />
</>
) : (
<input {...conform.input(projectVersion, { type: "hidden" })} value="v2" />
<>
<Callout variant="info">This will be a v2 project</Callout>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
</>
)}
<FormButtons
confirmButton={
+4 -67
View File
@@ -17,19 +17,14 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { Select, SelectItem } from "~/components/primitives/Select";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
import { organizationPath, rootPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
projectName: z.string().min(3).max(50),
projectVersion: z.enum(["v2", "v3"]),
companySize: z.string().optional(),
});
@@ -57,29 +52,10 @@ export const action: ActionFunction = async ({ request }) => {
const organization = await createOrganization({
title: submission.value.orgName,
userId,
projectName: submission.value.projectName,
companySize: submission.value.companySize ?? null,
projectVersion: submission.value.projectVersion,
});
const project = organization.projects[0];
const session = await setCurrentProjectId(project.id, request);
const { isManagedCloud } = featuresForRequest(request);
const headers = {
"Set-Cookie": await commitCurrentProjectSession(session),
};
if (isManagedCloud && submission.value.projectVersion === "v2") {
return redirect(selectPlanPath(organization), {
headers,
});
}
return redirect(projectPath(organization, project), {
headers,
});
return redirect(organizationPath(organization));
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
@@ -91,10 +67,7 @@ export default function NewOrganizationPage() {
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
//this is temporary whilst v3 is invite-only. Switch to the useFeatures value when v3 is generally available.
const v3Enabled = false;
const [form, { orgName, projectName, projectVersion }] = useForm({
const [form, { orgName }] = useForm({
id: "create-organization",
// TODO: type this
lastSubmission: lastSubmission as any,
@@ -123,45 +96,9 @@ export default function NewOrganizationPage() {
<Hint>E.g. your company name or your workspace name.</Hint>
<FormError id={orgName.errorId}>{orgName.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
placeholder="Your Project name"
icon="folder"
/>
<Hint>Your Jobs will live inside this Project.</Hint>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
{v3Enabled ? (
<InputGroup>
<Label htmlFor={projectVersion.id}>Project version</Label>
<Select
{...conform.select(projectVersion)}
defaultValue={undefined}
variant="tertiary/medium"
placeholder="Select version"
dropdownIcon
text={(value) => {
switch (value) {
case "v2":
return "Version 2";
case "v3":
return "Version 3";
}
}}
>
<SelectItem value="v2">Version 2</SelectItem>
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
</Select>
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
</InputGroup>
) : (
<input {...conform.input(projectVersion, { type: "hidden" })} value="v2" />
)}
{isManagedCloud && (
<InputGroup>
<Label htmlFor={projectName.id}>Number of employees</Label>
<Label htmlFor={"companySize"}>Number of employees</Label>
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
<RadioGroupItem
id="employees-1-5"
@@ -6,7 +6,7 @@ import { z } from "zod";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
@@ -158,7 +158,7 @@ export default function Page() {
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
<CheckboxWithLabel
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive onboarding emails"
@@ -0,0 +1,31 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { marqs } from "~/v3/marqs/index.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const details = await marqs?.getSharedQueueDetails();
return json(details);
}
@@ -0,0 +1,49 @@
import { CreateExternalConnectionBody } from "@trigger.dev/core";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
export class CreateExternalConnectionService {
#prismaClient: PrismaClientOrTransaction;
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
accountIdentifier: string,
clientSlug: string,
environment: AuthenticatedEnvironment,
payload: CreateExternalConnectionBody
) {
const externalAccount = await this.#prismaClient.externalAccount.upsert({
where: {
environmentId_identifier: {
environmentId: environment.id,
identifier: accountIdentifier,
},
},
create: {
environmentId: environment.id,
organizationId: environment.organizationId,
identifier: accountIdentifier,
},
update: {},
});
const integration = await this.#prismaClient.integration.findUniqueOrThrow({
where: {
organizationId_slug: {
organizationId: environment.organizationId,
slug: clientSlug,
},
},
});
return await integrationAuthRepository.createConnectionFromToken({
externalAccount: externalAccount,
integration,
token: payload,
});
}
}
@@ -1,15 +1,10 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
CreateExternalConnectionBody,
CreateExternalConnectionBodySchema,
ErrorWithStackSchema,
} from "@trigger.dev/core";
import { CreateExternalConnectionBodySchema, ErrorWithStackSchema } from "@trigger.dev/core";
import { z } from "zod";
import { generateErrorMessage } from "zod-error";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { AuthenticatedEnvironment, authenticateApiRequest } from "~/services/apiAuth.server";
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { CreateExternalConnectionService } from "./CreateExternalConnectionService.server";
const ParamsSchema = z.object({
accountId: z.string(),
@@ -67,48 +62,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ message: parsedError.data.message }, { status: 500 });
}
}
class CreateExternalConnectionService {
#prismaClient: PrismaClientOrTransaction;
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
accountIdentifier: string,
clientSlug: string,
environment: AuthenticatedEnvironment,
payload: CreateExternalConnectionBody
) {
const externalAccount = await this.#prismaClient.externalAccount.upsert({
where: {
environmentId_identifier: {
environmentId: environment.id,
identifier: accountIdentifier,
},
},
create: {
environmentId: environment.id,
organizationId: environment.organizationId,
identifier: accountIdentifier,
},
update: {},
});
const integration = await this.#prismaClient.integration.findUniqueOrThrow({
where: {
organizationId_slug: {
organizationId: environment.organizationId,
slug: clientSlug,
},
},
});
return await integrationAuthRepository.createConnectionFromToken({
externalAccount: externalAccount,
integration,
token: payload,
});
}
}
@@ -1,73 +1,10 @@
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { z } from "zod";
import { $transaction, PrismaClient, prisma } from "~/db.server";
import type { PrismaClient } from "~/db.server";
import { $transaction, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { workerQueue } from "~/services/worker.server";
import { safeJsonParse } from "~/utils/json";
const ParamsSchema = z.object({
environmentId: z.string(),
endpointSlug: z.string(),
indexHookIdentifier: z.string(),
});
export async function loader({ params }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return {
status: 400,
json: {
error: "Invalid params",
},
};
}
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
const service = new TriggerEndpointIndexHookService();
await service.call({
environmentId,
endpointSlug,
indexHookIdentifier,
});
return json({
ok: true,
});
}
export async function action({ request, params }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return {
status: 400,
json: {
error: "Invalid params",
},
};
}
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
const body = await request.text();
const service = new TriggerEndpointIndexHookService();
await service.call({
environmentId,
endpointSlug,
indexHookIdentifier,
body: body ? safeJsonParse(body) : undefined,
});
return json({
ok: true,
});
}
import { RuntimeEnvironmentType } from "~/database-types";
import type { ParamsSchema } from "./route";
type TriggerEndpointDeployHookOptions = z.infer<typeof ParamsSchema> & {
body?: any;
@@ -106,7 +43,7 @@ export class TriggerEndpointIndexHookService {
},
});
if (!endpoint) {
if (!endpoint || !endpoint.url) {
throw new Error("Endpoint not found");
}
@@ -0,0 +1,67 @@
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { safeJsonParse } from "~/utils/json";
import { TriggerEndpointIndexHookService } from "./TriggerEndpointIndexHookService.server";
export const ParamsSchema = z.object({
environmentId: z.string(),
endpointSlug: z.string(),
indexHookIdentifier: z.string(),
});
export async function loader({ params }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return {
status: 400,
json: {
error: "Invalid params",
},
};
}
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
const service = new TriggerEndpointIndexHookService();
await service.call({
environmentId,
endpointSlug,
indexHookIdentifier,
});
return json({
ok: true,
});
}
export async function action({ request, params }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return {
status: 400,
json: {
error: "Invalid params",
},
};
}
const { environmentId, endpointSlug, indexHookIdentifier } = parsedParams.data;
const body = await request.text();
const service = new TriggerEndpointIndexHookService();
await service.call({
environmentId,
endpointSlug,
indexHookIdentifier,
body: body ? safeJsonParse(body) : undefined,
});
return json({
ok: true,
});
}
@@ -54,6 +54,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
email: body.data.channelData.email,
},
deduplicationKey: body.data.deduplicationKey,
environmentTypes: body.data.environmentTypes,
});
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
@@ -75,6 +76,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
secret: body.data.channelData.secret,
},
deduplicationKey: body.data.deduplicationKey,
environmentTypes: body.data.environmentTypes,
});
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
@@ -0,0 +1,137 @@
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
const ParamsSchema = z.object({
projectRef: z.string(),
slug: z.string(),
name: z.string(),
});
export async function action({ params, request }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);
// Find the environment variable
const variable = await prisma.environmentVariable.findFirst({
where: {
key: parsedParams.data.name,
projectId: environment.project.id,
},
});
if (!variable) {
return json({ error: "Environment variable not found" }, { status: 404 });
}
const repository = new EnvironmentVariablesRepository();
switch (request.method.toUpperCase()) {
case "DELETE": {
const result = await repository.deleteValue(environment.project.id, {
id: variable.id,
environmentId: environment.id,
});
if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error }, { status: 400 });
}
}
case "PUT":
case "POST": {
const jsonBody = await request.json();
const body = UpdateEnvironmentVariableRequestBody.safeParse(jsonBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const result = await repository.edit(environment.project.id, {
values: [
{
value: body.data.value,
environmentId: environment.id,
},
],
id: variable.id,
keepEmptyValues: true,
});
if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error }, { status: 400 });
}
}
}
}
export async function loader({ params, request }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);
// Find the environment variable
const variable = await prisma.environmentVariable.findFirst({
where: {
key: parsedParams.data.name,
projectId: environment.project.id,
},
});
if (!variable) {
return json({ error: "Environment variable not found" }, { status: 404 });
}
const repository = new EnvironmentVariablesRepository();
const variables = await repository.getEnvironment(environment.project.id, environment.id, true);
const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);
if (!environmentVariable) {
return json({ error: "Environment variable not found" }, { status: 404 });
}
return json({
value: environmentVariable.value,
});
}
@@ -0,0 +1,84 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3";
import { parse } from "dotenv";
import { z } from "zod";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
const ParamsSchema = z.object({
projectRef: z.string(),
slug: z.string(),
});
export async function action({ params, request }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);
const repository = new EnvironmentVariablesRepository();
const body = await parseImportBody(request);
const result = await repository.create(environment.project.id, {
override: typeof body.override === "boolean" ? body.override : false,
environmentIds: [environment.id],
variables: Object.entries(body.variables).map(([key, value]) => ({
key,
value,
})),
});
if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error, variableErrors: result.variableErrors }, { status: 400 });
}
}
async function parseImportBody(request: Request): Promise<ImportEnvironmentVariablesRequestBody> {
const contentType = request.headers.get("content-type") ?? "application/json";
if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("variables");
const override = formData.get("override") === "true";
if (file instanceof File) {
const buffer = await file.arrayBuffer();
const variables = parse(Buffer.from(buffer));
return { variables, override };
} else {
throw json({ error: "Invalid file" }, { status: 400 });
}
} else {
const rawBody = await request.json();
const body = ImportEnvironmentVariablesRequestBody.safeParse(rawBody);
if (!body.success) {
throw json({ error: "Invalid body" }, { status: 400 });
}
return body.data;
}
}
@@ -0,0 +1,86 @@
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { CreateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
const ParamsSchema = z.object({
projectRef: z.string(),
slug: z.string(),
});
export async function action({ params, request }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);
const jsonBody = await request.json();
const body = CreateEnvironmentVariableRequestBody.safeParse(jsonBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const repository = new EnvironmentVariablesRepository();
const result = await repository.create(environment.project.id, {
override: true,
environmentIds: [environment.id],
variables: [
{
key: body.data.name,
value: body.data.value,
},
],
});
if (result.success) {
return json({ success: true });
} else {
return json({ error: result.error, variableErrors: result.variableErrors }, { status: 400 });
}
}
export async function loader({ params, request }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
parsedParams.data.projectRef,
parsedParams.data.slug
);
const repository = new EnvironmentVariablesRepository();
const variables = await repository.getEnvironment(environment.project.id, environment.id, true);
return json(variables.map((variable) => ({ name: variable.key, value: variable.value })));
}
@@ -0,0 +1,20 @@
import type { LogMessage } from "@trigger.dev/core";
import type { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
export class CreateRunLogService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(environment: AuthenticatedEnvironment, runId: string, logMessage: LogMessage) {
// @ts-ignore
logger.debug(logMessage.message, logMessage.data ?? {});
return logMessage;
}
}
@@ -1,13 +1,9 @@
import type { Organization, RuntimeEnvironment } from "@trigger.dev/database";
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { LogMessage } from "@trigger.dev/core";
import { LogMessageSchema } from "@trigger.dev/core";
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { authenticateApiRequest, AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { CreateRunLogService } from "./CreateRunLogService.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -53,18 +49,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export class CreateRunLogService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(environment: AuthenticatedEnvironment, runId: string, logMessage: LogMessage) {
// @ts-ignore
logger.debug(logMessage.message, logMessage.data ?? {});
return logMessage;
}
}
@@ -0,0 +1,71 @@
import {
StatusUpdate,
StatusHistory,
StatusHistorySchema,
StatusUpdateState,
StatusUpdateData,
} from "@trigger.dev/core";
import { PrismaClient } from "@trigger.dev/database";
import { prisma, $transaction } from "~/db.server";
export class SetStatusService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(runId: string, id: string, status: StatusUpdate) {
const statusRecord = await $transaction(this.#prismaClient, async (tx) => {
const existingStatus = await tx.jobRunStatusRecord.findUnique({
where: {
runId_key: {
runId,
key: id,
},
},
});
const history: StatusHistory = [];
const historyResult = StatusHistorySchema.safeParse(existingStatus?.history);
if (historyResult.success) {
history.push(...historyResult.data);
}
if (existingStatus) {
history.push({
label: existingStatus.label,
state: (existingStatus.state ?? undefined) as StatusUpdateState,
data: (existingStatus.data ?? undefined) as StatusUpdateData,
});
}
const updatedStatus = await tx.jobRunStatusRecord.upsert({
where: {
runId_key: {
runId,
key: id,
},
},
create: {
key: id,
runId,
//this shouldn't ever use the id in reality, as the SDK makess it compulsory on the first call
label: status.label ?? id,
state: status.state,
data: status.data as any,
history: [],
},
update: {
label: status.label,
state: status.state,
data: status.data as any,
history: history as any[],
},
});
return updatedStatus;
});
return statusRecord;
}
}
@@ -1,18 +1,10 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
JobRunStatusRecordSchema,
StatusHistory,
StatusHistorySchema,
StatusUpdate,
StatusUpdateData,
StatusUpdateSchema,
StatusUpdateState,
} from "@trigger.dev/core";
import { JobRunStatusRecordSchema, StatusUpdateSchema } from "@trigger.dev/core";
import { z } from "zod";
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { SetStatusService } from "./SetStatusService.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -80,65 +72,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export class SetStatusService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(runId: string, id: string, status: StatusUpdate) {
const statusRecord = await $transaction(this.#prismaClient, async (tx) => {
const existingStatus = await tx.jobRunStatusRecord.findUnique({
where: {
runId_key: {
runId,
key: id,
},
},
});
const history: StatusHistory = [];
const historyResult = StatusHistorySchema.safeParse(existingStatus?.history);
if (historyResult.success) {
history.push(...historyResult.data);
}
if (existingStatus) {
history.push({
label: existingStatus.label,
state: (existingStatus.state ?? undefined) as StatusUpdateState,
data: (existingStatus.data ?? undefined) as StatusUpdateData,
});
}
const updatedStatus = await tx.jobRunStatusRecord.upsert({
where: {
runId_key: {
runId,
key: id,
},
},
create: {
key: id,
runId,
//this shouldn't ever use the id in reality, as the SDK makess it compulsory on the first call
label: status.label ?? id,
state: status.state,
data: status.data as any,
history: [],
},
update: {
label: status.label,
state: status.state,
data: status.data as any,
history: history as any[],
},
});
return updatedStatus;
});
return statusRecord;
}
}
@@ -1,3 +1,3 @@
import { action } from "./api.v1.tasks.$id.callback.$secret";
import { action } from "./api.v1.tasks.$id.callback.$secret/route";
export { action };
@@ -0,0 +1,92 @@
import type { CompleteTaskBodyOutput, ServerTask } from "@trigger.dev/core";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { taskWithAttemptsToServerTask } from "~/models/task.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
export class CompleteRunTaskService {
#prismaClient: PrismaClientOrTransaction;
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
environment: AuthenticatedEnvironment,
runId: string,
id: string,
taskBody: CompleteTaskBodyOutput
): Promise<ServerTask | undefined> {
const existingTask = await this.#prismaClient.task.findUnique({
where: {
id,
},
include: {
run: true,
attempts: {
where: {
status: "PENDING",
},
orderBy: {
number: "desc",
},
take: 1,
},
},
});
if (!existingTask) {
return;
}
if (existingTask.runId !== runId) {
return;
}
if (existingTask.run.environmentId !== environment.id) {
return;
}
if (
existingTask.status === "COMPLETED" ||
existingTask.status === "ERRORED" ||
existingTask.status === "CANCELED"
) {
logger.debug("Task already completed", {
existingTask,
});
return taskWithAttemptsToServerTask(existingTask);
}
if (existingTask.attempts.length === 1) {
await this.#prismaClient.taskAttempt.update({
where: {
id: existingTask.attempts[0].id,
},
data: {
status: "COMPLETED",
},
});
}
const updatedTask = await this.#prismaClient.task.update({
where: {
id,
},
data: {
status: "COMPLETED",
output: taskBody.output as any,
outputIsUndefined: typeof taskBody.output === "undefined",
completedAt: new Date(),
outputProperties: taskBody.properties,
},
include: {
attempts: true,
run: true,
},
});
return taskWithAttemptsToServerTask(updatedTask);
}
}
@@ -1,17 +1,16 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { CompleteTaskBodyOutput, ServerTask } from "@trigger.dev/core";
import type { CompleteTaskBodyOutput } from "@trigger.dev/core";
import {
API_VERSIONS,
CompleteTaskBodyInputSchema,
CompleteTaskBodyV2InputSchema,
} from "@trigger.dev/core";
import { z } from "zod";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { taskWithAttemptsToServerTask } from "~/models/task.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CompleteRunTaskService } from "./CompleteRunTaskService.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -118,90 +117,3 @@ async function completeRunTask(
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export class CompleteRunTaskService {
#prismaClient: PrismaClientOrTransaction;
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
environment: AuthenticatedEnvironment,
runId: string,
id: string,
taskBody: CompleteTaskBodyOutput
): Promise<ServerTask | undefined> {
const existingTask = await this.#prismaClient.task.findUnique({
where: {
id,
},
include: {
run: true,
attempts: {
where: {
status: "PENDING",
},
orderBy: {
number: "desc",
},
take: 1,
},
},
});
if (!existingTask) {
return;
}
if (existingTask.runId !== runId) {
return;
}
if (existingTask.run.environmentId !== environment.id) {
return;
}
if (
existingTask.status === "COMPLETED" ||
existingTask.status === "ERRORED" ||
existingTask.status === "CANCELED"
) {
logger.debug("Task already completed", {
existingTask,
});
return taskWithAttemptsToServerTask(existingTask);
}
if (existingTask.attempts.length === 1) {
await this.#prismaClient.taskAttempt.update({
where: {
id: existingTask.attempts[0].id,
},
data: {
status: "COMPLETED",
},
});
}
const updatedTask = await this.#prismaClient.task.update({
where: {
id,
},
data: {
status: "COMPLETED",
output: taskBody.output as any,
outputIsUndefined: typeof taskBody.output === "undefined",
completedAt: new Date(),
outputProperties: taskBody.properties,
},
include: {
attempts: true,
run: true,
},
});
return taskWithAttemptsToServerTask(updatedTask);
}
}
@@ -1,76 +1,11 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { FailTaskBodyInput, FailTaskBodyInputSchema, ServerTask } from "@trigger.dev/core";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { FailTaskBodyInput, ServerTask } from "@trigger.dev/core";
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { taskWithAttemptsToServerTask } from "~/models/task.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { formatError } from "~/utils/formatErrors.server";
const ParamsSchema = z.object({
runId: z.string(),
id: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const { runId, id } = ParamsSchema.parse(params);
// Now parse the request body
const anyBody = await request.json();
logger.debug("FailRunTaskService.call() request body", {
body: anyBody,
runId,
id,
});
const body = FailTaskBodyInputSchema.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new FailRunTaskService();
try {
const task = await service.call(authenticatedEnv, runId, id, body.data);
logger.debug("FailRunTaskService.call() response body", {
runId,
id,
task,
});
if (!task) {
return json({ message: "Task not found" }, { status: 404 });
}
return json(task);
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export class FailRunTaskService {
#prismaClient: PrismaClient;
@@ -0,0 +1,69 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { FailTaskBodyInputSchema } from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { FailRunTaskService } from "./FailRunTaskService.server";
const ParamsSchema = z.object({
runId: z.string(),
id: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const { runId, id } = ParamsSchema.parse(params);
// Now parse the request body
const anyBody = await request.json();
logger.debug("FailRunTaskService.call() request body", {
body: anyBody,
runId,
id,
});
const body = FailTaskBodyInputSchema.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new FailRunTaskService();
try {
const task = await service.call(authenticatedEnv, runId, id, body.data);
logger.debug("FailRunTaskService.call() response body", {
runId,
id,
task,
});
if (!task) {
return json({ message: "Task not found" }, { status: 404 });
}
return json(task);
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -0,0 +1,49 @@
import { ServerTask, RunTaskResponseWithCachedTasksBody } from "@trigger.dev/core";
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { prepareTasksForCaching } from "~/models/task.server";
export class ChangeRequestLazyLoadedCachedTasks {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
runId: string,
task: ServerTask,
cursor?: string | null
): Promise<RunTaskResponseWithCachedTasksBody> {
if (!cursor) {
return {
task,
};
}
// We need to limit the cached tasks to not be too large >2MB when serialized
const TOTAL_CACHED_TASK_BYTE_LIMIT = 2000000;
const nextTasks = await this.#prismaClient.task.findMany({
where: {
runId,
status: "COMPLETED",
noop: false,
},
take: 250,
cursor: {
id: cursor,
},
orderBy: {
id: "asc",
},
});
const preparedTasks = prepareTasksForCaching(nextTasks, TOTAL_CACHED_TASK_BYTE_LIMIT);
return {
task,
cachedTasks: preparedTasks,
};
}
}
@@ -1,17 +1,11 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
API_VERSIONS,
RunTaskBodyOutputSchema,
RunTaskResponseWithCachedTasksBody,
ServerTask,
} from "@trigger.dev/core";
import { API_VERSIONS, RunTaskBodyOutputSchema } from "@trigger.dev/core";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { prepareTasksForCaching } from "~/models/task.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { RunTaskService } from "~/services/tasks/runTask.server";
import { ChangeRequestLazyLoadedCachedTasks } from "./ChangeRequestLazyLoadedCachedTasks.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -111,48 +105,3 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Something went wrong" }, { status: 500 });
}
}
class ChangeRequestLazyLoadedCachedTasks {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
runId: string,
task: ServerTask,
cursor?: string | null
): Promise<RunTaskResponseWithCachedTasksBody> {
if (!cursor) {
return {
task,
};
}
// We need to limit the cached tasks to not be too large >2MB when serialized
const TOTAL_CACHED_TASK_BYTE_LIMIT = 2000000;
const nextTasks = await this.#prismaClient.task.findMany({
where: {
runId,
status: "COMPLETED",
noop: false,
},
take: 250,
cursor: {
id: cursor,
},
orderBy: {
id: "asc",
},
});
const preparedTasks = prepareTasksForCaching(nextTasks, TOTAL_CACHED_TASK_BYTE_LIMIT);
return {
task,
cachedTasks: preparedTasks,
};
}
}
@@ -0,0 +1,45 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { CreateTaskRunAttemptService } from "~/v3/services/createTaskRunAttempt.server";
const ParamsSchema = z.object({
/* This is the run friendly ID */
runParam: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return json({ error: "Invalid or missing run ID" }, { status: 400 });
}
const { runParam } = parsed.data;
const service = new CreateTaskRunAttemptService();
try {
const { execution } = await service.call(runParam, authenticationResult.environment);
return json(execution, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
}
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
@@ -1,44 +1,8 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { ResumeTaskService } from "~/services/tasks/resumeTask.server";
import { workerQueue } from "~/services/worker.server";
const ParamsSchema = z.object({
id: z.string(),
secret: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const { id } = ParamsSchema.parse(params);
// Parse body as JSON (no schema parsing)
const body = await request.json();
const service = new CallbackRunTaskService();
try {
// Complete task with request body as output
await service.call(id, body, request.url);
return json({ success: true });
} catch (error) {
if (error instanceof Error) {
logger.error("Error while processing task callback:", { error });
return json({ error: `Something went wrong: ${error.message}` }, { status: 500 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
export class CallbackRunTaskService {
#prismaClient: PrismaClient;
@@ -0,0 +1,38 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { CallbackRunTaskService } from "./CallbackRunTaskService.server";
const ParamsSchema = z.object({
id: z.string(),
secret: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const { id } = ParamsSchema.parse(params);
// Parse body as JSON (no schema parsing)
const body = await request.json();
const service = new CallbackRunTaskService();
try {
// Complete task with request body as output
await service.call(id, body, request.url);
return json({ success: true });
} catch (error) {
if (error instanceof Error) {
logger.error("Error while processing task callback:", { error });
return json({ error: `Something went wrong: ${error.message}` }, { status: 500 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -11,9 +11,10 @@ const ParamsSchema = z.object({
});
export const HeadersSchema = z.object({
"idempotency-key": z.string().optional().nullable(),
"trigger-version": z.string().optional().nullable(),
"x-trigger-span-parent-as-link": z.coerce.number().optional().nullable(),
"idempotency-key": z.string().nullish(),
"trigger-version": z.string().nullish(),
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
"x-trigger-worker": z.string().nullish(),
traceparent: z.string().optional(),
tracestate: z.string().optional(),
});
@@ -45,6 +46,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
tracestate,
"x-trigger-worker": isFromWorker,
} = headers.data;
const { taskId } = ParamsSchema.parse(params);
@@ -58,20 +60,31 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Invalid request body" }, { status: 400 });
}
logger.debug("Triggering task", {
taskId,
idempotencyKey,
triggerVersion,
body: body.data,
});
const service = new TriggerTaskService();
try {
const traceContext = traceparent
? !triggerVersion // If the trigger version is NOT set, we are in an older version of the SDK
? { traceparent, tracestate }
: isFromWorker // If the trigger version is set, and the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined
: undefined;
logger.debug("Triggering task", {
taskId,
idempotencyKey,
triggerVersion,
headers: Object.fromEntries(request.headers),
body: body.data,
isFromWorker,
traceContext,
});
const run = await service.call(taskId, authenticationResult.environment, body.data, {
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext: traceparent ? { traceparent, tracestate } : undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
});
+27 -1
View File
@@ -1,10 +1,36 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect, type ActionFunction, type LoaderFunction } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
getCurrentProjectId,
} from "~/services/currentProject.server";
import { logoutPath } from "~/utils/pathBuilder";
export const action: ActionFunction = async ({ request }) => {
const projectId = await getCurrentProjectId(request);
if (projectId) {
const removeProjectIdSession = await clearCurrentProjectId(request);
return redirect(logoutPath(), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
},
});
}
return await authenticator.logout(request, { redirectTo: "/" });
};
export const loader: LoaderFunction = async ({ request }) => {
const projectId = await getCurrentProjectId(request);
if (projectId) {
const removeProjectIdSession = await clearCurrentProjectId(request);
return redirect(logoutPath(), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
},
});
}
return await authenticator.logout(request, { redirectTo: "/" });
};
@@ -1,57 +1,9 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { TaskQueue } from "@trigger.dev/database";
import { Gauge, Registry } from "prom-client";
import { z } from "zod";
import { prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { marqs } from "~/v3/marqs/index.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const validatedParams = ParamsSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
externalRef: validatedParams.projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
include: {
organization: true,
},
});
if (!project) {
return new Response("Not found", { status: 404 });
}
const registry = new Registry();
// Return prometheus metrics for the project (queues)
await registerProjectMetrics(registry, project.id, authenticationResult.userId);
return new Response(await registry.metrics(), {
headers: {
"Content-Type": registry.contentType,
},
});
}
export async function registerProjectMetrics(
registry: Registry,
projectId: string,
@@ -0,0 +1,51 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { Registry } from "prom-client";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { registerProjectMetrics } from "./registerProjectMetrics.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const validatedParams = ParamsSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
externalRef: validatedParams.projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
include: {
organization: true,
},
});
if (!project) {
return new Response("Not found", { status: 404 });
}
const registry = new Registry();
// Return prometheus metrics for the project (queues)
await registerProjectMetrics(registry, project.id, authenticationResult.userId);
return new Response(await registry.metrics(), {
headers: {
"Content-Type": registry.contentType,
},
});
}
@@ -11,7 +11,7 @@ import {
environmentTitle,
} from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { DateTime } from "~/components/primitives/DateTime";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormError } from "~/components/primitives/FormError";
@@ -34,7 +34,7 @@ import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { EditableScheduleElements } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
@@ -92,9 +92,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"
);
} catch (error: any) {
submission.error.taskIdentifier =
error instanceof Error ? error.message : JSON.stringify(error);
return json(submission, { status: 400 });
const errorMessage = `Failed: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`;
return redirectWithErrorMessage(
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
request,
errorMessage
);
}
};
@@ -265,7 +270,7 @@ export function UpsertScheduleForm({
<Label>Environments</Label>
<div className="flex flex-wrap items-center gap-2">
{possibleEnvironments.map((environment) => (
<Checkbox
<CheckboxWithLabel
key={environment.id}
id={environment.id}
value={environment.id}
@@ -4,11 +4,11 @@ import {
QueueListIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { useFetcher, useParams } from "@remix-run/react";
import { useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
import { useEffect } from "react";
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
@@ -46,6 +46,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
projectSlug: projectParam,
spanId: spanParam,
runFriendlyId: runParam,
});
if (!span) {
@@ -0,0 +1,210 @@
import { Form } from "@remix-run/react";
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { PlainClient } from "@team-plain/typescript-sdk";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { organizationPath } from "~/utils/pathBuilder";
import v3Icon from "~/assets/icons/v3.svg";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
const ParamSchema = z.object({
organizationSlug: z.string(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
if (request.method.toLowerCase() !== "post") {
return redirectWithErrorMessage("/", request, "Invalid request method");
}
const user = await requireUser(request);
const { organizationSlug } = ParamSchema.parse(params);
const failedRedirectPath = organizationPath({ slug: organizationSlug });
try {
if (!env.PLAIN_API_KEY) {
return redirectWithErrorMessage(
failedRedirectPath,
request,
"Error requesting V3 access: Plain API key"
);
}
//mark them as having requested v3
const organization = await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
data: {
hasRequestedV3: true,
},
});
//update Plain
const client = new PlainClient({
apiKey: env.PLAIN_API_KEY,
});
const upsertCustomerRes = await client.upsertCustomer({
identifier: {
emailAddress: user.email,
},
onCreate: {
fullName: user.name ?? user.email,
email: {
email: user.email,
isVerified: true,
},
},
onUpdate: {},
});
if (upsertCustomerRes.error) {
logger.error("Error upserting customer", upsertCustomerRes.error);
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
const groupResult = await client.addCustomerToCustomerGroups({
customerId: upsertCustomerRes.data.customer.id,
customerGroupIdentifiers: [
{
customerGroupKey: "interested-in-v3",
},
],
});
if (groupResult.error) {
logger.error("Error adding customer to group", groupResult.error);
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
const createThreadRes = await client.createThread({
customerIdentifier: {
customerId: upsertCustomerRes.data.customer.id,
},
title: "v3 early access request",
components: [
{
componentText: {
text: `${upsertCustomerRes.data.customer.email.email} has been added to the v3 early access group`,
},
},
{
componentText: {
text: `Company: ${organization.title ?? ""}`,
},
},
],
});
if (createThreadRes.error) {
logger.error("Error creating thread", createThreadRes.error);
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
return redirectWithSuccessMessage(
organizationPath(organization),
request,
"V3 access requested"
);
} catch (error) {
logger.error("Error requesting V3 access", { error });
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
};
export function RequestV3Access({
hasRequestedV3,
organizationSlug,
projectsCount,
}: {
hasRequestedV3: boolean;
organizationSlug: string;
projectsCount: number;
}) {
const user = useUser();
if (hasRequestedV3) {
return (
<MainCenteredContainer>
<div>
<div className="relative mb-4 flex size-9">
<img src={v3Icon} alt="v3" width={32} height={32} />
<div className="absolute right-0 top-0 size-4 rounded-full bg-background-dimmed">
<CheckCircleIcon className="size-4 text-success" />
</div>
</div>
<Paragraph spacing variant="base/bright">
Weve received your request for v3 and well notify you as soon as you have access.
Were granting new users access every day so you wont be waiting long.
</Paragraph>
<Paragraph spacing variant="base/bright">
Right now v3 is completely free to use but{" "}
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/#cloud-pricing">
paid tiers
</TextLink>{" "}
will be introduced soon.
</Paragraph>
<Paragraph spacing variant="base/bright">
In the meantime, check out the{" "}
<TextLink href="https://trigger.dev/docs">v3 docs</TextLink>, the{" "}
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/">
v3 blog post
</TextLink>{" "}
and <TextLink href="https://trigger.dev/discord">join our Discord</TextLink>.
</Paragraph>
</div>
</MainCenteredContainer>
);
}
return (
<MainCenteredContainer>
<img src={v3Icon} alt="v3" width={32} height={32} className="mb-4" />
<Form action={`/resources/orgs/${organizationSlug}/v3-access`} method="post">
{projectsCount > 0 ? (
<Paragraph spacing variant="base/bright">
You can no longer create v2 projects and your organization doesn't have access to v3
yet. We are approving access requests daily.
</Paragraph>
) : (
<Paragraph spacing variant="base/bright">
Trigger.dev v3 is currently in Developer Preview and were operating a waitlist as we
focus on the platforms reliability and scaleability.
</Paragraph>
)}
<Paragraph spacing variant="base/bright">
For more info, check out our{" "}
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/">
v3 blog post
</TextLink>
.
</Paragraph>
<div className="mt-2 flex items-center justify-between gap-3">
{projectsCount > 0 ? (
<LinkButton variant="tertiary/small" to={organizationPath({ slug: organizationSlug })}>
Cancel
</LinkButton>
) : null}
<Button variant="primary/small" type="submit">
Request access
</Button>
</div>
</Form>
</MainCenteredContainer>
);
}
@@ -0,0 +1,93 @@
import { parse } from "@conform-to/zod";
import { ActionFunctionArgs } from "@remix-run/router";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { v3RunsPath } from "~/utils/pathBuilder";
import { CreateBulkActionService } from "~/v3/services/bulk/createBulkAction.server";
const FormSchema = z.object({
organizationSlug: z.string(),
projectSlug: z.string(),
failedRedirect: z.string(),
runIds: z.array(z.string()).or(z.string()),
});
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
if (request.method.toLowerCase() !== "post") {
return redirectWithErrorMessage("/", request, "Invalid method");
}
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value) {
logger.error("Failed to parse resources/taskruns/bulk/cancel form data", { submission });
return redirectWithErrorMessage("/", request, "Failed to parse form data");
}
try {
const project = await prisma.project.findUnique({
where: {
slug: submission.value.projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Project not found"
);
}
const service = new CreateBulkActionService();
const result = await service.call({
projectId: project.id,
action: "CANCEL",
runIds:
typeof submission.value.runIds === "string"
? [submission.value.runIds]
: submission.value.runIds,
});
const path = v3RunsPath(
{ slug: submission.value.organizationSlug },
{ slug: project.slug },
{
bulkId: result.friendlyId,
}
);
return redirectWithSuccessMessage(path, request, result.message);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to cancel runs", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
} else {
logger.error("Failed to cancel runs", { error });
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
JSON.stringify(error)
);
}
}
}

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