Compare commits

...

61 Commits

Author SHA1 Message Date
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
Eric Allam cb81d6c835 Release 3.0.0-beta.32 2024-05-15 16:23:06 +01:00
github-actions[bot] 72748603ab chore: Update version for release (beta) (#1102)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-15 16:21:58 +01:00
Eric Allam f96f1e91a2 Adding changeset 2024-05-15 13:49:11 +01:00
Eric Allam 21c8b847e5 Better handle resolving dependencies when there are conflicts 2024-05-15 13:26:03 +01:00
Matt Aitken b39f79f0ab Use an env var for the Graphile cleanup ttl, set the default to 3 days (was 7) 2024-05-14 19:07:50 +01:00
Eric Allam 90b8797fbb Release 3.0.0-beta.31 2024-05-13 16:15:00 -04:00
github-actions[bot] 107f2e7bd5 chore: Update version for release (beta) (#1099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-13 16:14:14 -04:00
Eric Allam b8477ea2b0 Fixes an issue with scoped packages in additionalPackages option 2024-05-13 15:31:19 -04:00
312 changed files with 9222 additions and 3135 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fixes an issue with scoped packages in additionalPackages option
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fixing missing logs when importing client @opentelemetry/api
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Better handle issues with resolving dependency versions during deploy
+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`
+11 -1
View File
@@ -44,6 +44,7 @@
"@trigger.dev/yalt": "2.3.18"
},
"changesets": [
"afraid-sheep-joke",
"angry-eagles-trade",
"beige-pens-dance",
"big-tomatoes-deliver",
@@ -54,16 +55,20 @@
"clever-apes-collect",
"clever-carrots-travel",
"clever-donkeys-hunt",
"cool-comics-burn",
"cool-glasses-bake",
"cuddly-feet-approve",
"dry-walls-check",
"dull-mangos-press",
"eight-pumas-float",
"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",
@@ -107,7 +112,9 @@
"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",
@@ -116,12 +123,15 @@
"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-ladybugs-unite",
"two-pumas-wait"
"two-pumas-wait",
"warm-planes-taste"
]
}
+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
+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.
+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>
);
}
+1
View File
@@ -12,3 +12,4 @@ 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;
+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";
@@ -47,6 +47,8 @@ const EnvironmentSchema = z.object({
WORKER_SCHEMA: z.string().default("graphile_worker"),
WORKER_CONCURRENCY: z.coerce.number().int().default(10),
WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
/** The number of days a failed Graphile task should stay before getting cleaned up */
WORKER_CLEANUP_TTL_DAYS: z.coerce.number().int().default(3),
EXECUTION_WORKER_CONCURRENCY: z.coerce.number().int().default(10),
EXECUTION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
WORKER_ENABLED: z.string().default("true"),
@@ -156,6 +158,12 @@ 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),
});
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,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,
};
}
}
@@ -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)
);
}
}
}
@@ -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/replay 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: "REPLAY",
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 replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
} else {
logger.error("Failed to replay run", { error });
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
JSON.stringify(error)
);
}
}
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Button } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
export default function Story() {
const [isDisabled, setIsDisabled] = useState(false);
@@ -14,28 +14,28 @@ export default function Story() {
>
{isDisabled ? "Enable checkboxes" : "Disable checkboxes"}
</Button>
<Checkbox
<CheckboxWithLabel
name="Simple checkbox"
id="check1"
variant="simple/small"
label="This is a simple small checkbox"
disabled={isDisabled}
/>
<Checkbox
<CheckboxWithLabel
name="Simple checkbox"
id="check1"
variant="simple"
label="This is a simple checkbox"
disabled={isDisabled}
/>
<Checkbox
<CheckboxWithLabel
name="Button checkbox"
id="check2"
variant="button"
label="This is a button checkbox"
disabled={isDisabled}
/>
<Checkbox
<CheckboxWithLabel
name="Button checkbox"
id="check2"
variant="button"
@@ -43,7 +43,7 @@ export default function Story() {
badges={["This is a badge"]}
disabled={isDisabled}
/>
<Checkbox
<CheckboxWithLabel
name="Button checkbox"
id="check2"
variant="button"
@@ -52,7 +52,7 @@ export default function Story() {
disabled={isDisabled}
/>
<div className="flex flex-col gap-y-0.5 overflow-hidden rounded-md">
<Checkbox
<CheckboxWithLabel
name="Description checkbox"
id="check3"
variant="description"
@@ -61,7 +61,7 @@ export default function Story() {
description="This is a long checkbox description that goes full width. Grants full access to public and private repositories including read and write access to code, commit statuses, repository invitations, collaborators, deployment statuses, and repository webhooks. Note: In addition to repository related resources, the repo scope also grants access to manage organization-owned resources including projects, invitations, team memberships and webhooks. This scope also grants the ability to manage projects owned by users."
disabled={isDisabled}
/>
<Checkbox
<CheckboxWithLabel
name="Description checkbox"
id="check4"
variant="description"
+117
View File
@@ -4,6 +4,14 @@ import {
findEnvironmentByApiKey,
findEnvironmentByPublicApiKey,
} from "~/models/runtimeEnvironment.server";
import {
PersonalAccessTokenAuthenticationResult,
authenticateApiRequestWithPersonalAccessToken,
isPersonalAccessToken,
} from "./personalAccessToken.server";
import { prisma } from "~/db.server";
import { json } from "@remix-run/server-runtime";
import { findProjectByRef } from "~/models/project.server";
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
@@ -92,3 +100,112 @@ export function getApiKeyResult(apiKey: string) {
const type = isPublicApiKey(apiKey) ? ("PUBLIC" as const) : ("PRIVATE" as const);
return { apiKey, type };
}
export type DualAuthenticationResult =
| {
type: "personalAccessToken";
result: PersonalAccessTokenAuthenticationResult;
}
| {
type: "apiKey";
result: ApiAuthenticationResult;
};
export async function authenticateProjectApiKeyOrPersonalAccessToken(
request: Request
): Promise<DualAuthenticationResult | undefined> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
if (isPersonalAccessToken(apiKey)) {
const result = await authenticateApiRequestWithPersonalAccessToken(request);
if (!result) {
return;
}
return {
type: "personalAccessToken",
result,
};
}
const result = await authenticateApiKey(apiKey, { allowPublicKey: false });
if (!result) {
return;
}
return {
type: "apiKey",
result,
};
}
export async function authenticatedEnvironmentForAuthentication(
auth: DualAuthenticationResult,
projectRef: string,
slug: string
): Promise<AuthenticatedEnvironment> {
switch (auth.type) {
case "apiKey": {
if (auth.result.environment.project.externalRef !== projectRef) {
throw json(
{
error:
"Invalid project ref for this API key. Make sure you are using an API key associated with that project.",
},
{ status: 400 }
);
}
if (auth.result.environment.slug !== slug) {
throw json(
{
error:
"Invalid environment slug for this API key. Make sure you are using an API key associated with that environment.",
},
{ status: 400 }
);
}
return auth.result.environment;
}
case "personalAccessToken": {
const user = await prisma.user.findUnique({
where: {
id: auth.result.userId,
},
});
if (!user) {
throw json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const project = await findProjectByRef(projectRef, user.id);
if (!project) {
throw json({ error: "Project not found" }, { status: 404 });
}
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
},
include: {
project: true,
organization: true,
},
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return environment;
}
}
}
@@ -157,16 +157,18 @@ export function authorizationRateLimitMiddleware({
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry after ${reset} seconds.`,
reset: reset,
limit: limit,
error: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry after ${reset} seconds.`,
detail: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
secondsUntilReset,
error: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
@@ -191,6 +193,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
"/api/internal/stripe_webhooks",
"/api/v1/authorization-code",
"/api/v1/token",
/^\/api\/v1\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/tasks/$id/callback/$secret
+15
View File
@@ -20,6 +20,17 @@ const client = singleton(
})
);
const alertsClient = singleton(
"alerts-email-client",
() =>
new EmailClient({
apikey: env.ALERT_RESEND_API_KEY,
imagesBaseUrl: env.APP_ORIGIN,
from: env.ALERT_FROM_EMAIL ?? "noreply@alerts.trigger.dev",
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
})
);
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
// Auto redirect when in development mode
if (env.NODE_ENV === "development") {
@@ -67,3 +78,7 @@ export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: numbe
export async function sendEmail(data: DeliverEmail) {
return client.send(data);
}
export async function sendAlertEmail(data: DeliverEmail) {
return alertsClient.send(data);
}
@@ -4,7 +4,7 @@ import { AuthenticatedEnvironment } from "../apiAuth.server";
import { EndpointApi } from "../endpointApi.server";
import { workerQueue } from "../worker.server";
import { env } from "~/env.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { RuntimeEnvironmentType } from "~/database-types";
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
@@ -14,6 +14,9 @@ import { IndexEndpointStats } from "@trigger.dev/core";
import { RegisterHttpEndpointService } from "../triggers/registerHttpEndpoint.server";
import { RegisterWebhookService } from "../triggers/registerWebhook.server";
import { EndpointIndex } from "@trigger.dev/database";
import { env } from "~/env.server";
const MAX_SEQUENTIAL_FAILURE_COUNT = env.MAX_SEQUENTIAL_INDEX_FAILURE_COUNT;
export class PerformEndpointIndexService {
#prismaClient: PrismaClient;
@@ -56,9 +59,16 @@ export class PerformEndpointIndexService {
if (!endpointIndex.endpoint.url) {
logger.debug("Endpoint URL is not set", endpointIndex);
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: "Endpoint URL is not set",
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: "Endpoint URL is not set",
},
false
);
}
// Make a request to the endpoint to fetch a list of jobs
@@ -69,9 +79,15 @@ export class PerformEndpointIndexService {
const { response, parser, headerParser, errorParser } = await client.indexEndpoint();
if (!response) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Could not connect to endpoint ${endpointIndex.endpoint.url}`,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: `Could not connect to endpoint ${endpointIndex.endpoint.url}`,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
if (isRedirect(response.status)) {
@@ -83,15 +99,27 @@ export class PerformEndpointIndexService {
const location = response.headers.get("location");
if (!location) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting but no location header is present`,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting but no location header is present`,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
if (redirectCount > 5) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting too many times`,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting too many times`,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
await this.#prismaClient.endpoint.update({
@@ -111,20 +139,38 @@ export class PerformEndpointIndexService {
const body = await safeBodyFromResponse(response, errorParser);
if (body) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: body.message,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: body.message,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: "Trigger API key is invalid",
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: "Trigger API key is invalid",
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
if (!response.ok) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Could not connect to endpoint ${endpointIndex.endpoint.url}. Status code: ${response.status}`,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: `Could not connect to endpoint ${endpointIndex.endpoint.url}. Status code: ${response.status}`,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
const anyBody = await response.json();
@@ -152,10 +198,16 @@ export class PerformEndpointIndexService {
}).message;
}
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: friendlyError,
raw: fromZodError(bodyResult.error).message,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: friendlyError,
raw: fromZodError(bodyResult.error).message,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
const headerResult = headerParser.safeParse(Object.fromEntries(response.headers.entries()));
@@ -163,10 +215,16 @@ export class PerformEndpointIndexService {
const friendlyError = fromZodError(headerResult.error, {
prefix: "Your headers are invalid",
});
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: friendlyError.message,
raw: headerResult.error.issues,
});
return updateEndpointIndexWithError(
this.#prismaClient,
id,
endpointIndex.endpoint.id,
{
message: friendlyError.message,
raw: headerResult.error.issues,
},
endpointIndex.endpoint.environment.type !== "DEVELOPMENT"
);
}
const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints, webhooks } =
@@ -407,8 +465,49 @@ export class PerformEndpointIndexService {
async function updateEndpointIndexWithError(
prismaClient: PrismaClient,
id: string,
error: EndpointIndexError
endpointId: string,
error: EndpointIndexError,
checkDisabling = true
) {
// Check here to see if this endpoint has only failed for the last 50 times
// And if so, we disable the endpoint by setting the url to null
if (checkDisabling) {
const recentIndexes = await prismaClient.endpointIndex.findMany({
where: {
endpointId,
id: {
not: id,
},
},
orderBy: {
createdAt: "desc",
},
take: MAX_SEQUENTIAL_FAILURE_COUNT - 1,
select: {
status: true,
},
});
if (
recentIndexes.length === MAX_SEQUENTIAL_FAILURE_COUNT - 1 &&
recentIndexes.every((index) => index.status === "FAILURE")
) {
logger.debug("Disabling endpoint", {
endpointId,
error,
});
await prismaClient.endpoint.update({
where: {
id: endpointId,
},
data: {
url: null,
},
});
}
}
return await prismaClient.endpointIndex.update({
where: {
id,
@@ -5,7 +5,7 @@ import { AuthenticatedEnvironment } from "../apiAuth.server";
import { workerQueue } from "../worker.server";
import { CreateEndpointError } from "./createEndpoint.server";
import { EndpointApi } from "../endpointApi.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { RuntimeEnvironmentType } from "~/database-types";
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
@@ -1,11 +1,14 @@
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { JobRunStatus } from "@trigger.dev/database";
import type { PrismaClient } from "~/db.server";
import { $transaction, prisma } from "~/db.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { CancelRunService } from "../runs/cancelRun.server";
import { logger } from "../logger.server";
import { CancelRunsForEvent } from "@trigger.dev/core";
import type { CancelRunsForEvent } from "@trigger.dev/core";
import type { JobRunStatus as JobRunStatusType } from "@trigger.dev/database";
import { JobRunStatus } from "~/database-types";
const CANCELLABLE_JOB_RUN_STATUS: JobRunStatus[] = [
const CANCELLABLE_JOB_RUN_STATUS: Array<JobRunStatusType> = [
JobRunStatus.PENDING,
JobRunStatus.QUEUED,
JobRunStatus.WAITING_ON_CONNECTIONS,
@@ -1,11 +1,11 @@
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { JobRunStatus } from "@trigger.dev/database";
import { CancelRunService } from "../runs/cancelRun.server";
import { logger } from "../logger.server";
import { CancelRunsForJob } from "@trigger.dev/core";
import { JobRunStatus } from "~/database-types";
const CANCELLABLE_JOB_RUN_STATUS: JobRunStatus[] = [
const CANCELLABLE_JOB_RUN_STATUS: Array<keyof typeof JobRunStatus> = [
JobRunStatus.PENDING,
JobRunStatus.QUEUED,
JobRunStatus.WAITING_ON_CONNECTIONS,
@@ -90,7 +90,7 @@ export async function revokePersonalAccessToken(tokenId: string) {
});
}
type PersonalAccessTokenAuthenticationResult = {
export type PersonalAccessTokenAuthenticationResult = {
userId: string;
};
@@ -169,6 +169,10 @@ export async function authenticatePersonalAccessToken(
};
}
export function isPersonalAccessToken(token: string) {
return token.startsWith(tokenPrefix);
}
export function createAuthorizationCode() {
return prisma.authorizationCode.create({
data: {
@@ -34,7 +34,7 @@ import { detectResponseIsTimeout } from "~/models/endpoint.server";
import { isRunCompleted } from "~/models/jobRun.server";
import { resolveRunConnections } from "~/models/runConnection.server";
import { prepareTasksForCaching, prepareTasksForCachingLegacy } from "~/models/task.server";
import { CompleteRunTaskService } from "~/routes/api.v1.runs.$runId.tasks.$id.complete";
import { CompleteRunTaskService } from "~/routes/api.v1.runs.$runId.tasks.$id.complete/CompleteRunTaskService.server";
import { formatError } from "~/utils/formatErrors.server";
import { safeJsonZodParse } from "~/utils/json";
import { EndpointApi } from "../endpointApi.server";
@@ -441,6 +441,10 @@ export class PerformRunExecutionV3Service {
await this.#resumeAutoYieldedRunWithCompletedTask(run, safeBody.data, durationInMs);
break;
}
case "AUTO_YIELD_RATE_LIMIT": {
await this.#rescheduleRun(run, safeBody.data.reset, durationInMs);
break;
}
case "RESUME_WITH_PARALLEL_TASK": {
await this.#resumeParallelRunWithTask(run, safeBody.data, durationInMs);
@@ -667,6 +671,10 @@ export class PerformRunExecutionV3Service {
break;
}
case "AUTO_YIELD_RATE_LIMIT": {
await this.#rescheduleRun(run, childError.reset, durationInMs);
break;
}
case "CANCELED": {
break;
}
@@ -801,9 +809,9 @@ export class PerformRunExecutionV3Service {
});
}
async #resumeAutoYieldedRun(
async #rescheduleRun(
run: FoundRun,
data: AutoYieldMetadata,
reset: number,
durationInMs: number,
executionCount: number = 1
) {
@@ -820,16 +828,6 @@ export class PerformRunExecutionV3Service {
executionCount: {
increment: executionCount,
},
autoYieldExecution: {
create: [
{
location: data.location,
timeRemaining: data.timeRemaining,
timeElapsed: data.timeElapsed,
limit: data.limit ?? 0,
},
],
},
forceYieldImmediately: false,
},
select: {
@@ -837,7 +835,7 @@ export class PerformRunExecutionV3Service {
},
});
await ResumeRunService.enqueue(run, tx);
await ResumeRunService.enqueue(run, tx, new Date(reset));
});
}
@@ -888,6 +886,46 @@ export class PerformRunExecutionV3Service {
});
}
async #resumeAutoYieldedRun(
run: FoundRun,
data: AutoYieldMetadata,
durationInMs: number,
executionCount: number = 1
) {
await $transaction(this.#prismaClient, async (tx) => {
await tx.jobRun.update({
where: {
id: run.id,
},
data: {
status: "WAITING_TO_EXECUTE",
executionDuration: {
increment: durationInMs,
},
executionCount: {
increment: executionCount,
},
autoYieldExecution: {
create: [
{
location: data.location,
timeRemaining: data.timeRemaining,
timeElapsed: data.timeElapsed,
limit: data.limit ?? 0,
},
],
},
forceYieldImmediately: false,
},
select: {
executionCount: true,
},
});
await ResumeRunService.enqueue(run, tx);
});
}
async #retryRunWithTask(
run: FoundRun,
data: RunJobRetryWithTask,
@@ -4,6 +4,9 @@ import { NextScheduledEventService } from "./nextScheduledEvent.server";
import { IngestSendEvent } from "../events/ingestSendEvent.server";
import { InvokeDispatcherService } from "../events/invokeDispatcher.server";
import { logger } from "../logger.server";
import { workerQueue } from "../worker.server";
const DEFERRED_ENQUEUE_THRESHOLD_IN_SECONDS = 60 * 60 * 2 - 1; // 2 hours - 1 second
export class DeliverScheduledEventService {
#prismaClient: PrismaClientOrTransaction;
@@ -12,6 +15,132 @@ export class DeliverScheduledEventService {
this.#prismaClient = prismaClient;
}
// This runs every 10 minutes
public static async scheduleImminentDeferredEvents() {
// Find all deferred events that are due to be enqueued in the next hour
const deferredEvents = await prisma.deferredScheduledEventService.findMany({
where: {
runAt: {
lte: new Date(Date.now() + (DEFERRED_ENQUEUE_THRESHOLD_IN_SECONDS / 2) * 1000),
},
},
});
for (const deferredEvent of deferredEvents) {
logger.debug("Enqueuing deferred scheduled event", {
scheduleSourceId: deferredEvent.scheduleSourceId,
runAt: deferredEvent.runAt,
});
try {
await DeliverScheduledEventService.enqueue(
deferredEvent.scheduleSourceId,
deferredEvent.runAt,
{
ts: deferredEvent.runAt,
lastTimestamp: deferredEvent.lastTimestamp ?? undefined,
}
);
} catch (error) {
logger.error("Error enqueuing deferred scheduled event", {
scheduleSourceId: deferredEvent.scheduleSourceId,
runAt: deferredEvent.runAt,
error,
});
}
}
}
public static async dequeue(id: string, tx: PrismaClientOrTransaction = prisma) {
await tx.deferredScheduledEventService.deleteMany({
where: {
scheduleSourceId: id,
},
});
await workerQueue.dequeue(`scheduled:${id}`);
await tx.scheduleSource.update({
where: {
id,
},
data: {
workerJobId: null,
},
});
}
public static async enqueue(
id: string,
runAt: Date,
payload: ScheduledPayload,
tx: PrismaClientOrTransaction = prisma
) {
if (runAt.getTime() - Date.now() > DEFERRED_ENQUEUE_THRESHOLD_IN_SECONDS * 1000) {
logger.debug("Deferring enqueueing events.deliverScheduled", {
id,
runAt,
payload,
});
await tx.deferredScheduledEventService.upsert({
where: {
scheduleSourceId: id,
},
create: {
scheduleSourceId: id,
runAt,
lastTimestamp: payload.lastTimestamp,
},
update: {
runAt,
lastTimestamp: payload.lastTimestamp,
},
});
await tx.scheduleSource.update({
where: {
id,
},
data: {
workerJobId: null,
nextEventTimestamp: runAt,
},
});
await workerQueue.dequeue(`scheduled:${id}`);
} else {
await tx.deferredScheduledEventService.deleteMany({
where: {
scheduleSourceId: id,
},
});
const workerJob = await workerQueue.enqueue(
"events.deliverScheduled",
{
id: id,
payload,
},
{
runAt,
tx,
jobKey: `scheduled:${id}`,
}
);
await tx.scheduleSource.update({
where: {
id,
},
data: {
workerJobId: workerJob.id,
nextEventTimestamp: runAt,
},
});
}
}
public async call(id: string, payload: ScheduledPayload) {
return await $transaction(
this.#prismaClient,

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