Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bd1ca8b66 | |||
| 7f5ce165a0 | |||
| 7df1b90928 | |||
| 57a7e00bfa | |||
| 7362feed71 | |||
| b1b9321ad2 | |||
| a69f756e34 | |||
| 0f6e580641 | |||
| 796f1209f2 | |||
| 4ce96b7d28 | |||
| b86ffa0d3d | |||
| 3ee7cd6ff6 | |||
| 74686c00cb | |||
| 8f3e550d03 | |||
| 591422b8cf | |||
| bbaa6ba156 | |||
| e20fa3c2ec | |||
| 57514f8771 | |||
| 83bcefe10c | |||
| 4ca9f182d7 | |||
| 5dec3ed4e6 | |||
| 955961eb5f | |||
| 33184a8130 | |||
| 5611768854 | |||
| d0e6442872 | |||
| 1e374d30d0 | |||
| 9f649d2a27 | |||
| 0a20b6e05a | |||
| 78e2d0e9f9 | |||
| 12de1b0d34 | |||
| b62df731fc | |||
| e347389b64 | |||
| 10f92a6124 | |||
| b957aa1752 | |||
| a998d6c505 | |||
| 5f739ee312 | |||
| fccba7db98 | |||
| 0f7b7ff6d6 | |||
| 603ffbc426 | |||
| beef7769c6 | |||
| e9088cf08c | |||
| 390bed0b9a | |||
| e3e3dab525 | |||
| b884a0ab53 | |||
| 871d6e1d52 | |||
| 7daa82fc7a | |||
| 9dd7fb2748 | |||
| c246578a81 | |||
| d395b95762 | |||
| f3bda59617 | |||
| ef8b52c20f | |||
| 2bedc779b9 | |||
| a5b5912270 | |||
| 275318d2df | |||
| 8d48ebdee8 | |||
| 7ef0fe79b5 | |||
| 921957958e | |||
| 73721fc55c | |||
| 4fcec3bd0d | |||
| 7f4b6b9974 | |||
| 950ab24e7f | |||
| c4da1b4a9a | |||
| 24debf0cee | |||
| 645643ea41 | |||
| 321378db4f | |||
| c8b10ce188 | |||
| 57ed2b2d0d | |||
| fa3a22eb78 | |||
| a1bc15d18b | |||
| 3f48dd0a68 | |||
| 5c71868a73 | |||
| 3ce6decef4 | |||
| ab007f2708 | |||
| 8412680863 | |||
| a90908df6e | |||
| a82b86210f | |||
| 0367a92731 | |||
| d230cf622f | |||
| a1bf073b43 | |||
| 5588108b97 | |||
| b9bcecffe3 | |||
| 59075f5fc2 | |||
| fa942fc9f5 | |||
| 47a8a557cf | |||
| b51d0b5399 | |||
| 763c84e3b3 | |||
| dfb00e84e9 | |||
| b5a64545bb | |||
| 3cf3eaff48 | |||
| 4ca758a3de | |||
| da81537009 | |||
| 7c1b13ba3b | |||
| 4b654e5b3b | |||
| 726a50ace7 | |||
| 9deffb67c4 | |||
| ff04bf44ee | |||
| e740297829 | |||
| a31705e198 | |||
| f0bdf53364 | |||
| 9638499163 | |||
| 9104c252b7 | |||
| 72cf345602 | |||
| d5b8f8299d | |||
| dd22b88d25 | |||
| 62b9c5879c | |||
| 1b0973fbc1 | |||
| fc78854ed4 | |||
| ed15bb0618 | |||
| ced9034428 | |||
| 7ec329d5d6 | |||
| 81180999de | |||
| feaa79ecf3 | |||
| d34dc0847c | |||
| 1b7c7520b4 | |||
| 34d77e830c | |||
| 87c302bf26 | |||
| d462c901f5 | |||
| 55c3d79cc8 | |||
| ce901c92e4 |
+4
-1
@@ -3,7 +3,10 @@ SESSION_SECRET=abcdef1234
|
||||
MAGIC_LINK_SECRET=abcdef1234
|
||||
ENCRYPTION_KEY=ae13021afef0819c3a307ad487071c06 # Must be a random 16 byte hex string. You can generate an encryption key by running `openssl rand -hex 16` in your terminal
|
||||
LOGIN_ORIGIN=http://localhost:3030
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
|
||||
# This sets the URL used for direct connections to the database and should only be needed in limited circumstances
|
||||
# See: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#fields:~:text=the%20shadow%20database.-,directUrl,-No
|
||||
DIRECT_URL=${DATABASE_URL}
|
||||
REMIX_APP_PORT=3030
|
||||
APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- improvements/*
|
||||
tags:
|
||||
- "v.docker.*"
|
||||
paths:
|
||||
@@ -64,13 +65,107 @@ jobs:
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck --filter webapp
|
||||
|
||||
unitTests:
|
||||
name: Unit Tests
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
pnpm run test
|
||||
|
||||
e2e:
|
||||
name: e2e Tests
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
# Setup environment variables
|
||||
cp ./.env.example ./.env
|
||||
cp ./examples/nextjs-test/.env.example ./examples/nextjs-test/.env.local
|
||||
|
||||
# Build packages
|
||||
pnpm run build --filter @examples/nextjs-test^...
|
||||
pnpm --filter @trigger.dev/database generate
|
||||
|
||||
# Move trigger-cli bin to correct place
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Execute tests
|
||||
pnpm run docker
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run test:e2e
|
||||
|
||||
# Cleanup
|
||||
pnpm run docker:stop
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
publish:
|
||||
needs: [typecheck]
|
||||
needs: [typecheck, unitTests, e2e]
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
short_sha: ${{ steps.get_commit.outputs.sha_short }}
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -84,6 +179,10 @@ jobs:
|
||||
IMAGE_TAG="v${ORIGINAL_VERSION}"
|
||||
fi
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
elif [[ $GITHUB_REF == refs/heads/improvements/* ]]; then
|
||||
ORIGINAL_VERSION="${GITHUB_REF#refs/heads/improvements/}"
|
||||
IMAGE_TAG="${ORIGINAL_VERSION}.rc"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
elif [[ $GITHUB_REF == refs/heads/* ]]; then
|
||||
IMAGE_TAG="${GITHUB_REF#refs/heads/}"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
|
||||
+4
-1
@@ -48,4 +48,7 @@ apps/**/public/build
|
||||
.sentryclirc
|
||||
.buildt
|
||||
|
||||
**/tmp/
|
||||
**/tmp/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
+52
-4
@@ -27,7 +27,7 @@ branch are tagged into a release monthly.
|
||||
```
|
||||
|
||||
> If you are on windows, run the following command on gitbash with admin privileges:
|
||||
> `git clone -c core.symlinks=true https://triggerdotdev/trigger.dev.git`
|
||||
> `git clone -c core.symlinks=true https://github.com/triggerdotdev/trigger.dev.git`
|
||||
|
||||
2. Navigate to the project folder
|
||||
```
|
||||
@@ -37,11 +37,11 @@ branch are tagged into a release monthly.
|
||||
```
|
||||
pnpm i
|
||||
```
|
||||
4. Create your `.env` files
|
||||
4. Create your `.env` file
|
||||
```
|
||||
cp .env.example .env && cp packages/database/.env.example packages/database/.env
|
||||
cp .env.example .env
|
||||
```
|
||||
5. Open the root `.env` file and generate a new value for `ENCRYPTION_KEY`:
|
||||
5. 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:
|
||||
|
||||
@@ -169,6 +169,54 @@ pnpm exec trigger-cli dev
|
||||
|
||||
9. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR.
|
||||
|
||||
## Running end-to-end webapp tests
|
||||
|
||||
To run the end-to-end tests, follow the steps below:
|
||||
|
||||
1. Set up environment variables (copy example envs into the correct place)
|
||||
|
||||
```sh
|
||||
cp ./.env.example ./.env
|
||||
cp ./examples/nextjs-test/.env.example ./examples/nextjs-test/.env.local
|
||||
```
|
||||
|
||||
2. Set up dependencies
|
||||
|
||||
```sh
|
||||
# Build packages
|
||||
pnpm run build --filter @examples/nextjs-test^...
|
||||
pnpm --filter @trigger.dev/database generate
|
||||
|
||||
# Move trigger-cli bin to correct place
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Install playwrite browsers (ONE TIME ONLY)
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
3. Set up the database
|
||||
|
||||
```sh
|
||||
pnpm run docker
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
```
|
||||
|
||||
4. Run the end-to-end tests
|
||||
|
||||
```sh
|
||||
pnpm run test:e2e
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
The end-to-end tests use a `setup` and `teardown` script to seed the database with test data. If the test runner doesn't exit cleanly, then the database can be left in a state where the tests can't run because the `setup` script will try to create data that already exists. If this happens, you can manually delete the `users` and `organizations` from the database using prisma studio:
|
||||
|
||||
```sh
|
||||
# With the database running (i.e. pnpm run docker)
|
||||
pnpm run db:studio
|
||||
```
|
||||
|
||||
## Add sample jobs
|
||||
|
||||
The [examples/jobs-starter](./examples/jobs-starter/) project defines simple jobs you can get started with.
|
||||
|
||||
@@ -62,4 +62,4 @@ We provide an official trigger.dev docker image you can use to easily self-host
|
||||
|
||||
## Development
|
||||
|
||||
To setup and develop locally or contribute to the open source project, follow our [developement guide](./CONTRIBUTING.md).
|
||||
To setup and develop locally or contribute to the open source project, follow our [development guide](./CONTRIBUTING.md).
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
export function OneTreeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_4914_58397)">
|
||||
<g filter="url(#filter0_d_4914_58397)">
|
||||
<path
|
||||
d="M9 14H11C11 14 11 15 11 17C11 19 11.5 20 11.5 20H8.5C8.5 20 9 19 9 17C9 15 9 14 9 14Z"
|
||||
fill="url(#paint0_linear_4914_58397)"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_4914_58397)">
|
||||
<circle cx="5.5" cy="8.5" r="3.5" fill="currentColor" />
|
||||
</g>
|
||||
<g filter="url(#filter2_d_4914_58397)">
|
||||
<circle cx="8" cy="12" r="3" fill="currentColor" />
|
||||
</g>
|
||||
<circle cx="13" cy="11" r="4" fill="currentColor" />
|
||||
<circle cx="15.5" cy="7.5" r="2.5" fill="currentColor" />
|
||||
<g filter="url(#filter3_d_4914_58397)">
|
||||
<circle cx="9" cy="6" r="4" fill="currentColor" />
|
||||
</g>
|
||||
<circle cx="12.5" cy="5.5" r="3.5" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_4914_58397"
|
||||
x="4.5"
|
||||
y="14"
|
||||
width="11"
|
||||
height="14"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4914_58397" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4914_58397"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_d_4914_58397"
|
||||
x="-2"
|
||||
y="5"
|
||||
width="15"
|
||||
height="15"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4914_58397" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4914_58397"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter2_d_4914_58397"
|
||||
x="1"
|
||||
y="9"
|
||||
width="14"
|
||||
height="14"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4914_58397" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4914_58397"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter3_d_4914_58397"
|
||||
x="1"
|
||||
y="-2"
|
||||
width="16"
|
||||
height="16"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4914_58397" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4914_58397"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_4914_58397"
|
||||
x1="10"
|
||||
y1="14"
|
||||
x2="10"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#402309" />
|
||||
<stop offset="1" stopColor="#713F12" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_4914_58397">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export function SaplingIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_4906_23816)">
|
||||
<path
|
||||
d="M4.39538 17.7403C7.25332 16.3563 13.2867 16.4291 15.5096 17.7403C16.6666 18.4227 17.2938 19.0069 17.6294 19.4103C17.8542 19.6806 17.651 20 17.2994 20H2.34215C2.151 20 1.9749 19.8835 2.01519 19.6967C2.09465 19.3281 2.53308 18.6421 4.39538 17.7403Z"
|
||||
fill="url(#paint0_linear_4906_23816)"
|
||||
/>
|
||||
<g filter="url(#filter0_d_4906_23816)">
|
||||
<path
|
||||
d="M9.37762 9.80872C7.43627 7.16364 3.49903 8.13783 2.43836 8.66818C2.26159 9.02174 3.32907 8.80396 4.08851 10.8279C4.84794 12.8519 7.63503 13.1864 8.74087 12.0806C9.27121 11.5502 9.69141 10.2363 9.37762 9.80872Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_4906_23816)">
|
||||
<path
|
||||
d="M9.03577 9.94522L9.99862 9.80223C9.99862 9.80223 10.0387 16.2908 10.9485 16.8179C11.8582 17.345 8.47933 17.1309 8.92618 16.8179C9.37303 16.505 9.03577 9.94522 9.03577 9.94522Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_4906_23816)">
|
||||
<path
|
||||
d="M9.36235 10.1127C9.36235 5.78131 13.3229 4.41473 15.9395 4.00389C16.1892 3.96468 16.3818 4.22916 16.2961 4.46698C16.0763 5.07608 15.7914 6.09975 15.6717 7.46762C15.5019 9.40897 14.8971 10.3478 14.3613 10.962C13.3664 12.1026 9.36235 12.2341 9.36235 10.1127Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_4906_23816"
|
||||
x="-1.58107"
|
||||
y="8.04684"
|
||||
width="15.0664"
|
||||
height="12.6571"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4906_23816" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4906_23816"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_d_4906_23816"
|
||||
x="4.88525"
|
||||
y="9.80223"
|
||||
width="10.2195"
|
||||
height="15.3357"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4906_23816" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4906_23816"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter2_d_4906_23816"
|
||||
x="5.36235"
|
||||
y="4"
|
||||
width="14.9544"
|
||||
height="15.7756"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4906_23816" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4906_23816"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_4906_23816"
|
||||
x1="10"
|
||||
y1="16.7294"
|
||||
x2="10"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.291667" stopColor="#402309" />
|
||||
<stop offset="1" stopColor="#713F12" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_4906_23816">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export function TwoTreesIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_4906_58417)">
|
||||
<path
|
||||
d="M10 20V17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.7239 16.3L14.8274 13H15.1171C15.3061 12.9973 15.4901 12.9371 15.6464 12.8271C15.8027 12.717 15.9243 12.5619 15.9963 12.3808C16.0683 12.1998 16.0873 12.001 16.0512 11.8088C16.015 11.6167 15.9252 11.4398 15.7929 11.3L12.8965 8H13.0896C13.2868 8.01843 13.4847 7.97361 13.6565 7.87162C13.8282 7.76963 13.9656 7.6154 14.0499 7.42984C14.1342 7.24428 14.1614 7.03636 14.1278 6.83425C14.0942 6.63215 14.0014 6.44564 13.862 6.3L10 2L7.29699 5.0096C8.25755 5.0792 9.16726 5.48607 9.85673 6.16512C10.5124 6.81087 10.9224 7.65741 11.023 8.56163C11.6295 8.91846 12.136 9.42887 12.4856 10.0473C12.9492 10.8671 13.1066 11.8229 12.9286 12.7472C12.7507 13.6714 12.2496 14.5014 11.5168 15.0945C10.8041 15.6712 9.91568 15.9888 8.99914 15.999V18H17.0481C17.237 17.9973 17.4211 17.9371 17.5774 17.8271C17.7337 17.717 17.8553 17.5618 17.9273 17.3808C17.9992 17.1998 18.0183 17.001 17.9822 16.8088C17.946 16.6167 17.8562 16.4398 17.7239 16.3ZM7.99914 18V15.9991H6.00231V18H7.99914ZM4.97036 15.9991H5.00231L5.00231 18H2.95193C2.76296 17.9973 2.57892 17.9371 2.42263 17.8271C2.26635 17.717 2.14468 17.5618 2.07272 17.3808C2.00076 17.1998 1.98168 17.001 2.01783 16.8088C2.05398 16.6167 2.14377 16.4398 2.27609 16.3L3.01417 15.4591C3.59927 15.795 4.26325 15.9821 4.94587 15.9988C4.95403 15.999 4.9622 15.9991 4.97036 15.9991ZM3.69171 14.6872C4.08702 14.8804 4.52307 14.9881 4.97036 14.9991H8.93032C9.64375 15.0045 10.3365 14.7632 10.8877 14.3171C11.4389 13.871 11.8137 13.2485 11.9466 12.5581C12.0796 11.8678 11.9623 11.1534 11.6151 10.5395C11.268 9.9256 10.7131 9.45119 10.0472 9.19898V8.99898C10.0472 8.20331 9.7263 7.44023 9.15504 6.87761C8.58378 6.31499 7.80899 5.99891 7.00111 5.99891C6.77881 5.99891 6.55901 6.02285 6.34528 6.06926L6.13804 6.3C5.99864 6.44564 5.90584 6.63215 5.87222 6.83425C5.8386 7.03636 5.86579 7.24428 5.9501 7.42984C6.03441 7.6154 6.17176 7.76963 6.34354 7.87162C6.51533 7.97361 6.71323 8.01843 6.91043 8H7.10353L4.20706 11.3C4.07475 11.4398 3.98496 11.6167 3.94881 11.8088C3.91266 12.001 3.93174 12.1998 4.0037 12.3808C4.07566 12.5619 4.19732 12.717 4.35361 12.8271C4.5099 12.9371 4.69394 12.9973 4.88291 13H5.17255L3.69171 14.6872Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7 20L7 13.9814"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.0472 9V9.2C10.7131 9.4522 11.268 9.92661 11.6151 10.5405C11.9623 11.1544 12.0796 11.8687 11.9466 12.5591C11.8137 13.2494 11.4389 13.8719 10.8877 14.318C10.3365 14.7641 9.64374 15.0054 8.93032 15H4.97036C4.26546 14.9827 3.58847 14.7251 3.05487 14.2712C2.52126 13.8172 2.1641 13.195 2.0443 12.5106C1.92449 11.8263 2.04946 11.1222 2.3979 10.5185C2.74634 9.91474 3.29666 9.44875 3.95499 9.2V9C3.95499 8.20435 4.27592 7.44129 4.84718 6.87868C5.41843 6.31607 6.19323 6 7.00111 6C7.80899 6 8.58378 6.31607 9.15504 6.87868C9.7263 7.44129 10.0472 8.20435 10.0472 9Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4906_58417">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
@@ -20,6 +20,8 @@ import {
|
||||
} from "./primitives/Select";
|
||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from "./primitives/Sheet";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
@@ -55,14 +57,15 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
<SheetContent size="sm">
|
||||
<SheetHeader className="justify-between">Help & feedback</SheetHeader>
|
||||
<SheetBody>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Use this form to ask for help or give us feedback. We read every message and will get
|
||||
<DiscordBanner />
|
||||
<Paragraph variant="small" className="mb-4 border-t border-slate-800 pt-3">
|
||||
Or use this form to ask for help or give us feedback. We read every message and will get
|
||||
back to you as soon as we can.
|
||||
</Paragraph>
|
||||
<Form method="post" action="/resources/feedback" {...form.props}>
|
||||
<Fieldset>
|
||||
<Fieldset className="max-w-full">
|
||||
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
|
||||
<InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label>How can we help?</Label>
|
||||
<SelectGroup>
|
||||
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
|
||||
@@ -80,14 +83,14 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
</SelectGroup>
|
||||
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label>Message</Label>
|
||||
<TextArea {...conform.textarea(message)} />
|
||||
<FormError id={message.errorId}>{message.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
className="max-w-md"
|
||||
className="w-full"
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium">
|
||||
Send
|
||||
@@ -101,3 +104,28 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordBanner() {
|
||||
return (
|
||||
<a
|
||||
href="https://discord.gg/nkqV9xBYWy"
|
||||
target="_blank"
|
||||
className="group mb-4 flex w-full items-center justify-between rounded-md border border-slate-600 bg-gradient-to-br from-blue-400/30 to-indigo-400/50 p-4 transition hover:border-indigo-400"
|
||||
>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<DiscordIcon className="h-8 w-8" />
|
||||
<h2 className="font-title text-2xl text-bright transition group-hover:text-white">
|
||||
Join the Trigger.dev
|
||||
<br />
|
||||
Discord community
|
||||
</h2>
|
||||
<Paragraph variant="small">
|
||||
Get help or answer questions from the Trigger.dev community.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ChatBubbleLeftRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, useRevalidator } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEventSource } from "remix-utils";
|
||||
import invariant from "tiny-invariant";
|
||||
import gradientBackground from "~/assets/images/gradient-background.png";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
@@ -7,7 +12,8 @@ import { useJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { IntegrationIcon } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route";
|
||||
import { jobTestPath } from "~/utils/pathBuilder";
|
||||
import { jobTestPath, projectStreamingPath } from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { InlineCode } from "../code/InlineCode";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
@@ -23,132 +29,238 @@ import {
|
||||
ClientTabsTrigger,
|
||||
} from "../primitives/ClientTabs";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Header1, Header2 } from "../primitives/Headers";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import integrationButton from "./integration-button.png";
|
||||
import selectEnvironment from "./select-environment.png";
|
||||
import selectExample from "./select-example.png";
|
||||
|
||||
type SelectionChoices = "use-existing-project" | "create-new-next-app";
|
||||
|
||||
export function HowToSetupYourProject() {
|
||||
const project = useProject();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
const appOrigin = useAppOrigin();
|
||||
return (
|
||||
<>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in a Next.js project" />
|
||||
<StepContentContainer>
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
secure={`npx @trigger.dev/cli@latest init -k ••••••••• -t ${appOrigin}`}
|
||||
value={`npx @trigger.dev/cli@latest init -k ${devEnvironment?.apiKey} -t ${appOrigin}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
secure={`pnpm dlx @trigger.dev/cli@latest init -k ••••••••• -t ${appOrigin}`}
|
||||
value={`pnpm dlx @trigger.dev/cli@latest init -k ${devEnvironment?.apiKey} -t ${appOrigin}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
secure={`yarn dlx @trigger.dev/cli@latest init -k ••••••••• -t ${appOrigin}`}
|
||||
value={`yarn dlx @trigger.dev/cli@latest init -k ${devEnvironment?.apiKey} -t ${appOrigin}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<Paragraph spacing>
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very simple
|
||||
example Job in <InlineCode>examples.ts</InlineCode> to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run your Next.js app" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>Ensure your app is running locally.</Paragraph>
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`npm run dev`} />
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`pnpm run dev`} />
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`yarn run dev`} />
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
In a <strong className="text-bright">separate terminal window or tab</strong> run:
|
||||
</Paragraph>
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`npx @trigger.dev/cli@latest dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm dlx @trigger.dev/cli@latest dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn dlx @trigger.dev/cli@latest dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
<Paragraph spacing variant="small">
|
||||
If you’re not running on port 3000 you can specify the port by adding{" "}
|
||||
<InlineCode>--port 3001</InlineCode> to the end.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
You should leave the <InlineCode>dev</InlineCode> command running when you're developing.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Check for Jobs" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Once you've run the CLI command, click Refresh to view your example Job in the list.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
className="mt-4"
|
||||
LeadingIcon="refresh"
|
||||
onClick={() => window.location.reload()}
|
||||
const [selectedValue, setSelectedValue] = useState<SelectionChoices | null>(null);
|
||||
|
||||
invariant(devEnvironment, "devEnvironment is required");
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const events = useEventSource(projectStreamingPath(project.id), {
|
||||
event: "message",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (events !== null) {
|
||||
// This uses https://www.npmjs.com/package/canvas-confetti
|
||||
if ("confetti" in window && typeof window.confetti !== "undefined") {
|
||||
const duration = 3.5 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = {
|
||||
startVelocity: 30,
|
||||
spread: 360,
|
||||
ticks: 60,
|
||||
zIndex: 0,
|
||||
colors: [
|
||||
"#E7FF52",
|
||||
"#41FF54",
|
||||
"rgb(245 158 11)",
|
||||
"rgb(22 163 74)",
|
||||
"rgb(37 99 235)",
|
||||
"rgb(67 56 202)",
|
||||
"rgb(219 39 119)",
|
||||
"rgb(225 29 72)",
|
||||
"rgb(217 70 239)",
|
||||
],
|
||||
};
|
||||
function randomInRange(min: number, max: number): number {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
// @ts-ignore
|
||||
const interval = setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval);
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
// since particles fall down, start a bit higher than random
|
||||
// @ts-ignore
|
||||
window.confetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.1, 0.4), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
// @ts-ignore
|
||||
window.confetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.6, 0.9), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
revalidator.revalidate();
|
||||
}
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-ml-4 -mt-4 h-full w-[calc(100%+32px)] bg-cover bg-no-repeat pt-20"
|
||||
style={{ backgroundImage: `url("${gradientBackground}")` }}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing className="text-bright">
|
||||
Get setup in {selectedValue === "create-new-next-app" ? "5" : "2"} minutes
|
||||
</Header1>
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="secondary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
<RadioGroup
|
||||
className="mb-4 flex gap-x-2"
|
||||
onValueChange={(value) => setSelectedValue(value as SelectionChoices)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
<RadioGroupItem
|
||||
label="Use an existing Next.js project"
|
||||
description="Use Trigger.dev in an existing Next.js project in less than 2 mins."
|
||||
value="use-existing-project"
|
||||
checked={selectedValue === "use-existing-project"}
|
||||
variant="icon"
|
||||
data-action="use-existing-project"
|
||||
icon={<NamedIcon className="h-12 w-12 text-green-600" name={"tree"} />}
|
||||
/>
|
||||
<RadioGroupItem
|
||||
label="Create a new Next.js project"
|
||||
description="This is the quickest way to try out Trigger.dev in a new Next.js project and takes 5 mins."
|
||||
value="create-new-next-app"
|
||||
checked={selectedValue === "create-new-next-app"}
|
||||
variant="icon"
|
||||
data-action="create-new-next-app"
|
||||
icon={<NamedIcon className="h-8 w-8 text-green-600" name={"sapling"} />}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{selectedValue && (
|
||||
<>
|
||||
{selectedValue === "create-new-next-app" ? (
|
||||
<>
|
||||
<StepNumber stepNumber="1" title="Create a new Next.js project" />
|
||||
<StepContentContainer>
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`npx create-next-app@latest`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm create next-app`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn create next-app`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<Paragraph spacing variant="small">
|
||||
Trigger.dev works with either the Pages or App Router configuration.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Navigate to your new Next.js project" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
You have now created a new Next.js project. Let’s <InlineCode>cd</InlineCode>{" "}
|
||||
into it using the project name you just provided:
|
||||
</Paragraph>
|
||||
<ClipboardField
|
||||
value={"cd [replace with your project name]"}
|
||||
variant={"primary/medium"}
|
||||
></ClipboardField>
|
||||
</StepContentContainer>
|
||||
<StepNumber
|
||||
stepNumber="3"
|
||||
title="Run the CLI 'init' command in your new Next.js project"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
|
||||
<Paragraph spacing variant="small">
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very
|
||||
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
|
||||
to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Run your Next.js app" />
|
||||
<StepContentContainer>
|
||||
<NextDevCommand />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="5" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStep />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StepNumber
|
||||
stepNumber="1"
|
||||
title="Run the CLI 'init' command in an existing Next.js project"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
|
||||
|
||||
<Paragraph spacing variant="small">
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very
|
||||
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
|
||||
to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run your Next.js app" />
|
||||
<StepContentContainer>
|
||||
<NextDevCommand />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStep />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Wait for Jobs" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,3 +503,112 @@ export function HowToUseApiKeysAndEndpoints() {
|
||||
function StepContentContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mb-6 ml-9 mt-1">{children}</div>;
|
||||
}
|
||||
|
||||
function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey: string }) {
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
secure={`npx @trigger.dev/cli@latest init -k ••••••••• -t ${appOrigin}`}
|
||||
value={`npx @trigger.dev/cli@latest init -k ${apiKey} -t ${appOrigin}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
secure={`pnpm dlx @trigger.dev/cli@latest init -k ••••••••• -t ${appOrigin}`}
|
||||
value={`pnpm dlx @trigger.dev/cli@latest init -k ${apiKey} -t ${appOrigin}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
secure={`yarn dlx @trigger.dev/cli@latest init -k ••••••••• -t ${appOrigin}`}
|
||||
value={`yarn dlx @trigger.dev/cli@latest init -k ${apiKey} -t ${appOrigin}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
function NextDevCommand() {
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`npm run dev`} />
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`pnpm run dev`} />
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`yarn run dev`} />
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerDevCommand() {
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`npx @trigger.dev/cli@latest dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm dlx @trigger.dev/cli@latest dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn dlx @trigger.dev/cli@latest dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerDevStep() {
|
||||
return (
|
||||
<>
|
||||
<Paragraph spacing>
|
||||
In a <span className="text-amber-400">separate terminal window or tab</span> run:
|
||||
</Paragraph>
|
||||
<TriggerDevCommand />
|
||||
<Paragraph spacing variant="small">
|
||||
If you’re not running on port 3000 you can specify the port by adding{" "}
|
||||
<InlineCode variant="extra-small">--port 3001</InlineCode> to the end.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
You should leave the <InlineCode variant="extra-small">dev</InlineCode> command running when
|
||||
you're developing.
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,11 +51,6 @@ export function NoIntegrationSheet({
|
||||
)}
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<Callout variant="info">
|
||||
We don’t have an Integration for the {api.name} API yet but you can request one by
|
||||
clicking the button above. In the meantime, connect to {api.name} using one of the
|
||||
methods below.
|
||||
</Callout>
|
||||
<CustomHelp name={api.name} />
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { JobRunStatus } from "~/models/job.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
|
||||
const organization = useOrganization();
|
||||
@@ -44,7 +45,10 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
return (
|
||||
<TableRow
|
||||
key={job.id}
|
||||
className={cn(job.hasIntegrationsRequiringAction && "bg-rose-500/30")}
|
||||
className={cn(
|
||||
(job.hasIntegrationsRequiringAction && "bg-rose-500/20") ||
|
||||
(job.lastRun === undefined && "bg-green-500/20")
|
||||
)}
|
||||
>
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -141,7 +145,13 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<LabelValueStack label={"Never run"} value={"–"} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
<TableCellChevron to={path}>
|
||||
{job.lastRun === undefined && (
|
||||
<Badge className="mr-4" variant="green">
|
||||
New Job!
|
||||
</Badge>
|
||||
)}
|
||||
</TableCellChevron>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -6,12 +6,18 @@ const variants = {
|
||||
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-slate-700 text-bright uppercase whitespace-nowrap",
|
||||
outline:
|
||||
"grid place-items-center rounded-sm px-1 h-5 tracking-wider text-xxs border border-dimmed text-dimmed uppercase whitespace-nowrap",
|
||||
green:
|
||||
"grid place-items-center rounded-sm px-1.5 h-5 tracking-wider outline-offset-1 outline outline-1 outline-green-600 text-xxs bg-green-500 text-slate-900 uppercase whitespace-nowrap",
|
||||
};
|
||||
|
||||
type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: keyof typeof variants;
|
||||
};
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return <div className={cn(variants[variant], className)} {...props} />;
|
||||
export function Badge({ className, variant = "default", children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(variants[variant], className)} {...props}>
|
||||
<span className="-mb-0.5">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph, ParagraphVariant } from "./Paragraph";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ import { WebhookIcon } from "~/assets/icons/WebhookIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { SaplingIcon } from "~/assets/icons/SaplingIcon";
|
||||
import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon";
|
||||
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
|
||||
|
||||
const icons = {
|
||||
account: (className: string) => <UserCircleIcon className={cn("text-slate-400", className)} />,
|
||||
@@ -128,6 +131,7 @@ const icons = {
|
||||
property: (className: string) => <Cog8ToothIcon className={cn("text-slate-600", className)} />,
|
||||
"qr-code": (className: string) => <QrCodeIcon className={cn("text-amber-400", className)} />,
|
||||
refresh: (className: string) => <ArrowPathIcon className={cn("text-bright", className)} />,
|
||||
sapling: (className: string) => <SaplingIcon className={cn("text-green-500", className)} />,
|
||||
search: (className: string) => <MagnifyingGlassIcon className={cn("text-dimmed", className)} />,
|
||||
settings: (className: string) => <Cog8ToothIcon className={cn("text-slate-600", className)} />,
|
||||
spinner: (className: string) => <Spinner className={className} color="blue" />,
|
||||
@@ -135,6 +139,8 @@ const icons = {
|
||||
star: (className: string) => <StarIcon className={cn("text-yellow-500", className)} />,
|
||||
stop: (className: string) => <StopIcon className={cn("text-rose-500", className)} />,
|
||||
team: (className: string) => <UserGroupIcon className={cn("text-blue-500", className)} />,
|
||||
tree: (className: string) => <OneTreeIcon className={cn("text-green-500", className)} />,
|
||||
trees: (className: string) => <TwoTreesIcon className={cn("text-green-500", className)} />,
|
||||
trigger: (className: string) => <BoltIcon className={cn("text-orange-500", className)} />,
|
||||
user: (className: string) => <UserIcon className={cn("text-blue-600", className)} />,
|
||||
warning: (className: string) => (
|
||||
|
||||
@@ -11,12 +11,14 @@ const variants = {
|
||||
label: "text-sm text-bright mt-0.5 select-none",
|
||||
description: "text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
icon: "w-8 h-8 mb-2",
|
||||
},
|
||||
simple: {
|
||||
button: "w-fit pr-4 data-[disabled]:opacity-70",
|
||||
label: "text-bright select-none",
|
||||
description: "text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
icon: "w-8 h-8 mb-2",
|
||||
},
|
||||
"button/small": {
|
||||
button:
|
||||
@@ -24,6 +26,7 @@ const variants = {
|
||||
label: "text-sm text-bright select-none",
|
||||
description: "text-dimmed",
|
||||
inputPosition: "mt-0",
|
||||
icon: "w-8 h-8 mb-2",
|
||||
},
|
||||
button: {
|
||||
button:
|
||||
@@ -31,6 +34,7 @@ const variants = {
|
||||
label: "text-bright select-none",
|
||||
description: "text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
icon: "w-8 h-8 mb-2",
|
||||
},
|
||||
description: {
|
||||
button:
|
||||
@@ -38,6 +42,15 @@ const variants = {
|
||||
label: "text-bright font-semibold -mt-1 text-left",
|
||||
description: "text-dimmed -mt-0 text-left",
|
||||
inputPosition: "mt-0",
|
||||
icon: "w-8 h-8 mb-2",
|
||||
},
|
||||
icon: {
|
||||
button:
|
||||
"w-full p-2.5 pb-4 hover:bg-slate-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-slate-850 border-slate-800 border rounded-sm",
|
||||
label: "text-bright font-semibold -mt-1 text-left",
|
||||
description: "text-dimmed -mt-0 text-left",
|
||||
inputPosition: "mt-0",
|
||||
icon: "mb-3",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -57,53 +70,60 @@ type RadioGroupItemProps = Omit<
|
||||
description?: string;
|
||||
badges?: string[];
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
RadioGroupItemProps
|
||||
>(({ className, children, variant = "simple", label, description, badges, ...props }, ref) => {
|
||||
const variation = variants[variant];
|
||||
>(
|
||||
(
|
||||
{ className, children, variant = "simple", label, description, badges, icon, ...props },
|
||||
ref
|
||||
) => {
|
||||
const variation = variants[variant];
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-start gap-x-2 transition",
|
||||
variation.button,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-sm border border-slate-700 ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variation.inputPosition
|
||||
"group flex cursor-pointer items-start gap-x-2 transition",
|
||||
variation.button,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex h-full w-full items-center justify-center bg-indigo-700">
|
||||
<Circle className="h-1.5 w-1.5 fill-white text-white" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<label htmlFor={props.id} className={cn("cursor-pointer", variation.label)}>
|
||||
{label}
|
||||
</label>
|
||||
{badges && (
|
||||
<span className="-mr-2 flex gap-x-1.5">
|
||||
{badges.map((badge) => (
|
||||
<Badge key={badge}>{badge}</Badge>
|
||||
))}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-sm border border-slate-700 ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variation.inputPosition
|
||||
)}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex h-full w-full items-center justify-center bg-indigo-700">
|
||||
<Circle className="h-1.5 w-1.5 fill-white text-white" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</div>
|
||||
<div className={cn(icon ? "flex h-full flex-col justify-end" : "")}>
|
||||
{variant === "icon" && <div className={variation.icon}>{icon}</div>}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<label htmlFor={props.id} className={cn("cursor-pointer", variation.label)}>
|
||||
{label}
|
||||
</label>
|
||||
{badges && (
|
||||
<span className="-mr-2 flex gap-x-1.5">
|
||||
{badges.map((badge) => (
|
||||
<Badge key={badge}>{badge}</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(variant === "description" || variant === "icon") && (
|
||||
<Paragraph variant="small" className={cn("mt-0.5", variation.description)}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
{variant === "description" && (
|
||||
<Paragraph variant="small" className={cn("mt-0.5", variation.description)}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-indigo-900 opacity-100 border-l border-y border-slate-800",
|
||||
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-y border-slate-800",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header2 } from "./Headers";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
export function StepNumber({
|
||||
stepNumber,
|
||||
active = false,
|
||||
complete = false,
|
||||
displaySpinner = false,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
@@ -13,6 +15,7 @@ export function StepNumber({
|
||||
complete?: boolean;
|
||||
title?: React.ReactNode;
|
||||
className?: string;
|
||||
displaySpinner?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mr-3", className)}>
|
||||
@@ -28,7 +31,15 @@ export function StepNumber({
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded border border-slate-700 bg-slate-800 py-1 text-xs font-semibold text-dimmed shadow">
|
||||
{complete ? "✓" : stepNumber}
|
||||
</span>
|
||||
<Header2>{title}</Header2>
|
||||
|
||||
{displaySpinner ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Header2>{title}</Header2>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<Header2>{title}</Header2>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ReactNode, forwardRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./Badge";
|
||||
|
||||
type TableProps = {
|
||||
containerClassName?: string;
|
||||
@@ -172,12 +173,14 @@ export const TableCellChevron = forwardRef<
|
||||
{
|
||||
className?: string;
|
||||
to?: string;
|
||||
children?: ReactNode;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
>(({ className, to, onClick }, ref) => {
|
||||
>(({ className, to, children, onClick }, ref) => {
|
||||
return (
|
||||
<TableCell className={className} to={to} onClick={onClick} ref={ref} alignment="right">
|
||||
<ChevronRightIcon className="h-4 w-4 text-slate-700 transition group-hover:text-bright" />
|
||||
{children}
|
||||
<ChevronRightIcon className="h-4 w-4 text-dimmed transition group-hover:text-bright" />
|
||||
</TableCell>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -273,6 +273,7 @@ function BlankTasks({
|
||||
basicStatus: RunBasicStatus;
|
||||
}) {
|
||||
switch (basicStatus) {
|
||||
default:
|
||||
case "COMPLETED":
|
||||
return <Paragraph variant="small">There were no tasks for this run.</Paragraph>;
|
||||
case "FAILED":
|
||||
@@ -288,8 +289,6 @@ function BlankTasks({
|
||||
<TaskCardSkeleton />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <Paragraph variant="small">There were no tasks for this run.</Paragraph>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +324,7 @@ function RerunPopover({
|
||||
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
|
||||
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
|
||||
{environmentType === "PRODUCTION" && (
|
||||
<Callout variant="warning">
|
||||
<Callout variant="warning" className="mb-2">
|
||||
This will rerun this Job in your Production environment.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { withDesign } from "storybook-addon-designs";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/Badges",
|
||||
decorators: [withDesign],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof BadgesExample>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => <BadgesExample />,
|
||||
};
|
||||
|
||||
function BadgesExample() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-8 p-8">
|
||||
<Badge>Default</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
<Badge variant="green">Green</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { withDesign } from "storybook-addon-designs";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/RadioGroup",
|
||||
@@ -42,6 +43,14 @@ function RadioGroupExample() {
|
||||
value={"5"}
|
||||
variant="description"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="r7"
|
||||
label="This is an icon label"
|
||||
description="This is a description"
|
||||
value={"6"}
|
||||
variant="icon"
|
||||
icon={<NamedIcon name="tree" className="h-8 w-8" />}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const LIVE_ENVIRONMENT = "live";
|
||||
export const DEV_ENVIRONMENT = "development";
|
||||
export const MAX_LIVE_PROJECTS = 1;
|
||||
export const DEFAULT_MAX_CONCURRENT_RUNS = 100;
|
||||
export const DEFAULT_MAX_CONCURRENT_RUNS = 10;
|
||||
export const MAX_CONCURRENT_RUNS_LIMIT = 20;
|
||||
export const PREPROCESS_RETRY_LIMIT = 2;
|
||||
export const EXECUTE_JOB_RETRY_LIMIT = 10;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaClient, Prisma } from "@trigger.dev/database";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { env } from "./env.server";
|
||||
|
||||
export type PrismaTransactionClient = Omit<
|
||||
PrismaClient,
|
||||
@@ -84,8 +85,16 @@ function getClient() {
|
||||
const { DATABASE_URL } = process.env;
|
||||
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
|
||||
|
||||
const databaseUrl = new URL(DATABASE_URL);
|
||||
|
||||
// We need to add the connection_limit and pool_timeout query params to the url, in a way that works if the DATABASE_URL already has query params
|
||||
const query = databaseUrl.searchParams;
|
||||
query.set("connection_limit", env.DATABASE_CONNECTION_LIMIT.toString());
|
||||
query.set("pool_timeout", env.DATABASE_POOL_TIMEOUT.toString());
|
||||
databaseUrl.search = query.toString();
|
||||
|
||||
// Remove the username:password in the url and print that to the console
|
||||
const urlWithoutCredentials = new URL(DATABASE_URL);
|
||||
const urlWithoutCredentials = new URL(databaseUrl.href);
|
||||
urlWithoutCredentials.password = "";
|
||||
|
||||
console.log(`🔌 setting up prisma client to ${urlWithoutCredentials.toString()}`);
|
||||
@@ -93,7 +102,7 @@ function getClient() {
|
||||
const client = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: DATABASE_URL,
|
||||
url: databaseUrl.href,
|
||||
},
|
||||
},
|
||||
log: [
|
||||
|
||||
@@ -74,7 +74,7 @@ function serveTheBots(
|
||||
{
|
||||
// Use onAllReady to wait for the entire document to be ready
|
||||
onAllReady() {
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
||||
let body = new PassThrough();
|
||||
pipe(body);
|
||||
resolve(
|
||||
@@ -114,7 +114,7 @@ function serveBrowsers(
|
||||
// use onShellReady to wait until a suspense boundary is triggered
|
||||
onShellReady() {
|
||||
shellReady = true;
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
||||
let body = new PassThrough();
|
||||
pipe(body);
|
||||
resolve(
|
||||
|
||||
@@ -4,6 +4,9 @@ import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server"
|
||||
const EnvironmentSchema = z.object({
|
||||
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
|
||||
DATABASE_URL: z.string(),
|
||||
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
|
||||
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
|
||||
DIRECT_URL: z.string(),
|
||||
SESSION_SECRET: z.string(),
|
||||
MAGIC_LINK_SECRET: z.string(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
@@ -20,6 +23,8 @@ const EnvironmentSchema = z.object({
|
||||
.default(process.env.NODE_ENV),
|
||||
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
|
||||
POSTHOG_PROJECT_KEY: z.string().optional(),
|
||||
TELEMETRY_TRIGGER_API_KEY: z.string().optional(),
|
||||
TELEMETRY_TRIGGER_API_URL: z.string().optional(),
|
||||
HIGHLIGHT_PROJECT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -28,6 +33,13 @@ const EnvironmentSchema = z.object({
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
PLAIN_API_KEY: z.string().optional(),
|
||||
RUNTIME_PLATFORM: z.enum(["docker-compose", "ecs", "local"]).default("local"),
|
||||
WORKER_SCHEMA: z.string().default("graphile_worker"),
|
||||
WORKER_CONCURRENCY: z.coerce.number().int().default(10),
|
||||
WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
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"),
|
||||
EXECUTION_WORKER_ENABLED: z.string().default("true"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { JobRun, JobRunExecution } from "@trigger.dev/database";
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { executionWorker } from "~/services/worker.server";
|
||||
|
||||
export async function enqueueRunExecutionV1(
|
||||
execution: JobRunExecution,
|
||||
queueId: string,
|
||||
concurrency: number,
|
||||
tx: PrismaClientOrTransaction,
|
||||
runAt?: Date
|
||||
) {
|
||||
const job = await executionWorker.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: execution.id,
|
||||
},
|
||||
{
|
||||
queueName: `job:queue:${queueId}`,
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `execution:${execution.runId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export type EnqueueRunExecutionV2Options = {
|
||||
runAt?: Date;
|
||||
resumeTaskId?: string;
|
||||
isRetry?: boolean;
|
||||
};
|
||||
|
||||
export async function enqueueRunExecutionV2(
|
||||
run: JobRun,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options: EnqueueRunExecutionV2Options = {}
|
||||
) {
|
||||
const job = await executionWorker.enqueue(
|
||||
"performRunExecutionV2",
|
||||
{
|
||||
id: run.id,
|
||||
reason: run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB",
|
||||
resumeTaskId: options.resumeTaskId,
|
||||
isRetry: typeof options.isRetry === "boolean" ? options.isRetry : false,
|
||||
},
|
||||
{
|
||||
queueName: `job:${run.jobId}:env:${run.environmentId}`,
|
||||
tx,
|
||||
runAt: options.runAt,
|
||||
jobKey: `job_run:${run.id}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) {
|
||||
return await executionWorker.dequeue(`job_run:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
CronItem,
|
||||
CronItemOptions,
|
||||
Job as GraphileJob,
|
||||
Runner as GraphileRunner,
|
||||
JobHelpers,
|
||||
@@ -7,7 +9,7 @@ import type {
|
||||
TaskList,
|
||||
TaskSpec,
|
||||
} from "graphile-worker";
|
||||
import { run as graphileRun } from "graphile-worker";
|
||||
import { run as graphileRun, parseCronItems } from "graphile-worker";
|
||||
|
||||
import omit from "lodash.omit";
|
||||
import { z } from "zod";
|
||||
@@ -18,6 +20,13 @@ export interface MessageCatalogSchema {
|
||||
[key: string]: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
}
|
||||
|
||||
const RawCronPayloadSchema = z.object({
|
||||
_cron: z.object({
|
||||
ts: z.coerce.date(),
|
||||
backfilled: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
const GraphileJobSchema = z.object({
|
||||
id: z.coerce.string(),
|
||||
queue_name: z.string().nullable(),
|
||||
@@ -42,6 +51,7 @@ const AddJobResultsSchema = z.array(GraphileJobSchema);
|
||||
export type ZodTasks<TConsumerSchema extends MessageCatalogSchema> = {
|
||||
[K in keyof TConsumerSchema]: {
|
||||
queueName?: string | ((payload: z.infer<TConsumerSchema[K]>) => string);
|
||||
jobKey?: string | ((payload: z.infer<TConsumerSchema[K]>) => string | undefined);
|
||||
priority?: number;
|
||||
maxAttempts?: number;
|
||||
jobKeyMode?: "replace" | "preserve_run_at" | "unsafe_dedupe";
|
||||
@@ -50,29 +60,52 @@ export type ZodTasks<TConsumerSchema extends MessageCatalogSchema> = {
|
||||
};
|
||||
};
|
||||
|
||||
type RecurringTaskPayload = {
|
||||
ts: Date;
|
||||
backfilled: boolean;
|
||||
};
|
||||
|
||||
export type ZodRecurringTasks = {
|
||||
[key: string]: {
|
||||
pattern: string;
|
||||
options?: CronItemOptions;
|
||||
handler: (payload: RecurringTaskPayload, job: GraphileJob) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ZodWorkerEnqueueOptions = TaskSpec & {
|
||||
tx?: PrismaClientOrTransaction;
|
||||
};
|
||||
|
||||
export type ZodWorkerDequeueOptions = {
|
||||
tx?: PrismaClientOrTransaction;
|
||||
};
|
||||
|
||||
export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
|
||||
name: string;
|
||||
runnerOptions: RunnerOptions;
|
||||
prisma: PrismaClient;
|
||||
schema: TMessageCatalog;
|
||||
tasks: ZodTasks<TMessageCatalog>;
|
||||
recurringTasks?: ZodRecurringTasks;
|
||||
};
|
||||
|
||||
export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#name: string;
|
||||
#schema: TMessageCatalog;
|
||||
#prisma: PrismaClient;
|
||||
#runnerOptions: RunnerOptions;
|
||||
#tasks: ZodTasks<TMessageCatalog>;
|
||||
#recurringTasks?: ZodRecurringTasks;
|
||||
#runner?: GraphileRunner;
|
||||
|
||||
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
|
||||
this.#name = options.name;
|
||||
this.#schema = options.schema;
|
||||
this.#prisma = options.prisma;
|
||||
this.#runnerOptions = options.runnerOptions;
|
||||
this.#tasks = options.tasks;
|
||||
this.#recurringTasks = options.recurringTasks;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<boolean> {
|
||||
@@ -80,19 +113,70 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug("Initializing worker queue with options", {
|
||||
this.#logDebug("Initializing worker queue with options", {
|
||||
runnerOptions: this.#runnerOptions,
|
||||
});
|
||||
|
||||
const parsedCronItems = parseCronItems(this.#createCronItemsFromRecurringTasks());
|
||||
|
||||
this.#runner = await graphileRun({
|
||||
...this.#runnerOptions,
|
||||
taskList: this.#createTaskListFromTasks(),
|
||||
parsedCronItems,
|
||||
});
|
||||
|
||||
if (!this.#runner) {
|
||||
throw new Error("Failed to initialize worker queue");
|
||||
}
|
||||
|
||||
this.#runner?.events.on("pool:create", ({ workerPool }) => {
|
||||
this.#logDebug("pool:create");
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:listen:connecting", ({ workerPool, attempts }) => {
|
||||
this.#logDebug("pool:create", { attempts });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:listen:success", ({ workerPool, client }) => {
|
||||
this.#logDebug("pool:listen:success");
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:listen:error", ({ error }) => {
|
||||
this.#logDebug("pool:listen:error", { error });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:gracefulShutdown", ({ message }) => {
|
||||
this.#logDebug("pool:gracefulShutdown", { workerMessage: message });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:gracefulShutdown:error", ({ error }) => {
|
||||
this.#logDebug("pool:gracefulShutdown:error", { error });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("worker:create", ({ worker }) => {
|
||||
this.#logDebug("worker:create", { workerId: worker.workerId });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("worker:release", ({ worker }) => {
|
||||
this.#logDebug("worker:release", { workerId: worker.workerId });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("worker:stop", ({ worker, error }) => {
|
||||
this.#logDebug("worker:stop", { workerId: worker.workerId, error });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("worker:fatalError", ({ worker, error, jobError }) => {
|
||||
this.#logDebug("worker:fatalError", { workerId: worker.workerId, error, jobError });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("gracefulShutdown", ({ signal }) => {
|
||||
this.#logDebug("gracefulShutdown", { signal });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("stop", () => {
|
||||
this.#logDebug("stop");
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -105,23 +189,34 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
payload: z.infer<TMessageCatalog[K]>,
|
||||
options?: ZodWorkerEnqueueOptions
|
||||
): Promise<GraphileJob> {
|
||||
if (!this.#runner) {
|
||||
throw new Error("Worker not initialized");
|
||||
}
|
||||
|
||||
const task = this.#tasks[identifier];
|
||||
|
||||
const optionsWithoutTx = omit(options ?? {}, ["tx"]);
|
||||
const taskWithoutJobKey = omit(task, ["jobKey"]);
|
||||
|
||||
const spec = {
|
||||
...optionsWithoutTx,
|
||||
...task,
|
||||
...taskWithoutJobKey,
|
||||
};
|
||||
|
||||
if (typeof task.queueName === "function") {
|
||||
spec.queueName = task.queueName(payload);
|
||||
}
|
||||
|
||||
if (typeof task.jobKey === "function") {
|
||||
const jobKey = task.jobKey(payload);
|
||||
|
||||
if (jobKey) {
|
||||
spec.jobKey = jobKey;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Enqueuing worker task", {
|
||||
identifier,
|
||||
payload,
|
||||
spec,
|
||||
});
|
||||
|
||||
const job = await this.#addJob(
|
||||
identifier as string,
|
||||
payload,
|
||||
@@ -139,6 +234,17 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return job;
|
||||
}
|
||||
|
||||
public async dequeue(
|
||||
jobKey: string,
|
||||
option?: ZodWorkerDequeueOptions
|
||||
): Promise<GraphileJob | undefined> {
|
||||
const results = await this.#removeJob(jobKey, option?.tx ?? this.#prisma);
|
||||
|
||||
logger.debug("dequeued worker task", { results, jobKey });
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async #addJob(
|
||||
identifier: string,
|
||||
payload: unknown,
|
||||
@@ -164,8 +270,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
spec.maxAttempts || null,
|
||||
spec.jobKey || null,
|
||||
spec.priority || null,
|
||||
spec.jobKeyMode || null,
|
||||
spec.flags || null
|
||||
spec.flags || null,
|
||||
spec.jobKeyMode || null
|
||||
);
|
||||
|
||||
const rows = AddJobResultsSchema.safeParse(results);
|
||||
@@ -181,6 +287,32 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return job as GraphileJob;
|
||||
}
|
||||
|
||||
async #removeJob(jobKey: string, tx: PrismaClientOrTransaction) {
|
||||
try {
|
||||
const result = await tx.$queryRawUnsafe(
|
||||
`SELECT * FROM graphile_worker.remove_job(
|
||||
job_key => $1::text
|
||||
)`,
|
||||
jobKey
|
||||
);
|
||||
const job = AddJobResultsSchema.safeParse(result);
|
||||
|
||||
if (!job.success) {
|
||||
logger.debug("results returned from remove_job could not be parsed", {
|
||||
error: job.error.flatten(),
|
||||
result,
|
||||
jobKey,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return job.data[0] as GraphileJob;
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to remove job from queue, ${e}}`);
|
||||
}
|
||||
}
|
||||
|
||||
#createTaskListFromTasks() {
|
||||
const taskList: TaskList = {};
|
||||
|
||||
@@ -192,9 +324,38 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
taskList[key] = task;
|
||||
}
|
||||
|
||||
for (const [key] of Object.entries(this.#recurringTasks ?? {})) {
|
||||
const task: Task = (payload, helpers) => {
|
||||
return this.#handleRecurringTask(key, payload, helpers);
|
||||
};
|
||||
|
||||
taskList[key] = task;
|
||||
}
|
||||
|
||||
return taskList;
|
||||
}
|
||||
|
||||
#createCronItemsFromRecurringTasks() {
|
||||
const cronItems: CronItem[] = [];
|
||||
|
||||
if (!this.#recurringTasks) {
|
||||
return cronItems;
|
||||
}
|
||||
|
||||
for (const [key, task] of Object.entries(this.#recurringTasks)) {
|
||||
const cronItem: CronItem = {
|
||||
pattern: task.pattern,
|
||||
identifier: key,
|
||||
task: key,
|
||||
options: task.options,
|
||||
};
|
||||
|
||||
cronItems.push(cronItem);
|
||||
}
|
||||
|
||||
return cronItems;
|
||||
}
|
||||
|
||||
async #handleMessage<K extends keyof TMessageCatalog>(
|
||||
typeName: K,
|
||||
rawPayload: unknown,
|
||||
@@ -226,4 +387,49 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
await task.handler(payload, job);
|
||||
}
|
||||
|
||||
async #handleRecurringTask(
|
||||
typeName: string,
|
||||
rawPayload: unknown,
|
||||
helpers: JobHelpers
|
||||
): Promise<void> {
|
||||
const job = helpers.job;
|
||||
|
||||
logger.debug("Received recurring task, calling handler", {
|
||||
type: String(typeName),
|
||||
payload: rawPayload,
|
||||
job,
|
||||
});
|
||||
|
||||
const recurringTask = this.#recurringTasks?.[typeName];
|
||||
|
||||
if (!recurringTask) {
|
||||
throw new Error(`No recurring task for message type: ${String(typeName)}`);
|
||||
}
|
||||
|
||||
const parsedPayload = RawCronPayloadSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsedPayload.success) {
|
||||
throw new Error(
|
||||
`Failed to parse recurring task payload: ${JSON.stringify(parsedPayload.error)}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = parsedPayload.data;
|
||||
|
||||
try {
|
||||
await recurringTask.handler(payload._cron, job);
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle recurring task", {
|
||||
error,
|
||||
payload,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#logDebug(message: string, args?: any) {
|
||||
logger.debug(`[worker][${this.#name}] ${message}`, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getUser } from "./services/session.server";
|
||||
import { appEnvTitleTag } from "./utils";
|
||||
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
|
||||
import { useHighlight } from "./hooks/useHighlight";
|
||||
import { ExternalScripts } from "remix-utils";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
|
||||
@@ -104,6 +105,7 @@ function App() {
|
||||
</HighlightErrorBoundary>
|
||||
<Toast />
|
||||
<ScrollRestoration />
|
||||
<ExternalScripts />
|
||||
<Scripts />
|
||||
<LiveReload />
|
||||
</body>
|
||||
|
||||
+197
-150
@@ -1,13 +1,17 @@
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { ArrowUpIcon } from "@heroicons/react/24/solid";
|
||||
import { LoaderArgs, SerializeFrom } from "@remix-run/server-runtime";
|
||||
import useWindowSize from "react-use/lib/useWindowSize";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import Confetti from "react-confetti";
|
||||
import { ExternalScriptsFunction } from "remix-utils";
|
||||
import { HowToSetupYourProject } from "~/components/helpContent/HelpContentText";
|
||||
import { JobsTable } from "~/components/jobs/JobsTable";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BreadcrumbLink } from "~/components/navigation/NavBar";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import {
|
||||
PageHeader,
|
||||
PageInfoGroup,
|
||||
@@ -17,24 +21,19 @@ import {
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useFilterJobs } from "~/hooks/useFilterJobs";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import useWindowSize from "react-use/lib/useWindowSize";
|
||||
import {
|
||||
docsPath,
|
||||
ProjectParamSchema,
|
||||
projectIntegrationsPath,
|
||||
trimTrailingSlash,
|
||||
ProjectParamSchema,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { BreadcrumbLink } from "~/components/navigation/NavBar";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { GitHubLightIcon, OpenAILightIcon, ResendIcon } from "@trigger.dev/companyicons";
|
||||
import { ClockIcon, CalendarDaysIcon, SlackIcon } from "lucide-react";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -59,6 +58,12 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => <BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Jobs" />,
|
||||
expandSidebar: true,
|
||||
scripts: (match) => [
|
||||
{
|
||||
src: "https://cdn.jsdelivr.net/npm/canvas-confetti@1.5.1/dist/confetti.browser.min.js",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
@@ -83,94 +88,50 @@ export default function Page() {
|
||||
</PageInfoRow>
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
{/* Todo: this confetti component needs to trigger when the example project is created, then never again. */}
|
||||
{/* <Confetti
|
||||
width={width}
|
||||
height={height}
|
||||
recycle={false}
|
||||
numberOfPieces={1000}
|
||||
colors={[
|
||||
"#E7FF52",
|
||||
"#41FF54",
|
||||
"rgb(245 158 11)",
|
||||
"rgb(22 163 74)",
|
||||
"rgb(37 99 235)",
|
||||
"rgb(67 56 202)",
|
||||
"rgb(219 39 119)",
|
||||
"rgb(225 29 72)",
|
||||
"rgb(217 70 239)",
|
||||
]}
|
||||
/> */}
|
||||
<Help defaultOpen={jobs.length === 0}>
|
||||
<Help>
|
||||
{(open) => (
|
||||
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div>
|
||||
{jobs.length > 0 && jobs.some((j) => j.hasIntegrationsRequiringAction) && (
|
||||
<Callout
|
||||
variant="error"
|
||||
to={projectIntegrationsPath(organization, project)}
|
||||
className="mb-2"
|
||||
>
|
||||
Some of your Jobs have Integrations that have not been configured.
|
||||
</Callout>
|
||||
)}
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
{jobs.length === 0 ? (
|
||||
<Header2>Jobs</Header2>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="Search Jobs"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<HelpTrigger title="How do I add Trigger.dev to my Next.js app?" />
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
"flex w-full flex-col justify-center gap-x-4 rounded-md border border-dashed border-indigo-800 px-5 py-8"
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small">Your Jobs will appear here.</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("grid gap-4", open ? "h-full grid-cols-2" : " h-full grid-cols-1")}>
|
||||
<div className="h-full">
|
||||
{jobs.length > 0 ? (
|
||||
<>
|
||||
{jobs.some((j) => j.hasIntegrationsRequiringAction) && (
|
||||
<Callout
|
||||
variant="error"
|
||||
to={projectIntegrationsPath(organization, project)}
|
||||
className="mb-2"
|
||||
>
|
||||
Some of your Jobs have Integrations that have not been configured.
|
||||
</Callout>
|
||||
)}
|
||||
<div className="mb-2 flex flex-col">
|
||||
<Header2 spacing>Jobs</Header2>
|
||||
<div className="flex w-full">
|
||||
<Input
|
||||
placeholder="Search Jobs"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
<HelpTrigger title="Example Jobs and inspiration" />
|
||||
</div>
|
||||
</div>
|
||||
<JobsTable
|
||||
jobs={filteredItems}
|
||||
noResultsText={`No Jobs match ${filterText}. Try a different search
|
||||
query.`}
|
||||
query.`}
|
||||
/>
|
||||
{jobs.length === 1 ? (
|
||||
<>
|
||||
<Callout
|
||||
variant="docs"
|
||||
to={docsPath("documentation/guides/create-a-job")}
|
||||
className="my-3"
|
||||
>
|
||||
Create your first Job in code
|
||||
</Callout>
|
||||
<ExampleJobs />
|
||||
</>
|
||||
) : (
|
||||
<Callout
|
||||
variant="docs"
|
||||
to={docsPath("documentation/guides/create-a-job")}
|
||||
className="my-3"
|
||||
>
|
||||
Create another Job
|
||||
</Callout>
|
||||
{jobs.length === 1 && jobs.every((r) => r.lastRun === undefined) && (
|
||||
<RunYourJobPrompt />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<HowToSetupYourProject />
|
||||
)}
|
||||
</div>
|
||||
<HelpContent title="How to add Trigger.dev to your Next.js app (3 mins)">
|
||||
<HowToSetupYourProject />
|
||||
<HelpContent title="Example Jobs and inspiration">
|
||||
<ExampleJobs />
|
||||
</HelpContent>
|
||||
</div>
|
||||
)}
|
||||
@@ -180,69 +141,155 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function ExampleJobs() {
|
||||
function RunYourJobPrompt() {
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col gap-y-2 rounded bg-slate-900 p-4">
|
||||
<Header2 className="text-slate-300">Example Jobs</Header2>
|
||||
<Paragraph variant="small">
|
||||
If you want more inspiration or just want to dig into the code of a Job, check out our{" "}
|
||||
<TextLink href="https://github.com/triggerdotdev/examples">examples repo</TextLink>.
|
||||
<div className="mt-2 flex w-full gap-x-2 rounded border border-slate-800 bg-slate-900 p-4 pl-6">
|
||||
<ArrowUpIcon className="h-5 w-5 animate-bounce text-green-500" />
|
||||
<Paragraph variant="small" className="text-green-500">
|
||||
Your Job is ready to run! Click it to run it now.
|
||||
</Paragraph>
|
||||
<div className="h-[1px] w-full bg-slate-800" />
|
||||
<div className="flex gap-1.5">
|
||||
<ClockIcon className="h-4 w-4 pt-0.5 text-slate-100" />
|
||||
<Paragraph variant="small">
|
||||
<TextLink href="https://github.com/triggerdotdev/examples/tree/main/delays">
|
||||
Delays
|
||||
</TextLink>{" "}
|
||||
- Using delays inside Jobs
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<CalendarDaysIcon className="h-4 w-4 pt-0.5 text-slate-100" />
|
||||
<Paragraph variant="small">
|
||||
<TextLink href="https://github.com/triggerdotdev/examples/tree/main/scheduled">
|
||||
Scheduled
|
||||
</TextLink>{" "}
|
||||
- Interval and cron scheduled Jobs
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<GitHubLightIcon className="ml-0.5 h-4 w-4 pt-0.5" />
|
||||
<Paragraph variant="small">
|
||||
<TextLink href="https://github.com/triggerdotdev/examples/tree/main/github">
|
||||
GitHub
|
||||
</TextLink>{" "}
|
||||
- When a new GitHub issue is opened it adds a “Bug” label to it.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<OpenAILightIcon className="ml-0.5 h-4 w-4 pt-0.5" />
|
||||
<Paragraph variant="small">
|
||||
<TextLink href="https://github.com/triggerdotdev/examples/tree/main/openai">
|
||||
OpenAI
|
||||
</TextLink>{" "}
|
||||
- Generate images and jokes from a prompt
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<ResendIcon className="ml-0.5 h-4 w-4 pt-0.5" />
|
||||
<Paragraph variant="small">
|
||||
<TextLink href="https://github.com/triggerdotdev/examples/tree/main/resend">
|
||||
Resend
|
||||
</TextLink>{" "}
|
||||
- Sends an email by submitting a form in the Next.js app
|
||||
</Paragraph>
|
||||
</div>{" "}
|
||||
<div className="flex gap-1.5">
|
||||
<SlackIcon className="ml-0.5 h-4 w-4 pt-0.5" />
|
||||
<Paragraph variant="small">
|
||||
<TextLink href="https://github.com/triggerdotdev/examples/tree/main/slack">
|
||||
Slack
|
||||
</TextLink>{" "}
|
||||
- Sends a Slack message when an event is received
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExampleJobs() {
|
||||
return (
|
||||
<>
|
||||
<Header2 spacing>Video walk-through</Header2>
|
||||
<Paragraph spacing variant="small">
|
||||
Watch Matt, CEO of Trigger.dev create a GitHub issue reminder in Slack using Trigger.dev.
|
||||
(10 mins)
|
||||
</Paragraph>
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/uocBQt2HeQo?&showinfo=0&rel=0&modestbranding=1"
|
||||
title="Trigger.dev explainer video"
|
||||
width="400"
|
||||
height="250"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
className="mb-4 w-full border-b border-slate-800"
|
||||
/>
|
||||
<Header2 spacing>How to create a Job</Header2>
|
||||
<Paragraph variant="small" spacing>
|
||||
Our docs are a great way to learn how to create Jobs with each type of Trigger, from
|
||||
webhooks, to delays, to triggering Jobs on a schedule.{" "}
|
||||
</Paragraph>
|
||||
<a
|
||||
href="https://trigger.dev/docs/documentation/guides/create-a-job"
|
||||
className="mb-4 flex w-full items-center rounded border-b border-slate-800 py-2 transition hover:border-transparent hover:bg-slate-800"
|
||||
>
|
||||
<NamedIcon name={"external-link"} className={iconStyles} />
|
||||
<Paragraph variant="small" className="font-semibold text-bright">
|
||||
How to create a Job
|
||||
</Paragraph>
|
||||
</a>
|
||||
<Header2 spacing>Check out some example Jobs in code</Header2>
|
||||
<Paragraph spacing variant="small">
|
||||
If you're looking for inspiration for your next Job, check out our{" "}
|
||||
<TextLink href="https://github.com/triggerdotdev/examples">examples repo</TextLink>. Or jump
|
||||
straight into an example repo from the list below:
|
||||
</Paragraph>
|
||||
<div className="flex w-full flex-col">
|
||||
{examples.map((example) => (
|
||||
<a
|
||||
href={example.codeLink}
|
||||
key={example.title}
|
||||
className="flex w-full items-center rounded border-b border-slate-800 py-2 transition hover:border-transparent hover:bg-slate-800"
|
||||
>
|
||||
{example.icon}
|
||||
<Paragraph variant="small">
|
||||
<span className="font-semibold text-bright">{example.title}</span> -{" "}
|
||||
{example.description}
|
||||
</Paragraph>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const iconStyles = "h-7 w-7 mr-2 pl-2 min-w-[28px]";
|
||||
|
||||
const examples = [
|
||||
{
|
||||
icon: <NamedIcon name={"clock"} className={iconStyles} />,
|
||||
title: "Basic delay",
|
||||
description: "Logs a message to the console, waits 5 minutes, and then logs another message.",
|
||||
codeLink: "https://github.com/triggerdotdev/examples/blob/main/delays/src/jobs/delayJob.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="calendar" className={iconStyles} />,
|
||||
title: "Basic interval",
|
||||
description: "This Job runs every 60 seconds, starting 60 seconds after it is first indexed.",
|
||||
codeLink: "https://github.com/triggerdotdev/examples/blob/main/scheduled/src/jobs/interval.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="calendar" className={iconStyles} />,
|
||||
title: "Cron scheduled interval",
|
||||
description: "A scheduled Job which runs at 2:30pm every Monday.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/scheduled/src/jobs/cronScheduled.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="openai" className={iconStyles} />,
|
||||
title: "OpenAI text summarizer",
|
||||
description:
|
||||
"Summarizes a block of text, pulling out the most unique / helpful points using OpenAI.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/openai-text-summarizer/src/jobs/textSummarizer.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="openai" className={iconStyles} />,
|
||||
title: "Tell me a joke using OpenAI",
|
||||
description: "Generates a random joke using OpenAI GPT 3.5.",
|
||||
codeLink: "https://github.com/triggerdotdev/examples/blob/main/openai/src/jobs/tellMeAJoke.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="openai" className={iconStyles} />,
|
||||
title: "Generate a random image using OpenAI",
|
||||
description: "Generates a random image of a hedgehog using OpenAI DALL-E.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/openai/src/jobs/generateHedgehogImages.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="resend" className={iconStyles} />,
|
||||
title: "Send an email using Resend",
|
||||
description: "Send a basic email using Resend.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/resend/src/jobs/resendBasicEmail.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="github" className={iconStyles} />,
|
||||
title: "GitHub issue reminder",
|
||||
description: "Sends a Slack message if a GitHub issue is left for 24h.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/github-issue-reminder/jobs/githubIssue.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="github" className={iconStyles} />,
|
||||
title: "Github new star alert in Slack",
|
||||
description: "When a repo is starred, a message is sent to a Slack.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/github/src/jobs/newStarToSlack.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="github" className={iconStyles} />,
|
||||
title: "Add a custom label to a GitHub issue",
|
||||
description: "When a new GitHub issue is opened it adds a “Bug” label to it.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/github/src/jobs/onIssueOpened.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="github" className={iconStyles} />,
|
||||
title: "GitHub new star alert",
|
||||
description: "When a repo is starred a message is logged with the new Stargazers count.",
|
||||
codeLink: "https://github.com/triggerdotdev/examples/blob/main/github/src/jobs/newStarAlert.ts",
|
||||
},
|
||||
{
|
||||
icon: <NamedIcon name="slack" className={iconStyles} />,
|
||||
title: "Send a Slack message",
|
||||
description: "Sends a Slack message to a specific channel when an event is received.",
|
||||
codeLink:
|
||||
"https://github.com/triggerdotdev/examples/blob/main/slack/src/jobs/sendSlackMessage.ts",
|
||||
},
|
||||
];
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { conform, useForm, useInputEvent } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Button, ButtonContent } from "~/components/primitives/Buttons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTrigger,
|
||||
} from "~/components/primitives/Sheet";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
import { bodySchema } from "../resources.projects.$projectId.endpoint";
|
||||
import { RuntimeEnvironment, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
type FirstEndpointSheetProps = {
|
||||
projectId: string;
|
||||
environments: { id: string; type: RuntimeEnvironmentType }[];
|
||||
};
|
||||
|
||||
export function FirstEndpointSheet({ projectId, environments }: FirstEndpointSheetProps) {
|
||||
const setEndpointUrlFetcher = useFetcher();
|
||||
const [form, { url, environmentId }] = useForm({
|
||||
id: "new-endpoint-url",
|
||||
lastSubmission: setEndpointUrlFetcher.data,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: bodySchema });
|
||||
},
|
||||
});
|
||||
|
||||
const loadingEndpointUrl = setEndpointUrlFetcher.state !== "idle";
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger>
|
||||
<ButtonContent variant={"primary/medium"}>Add your first endpoint</ButtonContent>
|
||||
</SheetTrigger>
|
||||
<SheetContent size="lg">
|
||||
<SheetHeader>
|
||||
<div>
|
||||
<Header1>Add your first endpoint</Header1>
|
||||
<Paragraph variant="small">
|
||||
We recommend you use{" "}
|
||||
<TextLink href={docsPath("documentation/guides/cli")}>the CLI</TextLink> when working
|
||||
in development.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<setEndpointUrlFetcher.Form
|
||||
method="post"
|
||||
action={`/resources/projects/${projectId}/endpoint`}
|
||||
{...form.props}
|
||||
>
|
||||
<InputGroup className="mb-4 max-w-none">
|
||||
<Header2>Environment type</Header2>
|
||||
<SelectGroup>
|
||||
<Select name={"environmentId"} defaultValue={environments[0].id}>
|
||||
<SelectTrigger size="secondary/small">
|
||||
<SelectValue placeholder="Select environment" className="m-0 p-0" /> Environment
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{environments.map((environment) => (
|
||||
<SelectItem key={environment.id} value={environment.id}>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<FormError id={environmentId.errorId}>{environmentId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-none">
|
||||
<Header2>Endpoint URL</Header2>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="rounded-r-none"
|
||||
{...conform.input(url, { type: "url" })}
|
||||
placeholder="URL for your Trigger API route"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
className="rounded-l-none"
|
||||
disabled={loadingEndpointUrl}
|
||||
LeadingIcon={loadingEndpointUrl ? "spinner-white" : undefined}
|
||||
>
|
||||
{loadingEndpointUrl ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
<FormError id={url.errorId}>{url.error}</FormError>
|
||||
<FormError id={form.errorId}>{form.error}</FormError>
|
||||
<Hint>
|
||||
This is the URL of your Trigger API route, Typically this would be:{" "}
|
||||
<InlineCode variant="extra-small">https://yourdomain.com/api/trigger</InlineCode>.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</setEndpointUrlFetcher.Form>
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
+16
-3
@@ -7,7 +7,7 @@ import { EnvironmentLabel, environmentTitle } from "~/components/environments/En
|
||||
import { HowToUseApiKeysAndEndpoints } from "~/components/helpContent/HelpContentText";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BreadcrumbLink } from "~/components/navigation/NavBar";
|
||||
import { ButtonContent } from "~/components/primitives/Buttons";
|
||||
import { Button, ButtonContent } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
@@ -39,6 +39,7 @@ import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { RuntimeEnvironmentType } from "../../../../../packages/database/src";
|
||||
import { ConfigureEndpointSheet } from "./ConfigureEndpointSheet";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { FirstEndpointSheet } from "./FirstEndpointSheet";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -95,6 +96,13 @@ export default function Page() {
|
||||
};
|
||||
}, [selected, clients]);
|
||||
|
||||
const isAnyClientFullyConfigured = useMemo(() => {
|
||||
return clients.some((client) => {
|
||||
const { DEVELOPMENT, PRODUCTION } = client.endpoints;
|
||||
return PRODUCTION.state === "configured" && DEVELOPMENT.state === PRODUCTION.state;
|
||||
});
|
||||
}, [clients]);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
@@ -119,7 +127,7 @@ export default function Page() {
|
||||
<PageDescription>API Keys and endpoints for your environments.</PageDescription>
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<Help defaultOpen>
|
||||
<Help defaultOpen={!isAnyClientFullyConfigured}>
|
||||
{(open) => (
|
||||
<div className={cn("grid h-full gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div>
|
||||
@@ -202,7 +210,12 @@ export default function Page() {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Paragraph>You have no clients yet</Paragraph>
|
||||
<>
|
||||
<Paragraph>Add your first endpoint</Paragraph>
|
||||
<Paragraph>
|
||||
<FirstEndpointSheet projectId={project.id} environments={environments} />
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{selectedEndpoint && (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ProjectsMenu } from "~/components/navigation/ProjectsMenu";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { ProjectPresenter } from "~/presenters/ProjectPresenter.server";
|
||||
import { analytics } from "~/services/analytics.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
@@ -33,7 +33,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
analytics.project.identify({ project });
|
||||
telemetry.project.identify({ project });
|
||||
|
||||
return typedjson({
|
||||
project,
|
||||
|
||||
@@ -6,7 +6,7 @@ import invariant from "tiny-invariant";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { getOrganizationFromSlug } from "~/models/organization.server";
|
||||
import { analytics } from "~/services/analytics.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { commitCurrentOrgSession, setCurrentOrg } from "~/services/currentOrganization.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath } from "~/utils/pathBuilder";
|
||||
@@ -25,7 +25,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
analytics.organization.identify({ organization });
|
||||
telemetry.organization.identify({ organization });
|
||||
|
||||
const session = await setCurrentOrg(organization.slug, request);
|
||||
|
||||
|
||||
@@ -6,26 +6,17 @@ import { ImpersonationBanner } from "~/components/ImpersonationBanner";
|
||||
import { NoMobileOverlay } from "~/components/NoMobileOverlay";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar } from "~/components/navigation/NavBar";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useIsProjectChildPage } from "~/hooks/useIsProjectChildPage";
|
||||
import { getOrganizations } from "~/models/organization.server";
|
||||
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { confirmBasicDetailsPath, invitationCodePath } from "~/utils/pathBuilder";
|
||||
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const organizations = await getOrganizations({ userId: user.id });
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
const features = featuresForRequest(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (features.isManagedCloud && !user.invitationCodeId && url.pathname !== invitationCodePath()) {
|
||||
throw redirect(invitationCodePath());
|
||||
}
|
||||
|
||||
//you have to confirm basic details before you can do anything
|
||||
if (!user.confirmedBasicDetails) {
|
||||
|
||||
@@ -107,6 +107,32 @@ export class RunTaskService {
|
||||
});
|
||||
|
||||
if (existingTask) {
|
||||
if (existingTask.status === "CANCELED") {
|
||||
const existingTaskStatus =
|
||||
(taskBody.delayUntil && taskBody.delayUntil.getTime() > Date.now()) || taskBody.trigger
|
||||
? "WAITING"
|
||||
: taskBody.noop
|
||||
? "COMPLETED"
|
||||
: "RUNNING";
|
||||
|
||||
const resumedExistingTask = await tx.task.update({
|
||||
where: {
|
||||
id: existingTask.id,
|
||||
},
|
||||
data: {
|
||||
status: existingTaskStatus,
|
||||
startedAt: new Date(),
|
||||
completedAt: existingTaskStatus === "COMPLETED" ? new Date() : undefined,
|
||||
},
|
||||
include: {
|
||||
run: true,
|
||||
attempts: true,
|
||||
},
|
||||
});
|
||||
|
||||
return resumedExistingTask;
|
||||
}
|
||||
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { ActionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar } from "~/components/navigation/NavBar";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { grantUserCloudAccess } from "~/models/user.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationsPath } from "~/utils/pathBuilder";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
isValidCode?: (code: string) => Promise<boolean>;
|
||||
} = {}
|
||||
) {
|
||||
return z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1, "Invite code missing")
|
||||
.superRefine((code, ctx) => {
|
||||
if (constraints.isValidCode === undefined) {
|
||||
//client-side validation skips this
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
// Tell zod this is an async validation by returning the promise
|
||||
return constraints.isValidCode(code).then((isValid) => {
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid invitation code",
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const formData = await request.formData();
|
||||
|
||||
const formSchema = createSchema({
|
||||
isValidCode: async (code) => {
|
||||
const invitationCode = await prisma.invitationCode.findUnique({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
|
||||
return invitationCode !== undefined && invitationCode !== null;
|
||||
},
|
||||
});
|
||||
|
||||
const submission = await parse(formData, { schema: formSchema, async: true });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
await grantUserCloudAccess({
|
||||
id: userId,
|
||||
inviteCode: submission.value.code,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationsPath(),
|
||||
request,
|
||||
"🚀 Welcome to the Trigger.dev Cloud private beta"
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { code }] = useForm({
|
||||
id: "invitation-code",
|
||||
lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: createSchema() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppContainer showBackgroundGradient={true}>
|
||||
<NavBar />
|
||||
<MainCenteredContainer>
|
||||
<FormTitle
|
||||
LeadingIcon="qr-code"
|
||||
title="Trigger.dev Cloud Beta"
|
||||
description={
|
||||
<>
|
||||
Enter your code to login and access now.
|
||||
<br /> No code yet? You can{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
href="https://bcymafitv0e.typeform.com/to/QQnotGJM#source=cloud-beta"
|
||||
>
|
||||
request a code
|
||||
</TextLink>{" "}
|
||||
or get started now by{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
href="https://trigger.dev/docs/documentation/guides/self-hosting"
|
||||
trailingIcon="external-link"
|
||||
trailingIconClassName="h-3 w-3 text-indigo-500 transition group-hover:text-indigo-400"
|
||||
>
|
||||
self-hosting
|
||||
</TextLink>{" "}
|
||||
Trigger.dev.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...conform.input(code, { type: "text" })}
|
||||
placeholder="Your super secret invite code"
|
||||
icon="qr-code"
|
||||
autoFocus={Boolean(code.initialError)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<FormError id={code.errorId}>{code.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon={"arrow-right"}>
|
||||
Get access
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,12 @@ export async function action({ request, params }: ActionArgs) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return json(e, { status: 400 });
|
||||
if (e instanceof Error) {
|
||||
submission.error.url = `${e.name}: ${e.message}`;
|
||||
} else {
|
||||
submission.error.url = "Unknown error";
|
||||
}
|
||||
|
||||
return json(submission, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
CreateEndpointError,
|
||||
CreateEndpointService,
|
||||
} from "~/services/endpoints/createEndpoint.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { RuntimeEnvironmentTypeSchema } from "@trigger.dev/core";
|
||||
import { env } from "process";
|
||||
import { ValidateCreateEndpointService } from "~/services/endpoints/validateCreateEndpoint.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
export const bodySchema = z.object({
|
||||
environmentId: z.string(),
|
||||
url: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectId } = ParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: bodySchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
where: {
|
||||
id: submission.value.environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
submission.error.environmentId = "Environment not found";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const service = new ValidateCreateEndpointService();
|
||||
const result = await service.call({
|
||||
url: submission.value.url,
|
||||
environment,
|
||||
});
|
||||
|
||||
return json(submission);
|
||||
} catch (e) {
|
||||
if (e instanceof CreateEndpointError) {
|
||||
submission.error.url = e.message;
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
submission.error.url = `${e.name}: ${e.message}`;
|
||||
} else {
|
||||
submission.error.url = "Unknown error";
|
||||
}
|
||||
|
||||
return json(submission, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { sse } from "~/utils/sse";
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
await requireUserId(request);
|
||||
|
||||
const { projectId } = z.object({ projectId: z.string() }).parse(params);
|
||||
|
||||
const project = await projectForUpdates(projectId);
|
||||
|
||||
if (!project) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
let lastSignals = calculateChangeSignals(project);
|
||||
|
||||
return sse({
|
||||
request,
|
||||
run: async (send, stop) => {
|
||||
const result = await projectForUpdates(projectId);
|
||||
if (!result) {
|
||||
return stop();
|
||||
}
|
||||
|
||||
const newSignals = calculateChangeSignals(result);
|
||||
|
||||
if (lastSignals.jobCount !== newSignals.jobCount) {
|
||||
send({ data: JSON.stringify(newSignals) });
|
||||
}
|
||||
|
||||
lastSignals = newSignals;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function projectForUpdates(id: string) {
|
||||
return prisma.project.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { jobs: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function calculateChangeSignals(
|
||||
project: NonNullable<Awaited<ReturnType<typeof projectForUpdates>>>
|
||||
) {
|
||||
const jobCount = project._count?.jobs ?? 0;
|
||||
|
||||
return {
|
||||
jobCount,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { parse } from "@conform-to/zod";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CancelRunService } from "~/services/runs/cancelRun.server";
|
||||
|
||||
export const cancelSchema = z.object({
|
||||
@@ -31,7 +32,19 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
request,
|
||||
`Canceled run. Any pending tasks will be canceled.`
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to cancel run", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
} else {
|
||||
logger.error("Failed to cancel run", { error });
|
||||
return json({ errors: { body: "Unknown error" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { sse } from "~/utils/sse";
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const options = z
|
||||
.object({
|
||||
minDelay: z.coerce.number().int(),
|
||||
maxDelay: z.coerce.number().int(),
|
||||
undefinedProbability: z.coerce.number().min(0).max(1).default(0.1),
|
||||
})
|
||||
.parse(searchParams);
|
||||
|
||||
logger.debug("Test SSE stream", { options });
|
||||
|
||||
let lastSignals = calculateChangeSignals(Date.now());
|
||||
|
||||
return sse({
|
||||
request,
|
||||
run: async (send, stop) => {
|
||||
const result = await dateForUpdates(options);
|
||||
|
||||
if (!result) {
|
||||
return stop();
|
||||
}
|
||||
|
||||
const newSignals = calculateChangeSignals(result);
|
||||
|
||||
if (lastSignals.ts !== newSignals.ts) {
|
||||
send({ data: JSON.stringify(newSignals) });
|
||||
}
|
||||
|
||||
lastSignals = newSignals;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function dateForUpdates(opts: {
|
||||
minDelay: number;
|
||||
maxDelay: number;
|
||||
undefinedProbability: number;
|
||||
}): Promise<number | undefined> {
|
||||
// Randomly await between minDelay and maxDelay
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.random() * (opts.maxDelay - opts.minDelay) + opts.minDelay);
|
||||
});
|
||||
|
||||
// There should be about a x% chance that this returns undefined
|
||||
if (Math.random() < opts.undefinedProbability) {
|
||||
logger.debug("Test SSE dataForUpdates returning undefined");
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Randomly return true or false
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function calculateChangeSignals(ts: number) {
|
||||
return {
|
||||
ts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { useEventSource } from "remix-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const config = z
|
||||
.object({
|
||||
minDelay: z.coerce.number().int().min(0).max(10000).default(1000),
|
||||
maxDelay: z.coerce.number().int().min(0).max(10000).default(2000),
|
||||
undefinedProbability: z.coerce.number().min(0).max(1).default(0.1),
|
||||
})
|
||||
.parse(params);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export default function SSETest() {
|
||||
const { minDelay, maxDelay, undefinedProbability } = useLoaderData<typeof loader>();
|
||||
|
||||
const events = useEventSource(
|
||||
`/tests/sse/stream?minDelay=${minDelay}&maxDelay=${maxDelay}&undefinedProbability=${undefinedProbability}`,
|
||||
{
|
||||
event: "message",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>SSE Test</h2>
|
||||
<p>{events ?? "No events"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
import { env } from "~/env.server";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { Project } from "~/models/project.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { User } from "~/models/user.server";
|
||||
|
||||
class BehaviouralAnalytics {
|
||||
client: PostHog | undefined = undefined;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
if (!apiKey) {
|
||||
console.log("No PostHog API key, so analytics won't track");
|
||||
return;
|
||||
}
|
||||
this.client = new PostHog(apiKey, { host: "https://app.posthog.com" });
|
||||
}
|
||||
|
||||
user = {
|
||||
identify: ({ user, isNewUser }: { user: User; isNewUser: boolean }) => {
|
||||
if (this.client === undefined) return;
|
||||
this.client.identify({
|
||||
distinctId: user.id,
|
||||
properties: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
if (isNewUser) {
|
||||
this.#capture({
|
||||
userId: user.id,
|
||||
event: "user created",
|
||||
eventProperties: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
organization = {
|
||||
identify: ({ organization }: { organization: Organization }) => {
|
||||
if (this.client === undefined) return;
|
||||
this.client.groupIdentify({
|
||||
groupType: "organization",
|
||||
groupKey: organization.id,
|
||||
properties: {
|
||||
name: organization.title,
|
||||
slug: organization.slug,
|
||||
createdAt: organization.createdAt,
|
||||
updatedAt: organization.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
new: ({
|
||||
userId,
|
||||
organization,
|
||||
organizationCount,
|
||||
}: {
|
||||
userId: string;
|
||||
organization: Organization;
|
||||
organizationCount: number;
|
||||
}) => {
|
||||
if (this.client === undefined) return;
|
||||
this.#capture({
|
||||
userId,
|
||||
event: "organization created",
|
||||
organizationId: organization.id,
|
||||
eventProperties: {
|
||||
id: organization.id,
|
||||
slug: organization.slug,
|
||||
title: organization.title,
|
||||
createdAt: organization.createdAt,
|
||||
updatedAt: organization.updatedAt,
|
||||
},
|
||||
userProperties: {
|
||||
organizationCount: organizationCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
project = {
|
||||
identify: ({ project }: { project: Project }) => {
|
||||
if (this.client === undefined) return;
|
||||
this.client.groupIdentify({
|
||||
groupType: "project",
|
||||
groupKey: project.id,
|
||||
properties: {
|
||||
name: project.name,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
new: ({
|
||||
userId,
|
||||
organizationId,
|
||||
project,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
project: Project;
|
||||
}) => {
|
||||
if (this.client === undefined) return;
|
||||
this.#capture({
|
||||
userId,
|
||||
event: "project created",
|
||||
organizationId,
|
||||
eventProperties: {
|
||||
id: project.id,
|
||||
|
||||
title: project.name,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
//todo Job
|
||||
// workflow = {
|
||||
// identify: ({ workflow }: { workflow: Workflow }) => {
|
||||
// if (this.client === undefined) return;
|
||||
// this.client.groupIdentify({
|
||||
// groupType: "workflow",
|
||||
// groupKey: workflow.id,
|
||||
// properties: {
|
||||
// name: workflow.title,
|
||||
// slug: workflow.slug,
|
||||
// packageJson: workflow.packageJson,
|
||||
// jsonSchema: workflow.jsonSchema,
|
||||
// createdAt: workflow.createdAt,
|
||||
// updatedAt: workflow.updatedAt,
|
||||
// organizationId: workflow.organizationId,
|
||||
// type: workflow.type,
|
||||
// status: workflow.status,
|
||||
// externalSourceId: workflow.externalSourceId,
|
||||
// service: workflow.service,
|
||||
// eventNames: workflow.eventNames,
|
||||
// disabledAt: workflow.disabledAt,
|
||||
// archivedAt: workflow.archivedAt,
|
||||
// isArchived: workflow.isArchived,
|
||||
// triggerTtlInSeconds: workflow.triggerTtlInSeconds,
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// new: ({
|
||||
// userId,
|
||||
// organizationId,
|
||||
// workflow,
|
||||
// workflowCount,
|
||||
// }: {
|
||||
// userId: string;
|
||||
// organizationId: string;
|
||||
// workflow: Workflow;
|
||||
// workflowCount: number;
|
||||
// }) => {
|
||||
// if (this.client === undefined) return;
|
||||
// this.#capture({
|
||||
// userId,
|
||||
// event: "workflow created",
|
||||
// organizationId: organizationId,
|
||||
// jobId: workflow.id,
|
||||
// eventProperties: {
|
||||
// id: workflow.id,
|
||||
// slug: workflow.slug,
|
||||
// title: workflow.title,
|
||||
// packageJson: workflow.packageJson,
|
||||
// jsonSchema: workflow.jsonSchema,
|
||||
// createdAt: workflow.createdAt,
|
||||
// updatedAt: workflow.updatedAt,
|
||||
// organizationId: workflow.organizationId,
|
||||
// type: workflow.type,
|
||||
// status: workflow.status,
|
||||
// externalSourceId: workflow.externalSourceId,
|
||||
// service: workflow.service,
|
||||
// eventNames: workflow.eventNames,
|
||||
// disabledAt: workflow.disabledAt,
|
||||
// archivedAt: workflow.archivedAt,
|
||||
// isArchived: workflow.isArchived,
|
||||
// triggerTtlInSeconds: workflow.triggerTtlInSeconds,
|
||||
// },
|
||||
// userProperties: {
|
||||
// workflowCount: workflowCount,
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// };
|
||||
|
||||
// workflowRun = {
|
||||
// new: ({
|
||||
// userId,
|
||||
// organizationId,
|
||||
// workflowId,
|
||||
// workflowRun,
|
||||
// environmentType,
|
||||
// runCount,
|
||||
// }: {
|
||||
// userId: string;
|
||||
// organizationId: string;
|
||||
// workflowId: string;
|
||||
// workflowRun: WorkflowRun;
|
||||
// environmentType: string;
|
||||
// runCount: number;
|
||||
// }) => {
|
||||
// if (this.client === undefined) return;
|
||||
// this.#capture({
|
||||
// userId,
|
||||
// event: "workflow run created",
|
||||
// eventProperties: {
|
||||
// id: workflowRun.id,
|
||||
// workflowId: workflowRun.workflowId,
|
||||
// environmentId: workflowRun.environmentId,
|
||||
// environmentType,
|
||||
// eventRuleId: workflowRun.eventRuleId,
|
||||
// eventId: workflowRun.eventId,
|
||||
// error: workflowRun.error,
|
||||
// status: workflowRun.status,
|
||||
// attemptCount: workflowRun.attemptCount,
|
||||
// createdAt: workflowRun.createdAt,
|
||||
// updatedAt: workflowRun.updatedAt,
|
||||
// startedAt: workflowRun.startedAt,
|
||||
// finishedAt: workflowRun.finishedAt,
|
||||
// timedOutAt: workflowRun.timedOutAt,
|
||||
// timedOutReason: workflowRun.timedOutReason,
|
||||
// isTest: workflowRun.isTest,
|
||||
// },
|
||||
// userProperties: {
|
||||
// runCount: runCount,
|
||||
// },
|
||||
// organizationId: organizationId,
|
||||
// jobId: workflowId,
|
||||
// environmentId: workflowRun.environmentId,
|
||||
// });
|
||||
// },
|
||||
// };
|
||||
|
||||
environment = {
|
||||
identify: ({ environment }: { environment: RuntimeEnvironment }) => {
|
||||
if (this.client === undefined) return;
|
||||
this.client.groupIdentify({
|
||||
groupType: "environment",
|
||||
groupKey: environment.id,
|
||||
properties: {
|
||||
name: environment.slug,
|
||||
slug: environment.slug,
|
||||
organizationId: environment.organizationId,
|
||||
createdAt: environment.createdAt,
|
||||
updatedAt: environment.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
telemetry = {
|
||||
capture: ({
|
||||
userId,
|
||||
event,
|
||||
properties,
|
||||
organizationId,
|
||||
environmentId,
|
||||
}: {
|
||||
userId: string;
|
||||
event: string;
|
||||
properties: Record<string | number, any>;
|
||||
organizationId?: string;
|
||||
environmentId?: string;
|
||||
}) => {
|
||||
this.#capture({
|
||||
userId,
|
||||
event,
|
||||
eventProperties: properties,
|
||||
organizationId,
|
||||
environmentId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
#capture(event: CaptureEvent) {
|
||||
if (this.client === undefined) return;
|
||||
let groups: Record<string, string> = {};
|
||||
|
||||
if (event.organizationId) {
|
||||
groups = {
|
||||
...groups,
|
||||
organization: event.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.projectId) {
|
||||
groups = {
|
||||
...groups,
|
||||
project: event.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.jobId) {
|
||||
groups = {
|
||||
...groups,
|
||||
workflow: event.jobId,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.environmentId) {
|
||||
groups = {
|
||||
...groups,
|
||||
environment: event.environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
let properties: Record<string, any> = {};
|
||||
if (event.eventProperties) {
|
||||
properties = {
|
||||
...properties,
|
||||
...event.eventProperties,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.userProperties) {
|
||||
properties = {
|
||||
...properties,
|
||||
$set: event.userProperties,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.userOnceProperties) {
|
||||
properties = {
|
||||
...properties,
|
||||
$set_once: event.userOnceProperties,
|
||||
};
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
distinctId: event.userId,
|
||||
event: event.event,
|
||||
properties,
|
||||
groups,
|
||||
};
|
||||
this.client.capture(eventData);
|
||||
}
|
||||
}
|
||||
|
||||
type CaptureEvent = {
|
||||
userId: string;
|
||||
event: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
jobId?: string;
|
||||
environmentId?: string;
|
||||
eventProperties?: Record<string, any>;
|
||||
userProperties?: Record<string, any>;
|
||||
userOnceProperties?: Record<string, any>;
|
||||
};
|
||||
|
||||
export const analytics = new BehaviouralAnalytics(env.POSTHOG_PROJECT_KEY);
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DeliverEmail } from "emails";
|
||||
import { EmailClient } from "emails";
|
||||
import type { SendEmailOptions } from "remix-auth-email-link";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
import type { User } from "~/models/user.server";
|
||||
import type { AuthUser } from "./authUser";
|
||||
@@ -14,6 +15,11 @@ const client = new EmailClient({
|
||||
});
|
||||
|
||||
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
|
||||
// Auto redirect when in development mode
|
||||
if (env.NODE_ENV === "development") {
|
||||
throw redirect(options.magicLink);
|
||||
}
|
||||
|
||||
return client.send({
|
||||
email: "magic_link",
|
||||
to: options.emailAddress,
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
RegisterTriggerBodySchema,
|
||||
RunJobBody,
|
||||
RunJobResponseSchema,
|
||||
ValidateResponse,
|
||||
ValidateResponseSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { safeBodyFromResponse } from "~/utils/json";
|
||||
import { safeBodyFromResponse, safeParseBodyFromResponse } from "~/utils/json";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
export class EndpointApiError extends Error {
|
||||
@@ -25,20 +27,15 @@ export class EndpointApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: this should work with tunnelling
|
||||
export class EndpointApi {
|
||||
constructor(
|
||||
private apiKey: string,
|
||||
private url: string,
|
||||
private id: string
|
||||
) {}
|
||||
constructor(private apiKey: string, private url: string) {}
|
||||
|
||||
async ping(): Promise<PongResponse> {
|
||||
async ping(endpointId: string): Promise<PongResponse> {
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-trigger-api-key": this.apiKey,
|
||||
"x-trigger-endpoint-id": this.id,
|
||||
"x-trigger-endpoint-id": endpointId,
|
||||
"x-trigger-action": "PING",
|
||||
},
|
||||
});
|
||||
@@ -73,13 +70,23 @@ export class EndpointApi {
|
||||
};
|
||||
}
|
||||
|
||||
const anyBody = await response.json();
|
||||
const pongResponse = await safeParseBodyFromResponse(response, PongResponseSchema);
|
||||
|
||||
logger.debug("ping() response from endpoint", {
|
||||
body: anyBody,
|
||||
});
|
||||
if (!pongResponse) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not parse response from endpoint. Make sure it points to the correct URL (you might be missing /api/trigger)`,
|
||||
};
|
||||
}
|
||||
|
||||
return PongResponseSchema.parse(anyBody);
|
||||
if (!pongResponse.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Endpoint ${this.url} responded with error: ${pongResponse.error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return pongResponse.data;
|
||||
}
|
||||
|
||||
async indexEndpoint() {
|
||||
@@ -155,6 +162,10 @@ export class EndpointApi {
|
||||
}
|
||||
|
||||
async executeJobRequest(options: RunJobBody) {
|
||||
logger.debug("executeJobRequest()", {
|
||||
options,
|
||||
});
|
||||
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -265,6 +276,64 @@ export class EndpointApi {
|
||||
|
||||
return HttpSourceResponseSchema.parse(anyBody);
|
||||
}
|
||||
|
||||
async validate(): Promise<ValidateResponse> {
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-trigger-api-key": this.apiKey,
|
||||
"x-trigger-action": "VALIDATE",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not connect to endpoint ${this.url}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
const body = await safeBodyFromResponse(response, ErrorWithStackSchema);
|
||||
|
||||
if (body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: body.message,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Trigger API key is invalid`,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not connect to endpoint ${this.url}. Status code: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const validateResponse = await safeParseBodyFromResponse(response, ValidateResponseSchema);
|
||||
|
||||
if (!validateResponse) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not parse response from endpoint. Make sure it points to the correct URL (you might be missing /api/trigger)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateResponse.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Endpoint ${this.url} responded with error: ${validateResponse.error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return validateResponse.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeFetch(url: string, options: RequestInit) {
|
||||
|
||||
@@ -34,9 +34,9 @@ export class CreateEndpointService {
|
||||
}) {
|
||||
const endpointUrl = this.#normalizeEndpointUrl(url);
|
||||
|
||||
const client = new EndpointApi(environment.apiKey, endpointUrl, id);
|
||||
const client = new EndpointApi(environment.apiKey, endpointUrl);
|
||||
|
||||
const pong = await client.ping();
|
||||
const pong = await client.ping(id);
|
||||
|
||||
if (!pong.ok) {
|
||||
throw new CreateEndpointError("FAILED_PING", pong.error);
|
||||
|
||||
@@ -28,7 +28,7 @@ export class IndexEndpointService {
|
||||
const endpoint = await findEndpoint(id);
|
||||
|
||||
// Make a request to the endpoint to fetch a list of jobs
|
||||
const client = new EndpointApi(endpoint.environment.apiKey, endpoint.url, endpoint.slug);
|
||||
const client = new EndpointApi(endpoint.environment.apiKey, endpoint.url);
|
||||
|
||||
const indexResponse = await client.indexEndpoint();
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
export class RecurringEndpointIndexService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(ts: Date) {
|
||||
// Find all production endpoints that haven't been indexed in the last 10 minutes
|
||||
const currentTimestamp = ts.getTime();
|
||||
|
||||
const endpoints = await this.#prismaClient.endpoint.findMany({
|
||||
where: {
|
||||
environment: {
|
||||
type: RuntimeEnvironmentType.PRODUCTION,
|
||||
},
|
||||
indexings: {
|
||||
none: {
|
||||
createdAt: {
|
||||
gt: new Date(currentTimestamp - 10 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug("Found endpoints that haven't been indexed in the last 10 minutes", {
|
||||
count: endpoints.length,
|
||||
});
|
||||
|
||||
// Enqueue each endpoint for indexing
|
||||
for (const endpoint of endpoints) {
|
||||
await workerQueue.enqueue("indexEndpoint", {
|
||||
id: endpoint.id,
|
||||
source: "INTERNAL",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { $transaction, prisma, PrismaClient } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { CreateEndpointError } from "./createEndpoint.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
|
||||
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
|
||||
|
||||
export class ValidateCreateEndpointService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ environment, url }: { environment: AuthenticatedEnvironment; url: string }) {
|
||||
const endpointUrl = this.#normalizeEndpointUrl(url);
|
||||
|
||||
const client = new EndpointApi(environment.apiKey, endpointUrl);
|
||||
|
||||
const validationResult = await client.validate();
|
||||
|
||||
if (!validationResult.ok) {
|
||||
throw new Error(validationResult.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $transaction(this.#prismaClient, async (tx) => {
|
||||
const endpoint = await tx.endpoint.upsert({
|
||||
where: {
|
||||
environmentId_slug: {
|
||||
environmentId: environment.id,
|
||||
slug: validationResult.endpointId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
connect: {
|
||||
id: environment.organizationId,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
connect: {
|
||||
id: environment.projectId,
|
||||
},
|
||||
},
|
||||
slug: validationResult.endpointId,
|
||||
url: endpointUrl,
|
||||
indexingHookIdentifier: indexingHookIdentifier(),
|
||||
},
|
||||
update: {
|
||||
url: endpointUrl,
|
||||
},
|
||||
});
|
||||
|
||||
// Kick off process to fetch the jobs for this endpoint
|
||||
await workerQueue.enqueue(
|
||||
"indexEndpoint",
|
||||
{
|
||||
id: endpoint.id,
|
||||
source: "INTERNAL",
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
return endpoint;
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new CreateEndpointError("FAILED_UPSERT", error.message);
|
||||
} else {
|
||||
throw new CreateEndpointError("FAILED_UPSERT", "Something went wrong");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the endpoint URL points to localhost, and the RUNTIME_PLATFORM is docker-compose, then we need to rewrite the host to host.docker.internal
|
||||
// otherwise we shouldn't change anything
|
||||
#normalizeEndpointUrl(url: string) {
|
||||
if (env.RUNTIME_PLATFORM === "docker-compose") {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.hostname === "localhost") {
|
||||
urlObj.hostname = "host.docker.internal";
|
||||
return urlObj.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EventDispatcher, EventRecord } from "@trigger.dev/database";
|
||||
import type { EventFilter } from "@trigger.dev/core";
|
||||
import { EventFilterSchema } from "@trigger.dev/core";
|
||||
import { EventFilterSchema, eventFilterMatches } from "@trigger.dev/core";
|
||||
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
@@ -124,29 +124,6 @@ export class EventMatcher {
|
||||
}
|
||||
|
||||
public matches(filter: EventFilter) {
|
||||
return patternMatches(this.event, filter);
|
||||
return eventFilterMatches(this.event, filter);
|
||||
}
|
||||
}
|
||||
|
||||
function patternMatches(payload: any, pattern: any): boolean {
|
||||
for (const [patternKey, patternValue] of Object.entries(pattern)) {
|
||||
const payloadValue = payload[patternKey];
|
||||
|
||||
if (Array.isArray(patternValue)) {
|
||||
if (patternValue.length > 0 && !patternValue.includes(payloadValue)) {
|
||||
return false;
|
||||
}
|
||||
} else if (typeof patternValue === "object") {
|
||||
if (Array.isArray(payloadValue)) {
|
||||
if (!payloadValue.some((item) => patternMatches(item, patternValue))) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!patternMatches(payloadValue, patternValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { plain } from "./integrations/plain";
|
||||
import { resend } from "./integrations/resend";
|
||||
import { slack } from "./integrations/slack";
|
||||
import { stripe } from "./integrations/stripe";
|
||||
import { sendgrid } from "./integrations/sendgrid";
|
||||
import { supabaseManagement, supabase } from "./integrations/supabase";
|
||||
import { typeform } from "./integrations/typeform";
|
||||
import type { Integration } from "./types";
|
||||
@@ -34,8 +35,9 @@ export const integrationCatalog = new IntegrationCatalog({
|
||||
plain,
|
||||
resend,
|
||||
slack,
|
||||
typeform,
|
||||
stripe,
|
||||
supabaseManagement,
|
||||
supabase,
|
||||
sendgrid,
|
||||
typeform,
|
||||
});
|
||||
|
||||
@@ -109,15 +109,9 @@ export class IntegrationConnectionCreatedService {
|
||||
});
|
||||
|
||||
// We need to start the run again
|
||||
await workerQueue.enqueue(
|
||||
"startRun",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{
|
||||
queueName: `job-queue:${run.queue.id}`,
|
||||
}
|
||||
);
|
||||
await workerQueue.enqueue("startRun", {
|
||||
id: run.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Integration } from "../types";
|
||||
|
||||
export const sendgrid: Integration = {
|
||||
identifier: "sendgrid",
|
||||
name: "SendGrid",
|
||||
packageName: "@trigger.dev/sendgrid@latest",
|
||||
authenticationMethods: {
|
||||
apikey: {
|
||||
type: "apikey",
|
||||
help: {
|
||||
samples: [
|
||||
{
|
||||
title: "Creating the client",
|
||||
code: `
|
||||
import { SendGrid } from "@trigger.dev/sendgrid";
|
||||
|
||||
const sendgrid = new SendGrid({
|
||||
id: "__SLUG__",
|
||||
apiKey: process.env.SENDGRID_API_KEY!,
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
title: "Using the client",
|
||||
code: `
|
||||
client.defineJob({
|
||||
id: "send-sendgrid-email",
|
||||
name: "Send SendGrid Email",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({
|
||||
name: "send.email",
|
||||
schema: z.object({
|
||||
to: z.string(),
|
||||
subject: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
}),
|
||||
integrations: {
|
||||
sendgrid,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.sendgrid.sendEmail({
|
||||
to: payload.to,
|
||||
from: "Trigger.dev <hello@email.trigger.dev>",
|
||||
subject: payload.subject,
|
||||
text: payload.text,
|
||||
});
|
||||
},
|
||||
});
|
||||
`,
|
||||
highlight: [
|
||||
[13, 15],
|
||||
[17, 22],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -10,11 +10,11 @@ const supabase = new SupabaseManagement({
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
id: "on-new-users",
|
||||
name: "On New Users",
|
||||
id: "on-new-todos",
|
||||
name: "On New Todos",
|
||||
version: "0.1.1",
|
||||
trigger: supabase.onInsert({
|
||||
table: "users",
|
||||
trigger: supabase.onInserted({
|
||||
table: "todos",
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
},
|
||||
@@ -33,11 +33,11 @@ const supabase = new SupabaseManagement({
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
id: "on-new-users",
|
||||
name: "On New Users",
|
||||
id: "on-new-todos",
|
||||
name: "On New Todos",
|
||||
version: "0.1.1",
|
||||
trigger: supabase.onInsert({
|
||||
table: "users",
|
||||
trigger: supabase.onInserted({
|
||||
table: "todos",
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
},
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { Endpoint, Integration, Job, JobIntegration, JobVersion } from "@trigger.dev/database";
|
||||
import {
|
||||
IntegrationConfig,
|
||||
JobMetadata,
|
||||
SCHEDULED_EVENT,
|
||||
TriggerMetadata,
|
||||
} from "@trigger.dev/core";
|
||||
import type { Endpoint, Integration, Job, JobIntegration, JobVersion } from "@trigger.dev/database";
|
||||
import { DEFAULT_MAX_CONCURRENT_RUNS } from "~/consts";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { RegisterScheduleSourceService } from "../schedules/registerScheduleSource.server";
|
||||
import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
|
||||
|
||||
export class RegisterJobService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -166,13 +166,9 @@ export class RegisterJobService {
|
||||
});
|
||||
|
||||
// Upsert the JobQueue
|
||||
const queueName =
|
||||
typeof metadata.queue === "string"
|
||||
? metadata.queue
|
||||
: typeof metadata.queue === "object"
|
||||
? metadata.queue.name
|
||||
: "default";
|
||||
const queueName = "default";
|
||||
|
||||
// Job Queues are going to be deprecated or used for something else, we're just doing this for now
|
||||
const jobQueue = await this.#prismaClient.jobQueue.upsert({
|
||||
where: {
|
||||
environmentId_name: {
|
||||
@@ -187,16 +183,10 @@ export class RegisterJobService {
|
||||
},
|
||||
},
|
||||
name: queueName,
|
||||
maxJobs:
|
||||
typeof metadata.queue === "object"
|
||||
? metadata.queue.maxConcurrent || DEFAULT_MAX_CONCURRENT_RUNS
|
||||
: DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
maxJobs: DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
},
|
||||
update: {
|
||||
maxJobs:
|
||||
typeof metadata.queue === "object"
|
||||
? metadata.queue.maxConcurrent || DEFAULT_MAX_CONCURRENT_RUNS
|
||||
: DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
maxJobs: DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -245,10 +235,10 @@ export class RegisterJobService {
|
||||
version: metadata.version,
|
||||
eventSpecification,
|
||||
preprocessRuns: metadata.preprocessRuns,
|
||||
startPosition: metadata.startPosition === "initial" ? "INITIAL" : "LATEST",
|
||||
startPosition: "LATEST",
|
||||
},
|
||||
update: {
|
||||
startPosition: metadata.startPosition === "initial" ? "INITIAL" : "LATEST",
|
||||
startPosition: "LATEST",
|
||||
eventSpecification,
|
||||
preprocessRuns: metadata.preprocessRuns,
|
||||
queue: {
|
||||
@@ -436,7 +426,7 @@ export class RegisterJobService {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
event: SCHEDULED_EVENT,
|
||||
event: [SCHEDULED_EVENT],
|
||||
source: "trigger.dev",
|
||||
payloadFilter: {},
|
||||
contextFilter: {},
|
||||
|
||||
@@ -5,6 +5,6 @@ import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
|
||||
export const logger = new Logger(
|
||||
"webapp",
|
||||
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||
["examples"],
|
||||
["examples", "output", "connectionString", "payload"],
|
||||
sensitiveDataReplacer
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { User } from "~/models/user.server";
|
||||
import { analytics } from "./analytics.server";
|
||||
import { telemetry } from "./telemetry.server";
|
||||
|
||||
export async function postAuthentication({
|
||||
user,
|
||||
@@ -10,5 +10,5 @@ export async function postAuthentication({
|
||||
loginMethod: User["authenticationMethod"];
|
||||
isNewUser: boolean;
|
||||
}) {
|
||||
analytics.user.identify({ user, isNewUser });
|
||||
telemetry.user.identify({ user, isNewUser });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { executionWorker } from "../worker.server";
|
||||
import { dequeueRunExecutionV2 } from "~/models/jobRunExecution.server";
|
||||
|
||||
export class CancelRunService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -17,21 +18,11 @@ export class CancelRunService {
|
||||
},
|
||||
});
|
||||
|
||||
const shouldDecrementQueue = run.status === "STARTED" || run.status === "PREPROCESSING";
|
||||
await tx.jobRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
completedAt: new Date(),
|
||||
queue: shouldDecrementQueue
|
||||
? {
|
||||
update: {
|
||||
jobCount: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,13 +39,7 @@ export class CancelRunService {
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"startQueuedRuns",
|
||||
{
|
||||
id: run.queueId,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
await dequeueRunExecutionV2(run, tx);
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EXECUTE_JOB_RETRY_LIMIT } from "~/consts";
|
||||
import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
|
||||
|
||||
const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "ABORTED", "CANCELED"];
|
||||
|
||||
@@ -17,86 +16,26 @@ export class ContinueRunService {
|
||||
async (tx) => {
|
||||
const run = await tx.jobRun.findUniqueOrThrow({
|
||||
where: { id: runId },
|
||||
include: {
|
||||
queue: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!RESUMABLE_STATUSES.includes(run.status)) {
|
||||
throw new Error("Run is not resumable");
|
||||
}
|
||||
|
||||
if (run.queue.jobCount >= run.queue.maxJobs) {
|
||||
await tx.jobRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "QUEUED",
|
||||
queuedAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
output: Prisma.DbNull,
|
||||
timedOutAt: null,
|
||||
timedOutReason: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.jobRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "STARTED",
|
||||
queuedAt: null,
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
output: Prisma.DbNull,
|
||||
timedOutAt: null,
|
||||
timedOutReason: null,
|
||||
queue: {
|
||||
update: {
|
||||
jobCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.jobRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "QUEUED",
|
||||
queuedAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
output: Prisma.DbNull,
|
||||
timedOutAt: null,
|
||||
timedOutReason: null,
|
||||
},
|
||||
});
|
||||
|
||||
const execution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
run: {
|
||||
connect: {
|
||||
id: runId,
|
||||
},
|
||||
},
|
||||
status: "PENDING",
|
||||
reason: "EXECUTE_JOB",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
isRetry: true,
|
||||
},
|
||||
});
|
||||
|
||||
const job = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: execution.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: {
|
||||
graphileJobId: job.id,
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"startQueuedRuns",
|
||||
{
|
||||
id: run.queueId,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
}
|
||||
await enqueueRunExecutionV2(run, tx);
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export class CreateRunService {
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx, queueName: `job-queue:${jobQueue.id}` }
|
||||
{ tx }
|
||||
);
|
||||
|
||||
return run;
|
||||
|
||||
+40
-105
@@ -1,27 +1,26 @@
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import {
|
||||
ApiEventLogSchema,
|
||||
CachedTaskSchema,
|
||||
RunJobCanceledWithTask,
|
||||
RunJobError,
|
||||
RunJobResumeWithTask,
|
||||
RunJobRetryWithTask,
|
||||
RunJobSuccess,
|
||||
RunSourceContextSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { EXECUTE_JOB_RETRY_LIMIT } from "~/consts";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV1 } from "~/models/jobRunExecution.server";
|
||||
import { resolveRunConnections } from "~/models/runConnection.server";
|
||||
import { formatError } from "~/utils/formatErrors.server";
|
||||
import { safeJsonZodParse } from "~/utils/json";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { formatError } from "~/utils/formatErrors.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
type FoundRunExecution = NonNullable<Awaited<ReturnType<typeof findRunExecution>>>;
|
||||
|
||||
export class PerformRunExecutionService {
|
||||
export class PerformRunExecutionV1Service {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
@@ -54,7 +53,7 @@ export class PerformRunExecutionService {
|
||||
async #executePreprocessing(execution: FoundRunExecution) {
|
||||
const { run } = execution;
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url, run.endpoint.slug);
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = ApiEventLogSchema.parse({ ...run.event, id: run.eventId });
|
||||
const startedAt = new Date();
|
||||
|
||||
@@ -162,22 +161,7 @@ export class PerformRunExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
const job = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: runExecution.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: runExecution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: job.id,
|
||||
},
|
||||
});
|
||||
await enqueueRunExecutionV1(runExecution, run.queue.id, run.queue.maxJobs, tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -189,7 +173,7 @@ export class PerformRunExecutionService {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url, run.endpoint.slug);
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = ApiEventLogSchema.parse({ ...run.event, id: run.eventId });
|
||||
|
||||
const startedAt = new Date();
|
||||
@@ -201,6 +185,12 @@ export class PerformRunExecutionService {
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt,
|
||||
run: {
|
||||
update: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -388,14 +378,6 @@ export class PerformRunExecutionService {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"runFinished",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,22 +408,13 @@ export class PerformRunExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
const graphileJob = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
{ tx, runAt: data.task.delayUntil ?? undefined }
|
||||
await enqueueRunExecutionV1(
|
||||
newJobExecution,
|
||||
run.queue.id,
|
||||
run.queue.maxJobs,
|
||||
tx,
|
||||
data.task.delayUntil ?? undefined
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: graphileJob.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -522,22 +495,13 @@ export class PerformRunExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
const graphileJob = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
{ tx, runAt: data.retryAt }
|
||||
await enqueueRunExecutionV1(
|
||||
newJobExecution,
|
||||
run.queue.id,
|
||||
run.queue.maxJobs,
|
||||
tx,
|
||||
data.retryAt
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: graphileJob.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -557,6 +521,13 @@ export class PerformRunExecutionService {
|
||||
// So when retryCount is 1, retryDelayInMs is 500ms
|
||||
// When retryCount is 2, retryDelayInMs is 750ms
|
||||
// When retryCount is 3, retryDelayInMs is 1125ms
|
||||
// When retryCount is 4, retryDelayInMs is 1687ms
|
||||
// When retryCount is 5, retryDelayInMs is 2531ms
|
||||
// When retryCount is 6, retryDelayInMs is 3796ms
|
||||
// When retryCount is 7, retryDelayInMs is 5694ms
|
||||
// When retryCount is 8, retryDelayInMs is 8541ms
|
||||
// When retryCount is 9, retryDelayInMs is 12812ms
|
||||
// When retryCount is 10, retryDelayInMs is 19218ms
|
||||
const retryDelayInMs = Math.round(500 * Math.pow(1.5, retryCount - 1));
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
@@ -572,20 +543,13 @@ export class PerformRunExecutionService {
|
||||
|
||||
const runAt = new Date(Date.now() + retryDelayInMs);
|
||||
|
||||
const job = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{ id: execution.id },
|
||||
{ runAt, tx }
|
||||
await enqueueRunExecutionV1(
|
||||
execution,
|
||||
execution.run.queue.id,
|
||||
execution.run.queue.maxJobs,
|
||||
tx,
|
||||
runAt
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: job.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -617,13 +581,6 @@ export class PerformRunExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"runFinished",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "PREPROCESS": {
|
||||
@@ -645,14 +602,6 @@ export class PerformRunExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"runFinished",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -675,22 +624,7 @@ export class PerformRunExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
const job = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: runExecution.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: runExecution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: job.id,
|
||||
},
|
||||
});
|
||||
await enqueueRunExecutionV1(runExecution, run.queue.id, run.queue.maxJobs, tx);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -733,6 +667,7 @@ async function findRunExecution(prisma: PrismaClientOrTransaction, id: string) {
|
||||
endpoint: true,
|
||||
organization: true,
|
||||
externalAccount: true,
|
||||
queue: true,
|
||||
runConnections: {
|
||||
include: {
|
||||
integration: true,
|
||||
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
ApiEventLogSchema,
|
||||
CachedTask,
|
||||
RunJobError,
|
||||
RunJobResumeWithTask,
|
||||
RunJobRetryWithTask,
|
||||
RunJobSuccess,
|
||||
RunSourceContextSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
|
||||
import { resolveRunConnections } from "~/models/runConnection.server";
|
||||
import { formatError } from "~/utils/formatErrors.server";
|
||||
import { safeJsonZodParse } from "~/utils/json";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
type FoundTask = FoundRun["tasks"][number];
|
||||
|
||||
export class PerformRunExecutionV2Service {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
id: string,
|
||||
reason: "PREPROCESS" | "EXECUTE_JOB",
|
||||
isRetry: boolean = false,
|
||||
resumeTaskId?: string
|
||||
) {
|
||||
const run = await findRun(this.#prismaClient, id);
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "PREPROCESS": {
|
||||
await this.#executePreprocessing(run);
|
||||
break;
|
||||
}
|
||||
case "EXECUTE_JOB": {
|
||||
await this.#executeJob(run, isRetry, resumeTaskId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the preprocessing step of a run, which will send the payload to the endpoint and give the job
|
||||
// an opportunity to generate run properties based on the payload.
|
||||
// If the endpoint is not available, or the response is not ok,
|
||||
// the run execution will be marked as failed and the run will start
|
||||
async #executePreprocessing(run: FoundRun) {
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = ApiEventLogSchema.parse({ ...run.event, id: run.eventId });
|
||||
|
||||
const { response, parser } = await client.preprocessRunRequest({
|
||||
event,
|
||||
job: {
|
||||
id: run.version.job.slug,
|
||||
version: run.version.version,
|
||||
},
|
||||
run: {
|
||||
id: run.id,
|
||||
isTest: run.isTest,
|
||||
},
|
||||
environment: {
|
||||
id: run.environment.id,
|
||||
slug: run.environment.slug,
|
||||
type: run.environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.organization.id,
|
||||
slug: run.organization.slug,
|
||||
title: run.organization.title,
|
||||
},
|
||||
account: run.externalAccount
|
||||
? {
|
||||
id: run.externalAccount.identifier,
|
||||
metadata: run.externalAccount.metadata,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: "Could not connect to the endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
if (safeBody.data.abort) {
|
||||
return this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"PREPROCESS",
|
||||
run,
|
||||
{ message: "Endpoint aborted the run" },
|
||||
"ABORTED"
|
||||
);
|
||||
} else {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
properties: safeBody.data.properties,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
async #executeJob(run: FoundRun, isRetry: boolean, resumeTaskId?: string) {
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(run);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = ApiEventLogSchema.parse({ ...run.event, id: run.eventId });
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecutionWithRetry({
|
||||
message: `Could not resolve all connections for run ${run.id}, attempting to retry`,
|
||||
});
|
||||
}
|
||||
|
||||
let resumedTask: Task | undefined;
|
||||
|
||||
if (resumeTaskId) {
|
||||
resumedTask =
|
||||
(await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
})) ?? undefined;
|
||||
|
||||
if (resumedTask) {
|
||||
resumedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
data: {
|
||||
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
|
||||
completedAt: resumedTask.noop ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
|
||||
|
||||
const { response, parser, errorParser } = await client.executeJobRequest({
|
||||
event,
|
||||
job: {
|
||||
id: run.version.job.slug,
|
||||
version: run.version.version,
|
||||
},
|
||||
run: {
|
||||
id: run.id,
|
||||
isTest: run.isTest,
|
||||
startedAt,
|
||||
isRetry,
|
||||
},
|
||||
environment: {
|
||||
id: run.environment.id,
|
||||
slug: run.environment.slug,
|
||||
type: run.environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.organization.id,
|
||||
slug: run.organization.slug,
|
||||
title: run.organization.title,
|
||||
},
|
||||
account: run.externalAccount
|
||||
? {
|
||||
id: run.externalAccount.identifier,
|
||||
metadata: run.externalAccount.metadata,
|
||||
}
|
||||
: undefined,
|
||||
connections: connections.auth,
|
||||
source: sourceContext.success ? sourceContext.data : undefined,
|
||||
tasks: prepareTasksForRun([run.tasks, resumedTask].flat().filter(Boolean)),
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug("Endpoint responded with non-200 status code", {
|
||||
status: response.status,
|
||||
runId: run.id,
|
||||
endpoint: run.endpoint.url,
|
||||
});
|
||||
|
||||
const errorBody = safeJsonZodParse(errorParser, rawBody);
|
||||
|
||||
if (errorBody && errorBody.success) {
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
errorBody.data
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(errorBody.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
const status = safeBody.data.status;
|
||||
|
||||
switch (status) {
|
||||
case "SUCCESS": {
|
||||
await this.#completeRunWithSuccess(run, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RESUME_WITH_TASK": {
|
||||
await this.#resumeRunWithTask(run, safeBody.data, isRetry);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ERROR": {
|
||||
await this.#failRunWithError(run, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RETRY_WITH_TASK": {
|
||||
await this.#retryRunWithTask(run, safeBody.data, isRetry);
|
||||
|
||||
break;
|
||||
}
|
||||
case "CANCELED": {
|
||||
await this.#cancelExecution(run);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #completeRunWithSuccess(run: FoundRun, data: RunJobSuccess) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status: "SUCCESS",
|
||||
output: data.output ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunWithTask(run: FoundRun, data: RunJobResumeWithTask, isRetry: boolean) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
// If the task has an operation, then the next performRunExecution will occur
|
||||
// when that operation has finished
|
||||
if (!data.task.operation) {
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
runAt: data.task.delayUntil ?? undefined,
|
||||
resumeTaskId: data.task.id,
|
||||
isRetry,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunWithError(execution: FoundRun, data: RunJobError) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
if (data.task) {
|
||||
await tx.task.update({
|
||||
where: {
|
||||
id: data.task.id,
|
||||
},
|
||||
data: {
|
||||
status: "ERRORED",
|
||||
completedAt: new Date(),
|
||||
output: data.error ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.#failRunExecution(tx, "EXECUTE_JOB", execution, data.error ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async #retryRunWithTask(run: FoundRun, data: RunJobRetryWithTask, isRetry: boolean) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
// We need to check for an existing task attempt
|
||||
const existingAttempt = await tx.taskAttempt.findFirst({
|
||||
where: {
|
||||
taskId: data.task.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
orderBy: {
|
||||
number: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAttempt) {
|
||||
await tx.taskAttempt.update({
|
||||
where: {
|
||||
id: existingAttempt.id,
|
||||
},
|
||||
data: {
|
||||
status: "ERRORED",
|
||||
error: formatError(data.error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// We need to create a new task attempt
|
||||
await tx.taskAttempt.create({
|
||||
data: {
|
||||
taskId: data.task.id,
|
||||
number: existingAttempt ? existingAttempt.number + 1 : 1,
|
||||
status: "PENDING",
|
||||
runAt: data.retryAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.task.update({
|
||||
where: {
|
||||
id: data.task.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING",
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
runAt: data.retryAt,
|
||||
resumeTaskId: data.task.id,
|
||||
isRetry,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunExecutionWithRetry(output: Record<string, any>): Promise<void> {
|
||||
throw new Error(JSON.stringify(output));
|
||||
}
|
||||
|
||||
async #failRunExecution(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
reason: "EXECUTE_JOB" | "PREPROCESS",
|
||||
run: FoundRun,
|
||||
output: Record<string, any>,
|
||||
status: "FAILURE" | "ABORTED" = "FAILURE"
|
||||
): Promise<void> {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
switch (reason) {
|
||||
case "EXECUTE_JOB": {
|
||||
// If the execution is an EXECUTE_JOB reason, we need to fail the run
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "PREPROCESS": {
|
||||
// If the status is ABORTED, we need to fail the run
|
||||
if (status === "ABORTED") {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #cancelExecution(run: FoundRun) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareTasksForRun(tasks: FoundTask[]): CachedTask[] {
|
||||
// We need to limit the cached tasks to not be too large >3.5MB when serialized
|
||||
const TOTAL_CACHED_TASK_BYTE_LIMIT = 3500000;
|
||||
|
||||
const cachedTasks = new Map<string, CachedTask>(); // Cache for prepared tasks
|
||||
const cachedTaskSizes = new Map<string, number>(); // Cache for calculated task sizes
|
||||
|
||||
// Helper function to get the cached prepared task, or prepare and cache if not already cached
|
||||
function getCachedTask(task: FoundTask): CachedTask {
|
||||
const taskId = task.id;
|
||||
if (!cachedTasks.has(taskId)) {
|
||||
cachedTasks.set(taskId, prepareTaskForRun(task));
|
||||
}
|
||||
return cachedTasks.get(taskId)!;
|
||||
}
|
||||
|
||||
// Helper function to get the cached task size, or calculate and cache if not already cached
|
||||
function getCachedTaskSize(task: CachedTask): number {
|
||||
const taskId = task.id;
|
||||
if (!cachedTaskSizes.has(taskId)) {
|
||||
cachedTaskSizes.set(taskId, calculateCachedTaskSize(task));
|
||||
}
|
||||
return cachedTaskSizes.get(taskId)!;
|
||||
}
|
||||
|
||||
// Create a dynamic programming array to store intermediate results
|
||||
const dp: number[][] = [];
|
||||
for (let i = 0; i <= tasks.length; i++) {
|
||||
dp[i] = [];
|
||||
for (let j = 0; j <= TOTAL_CACHED_TASK_BYTE_LIMIT; j++) {
|
||||
dp[i][j] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the dynamic programming array
|
||||
for (let i = 1; i <= tasks.length; i++) {
|
||||
const task = tasks[i - 1];
|
||||
const cachedTask = getCachedTask(task);
|
||||
const taskSize = getCachedTaskSize(cachedTask);
|
||||
for (let j = 0; j <= TOTAL_CACHED_TASK_BYTE_LIMIT; j++) {
|
||||
if (taskSize <= j) {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - taskSize] + taskSize);
|
||||
} else {
|
||||
dp[i][j] = dp[i - 1][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse the dynamic programming array to find the included tasks
|
||||
const tasksToRun: CachedTask[] = [];
|
||||
let j = TOTAL_CACHED_TASK_BYTE_LIMIT;
|
||||
for (let i = tasks.length; i > 0 && j > 0; i--) {
|
||||
if (dp[i][j] !== dp[i - 1][j]) {
|
||||
const task = tasks[i - 1];
|
||||
const cachedTask = getCachedTask(task);
|
||||
tasksToRun.unshift(cachedTask);
|
||||
j -= getCachedTaskSize(cachedTask);
|
||||
}
|
||||
}
|
||||
|
||||
return tasksToRun;
|
||||
}
|
||||
|
||||
function prepareTaskForRun(task: FoundTask): CachedTask {
|
||||
return {
|
||||
id: task.idempotencyKey, // We should eventually move this back to task.id
|
||||
status: task.status,
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
noop: task.noop,
|
||||
output: task.output as any,
|
||||
parentId: task.parentId,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateCachedTaskSize(task: CachedTask): number {
|
||||
return JSON.stringify(task).length;
|
||||
}
|
||||
|
||||
async function findRun(prisma: PrismaClientOrTransaction, id: string) {
|
||||
return await prisma.jobRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
environment: true,
|
||||
endpoint: true,
|
||||
organization: true,
|
||||
externalAccount: true,
|
||||
queue: true,
|
||||
runConnections: {
|
||||
include: {
|
||||
integration: true,
|
||||
connection: {
|
||||
include: {
|
||||
dataReference: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["COMPLETED"],
|
||||
},
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
version: {
|
||||
include: {
|
||||
job: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { z } from "zod";
|
||||
import { RawEventSchema, SendEventOptionsSchema } from "@trigger.dev/core";
|
||||
import { IngestSendEvent } from "../events/ingestSendEvent.server";
|
||||
|
||||
const SendEventOutputSchema = z.object({
|
||||
events: z.array(RawEventSchema),
|
||||
options: SendEventOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export class RunFinishedService {
|
||||
#prismaClient: PrismaClient;
|
||||
#ingestEventService = new IngestSendEvent();
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const run = await this.#prismaClient.jobRun.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
environment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Make sure to start any queued runs once this run is finished
|
||||
await workerQueue.enqueue("startQueuedRuns", {
|
||||
id: run.queueId,
|
||||
});
|
||||
|
||||
if (
|
||||
run.status === "SUCCESS" &&
|
||||
run.output &&
|
||||
typeof run.output === "object" &&
|
||||
"events" in run.output
|
||||
) {
|
||||
// If the run successfully completes, we will parse the output and
|
||||
// if it's in the form of { events: Array<RawEvent> } then we will send the events
|
||||
const parsedOutput = SendEventOutputSchema.safeParse(run.output);
|
||||
|
||||
if (parsedOutput.success) {
|
||||
for (const newEvent of parsedOutput.data.events) {
|
||||
await this.#ingestEventService.call(run.environment, newEvent, parsedOutput.data.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
|
||||
export class StartQueuedRunsService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const queue = await this.#prismaClient.jobQueue.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
runs: {
|
||||
where: {
|
||||
status: "QUEUED",
|
||||
},
|
||||
orderBy: {
|
||||
queuedAt: "asc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.runs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.jobCount >= queue.maxJobs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = queue.runs[0];
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"startRun",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{
|
||||
queueName: `job-queue:${queue.id}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConnectionType, Integration, IntegrationConnection } from "@trigger.dev/database";
|
||||
import { EXECUTE_JOB_RETRY_LIMIT, PREPROCESS_RETRY_LIMIT } from "~/consts";
|
||||
import type { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
@@ -21,34 +21,20 @@ export class StartRunService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.queue.jobCount >= run.queue.maxJobs) {
|
||||
await this.#queueRun(id);
|
||||
} else {
|
||||
const runConnectionsByKey = await createRunConnections(this.#prismaClient, run);
|
||||
const runConnectionsByKey = await createRunConnections(this.#prismaClient, run);
|
||||
|
||||
if (hasMissingConnections(runConnectionsByKey)) {
|
||||
await this.#handleMissingConnections(id, runConnectionsByKey);
|
||||
} else {
|
||||
await this.#startRun(id, run, runConnectionsByKey);
|
||||
}
|
||||
if (hasMissingConnections(runConnectionsByKey)) {
|
||||
await this.#handleMissingConnections(id, runConnectionsByKey);
|
||||
} else {
|
||||
await this.#startRun(id, run, runConnectionsByKey);
|
||||
}
|
||||
}
|
||||
|
||||
#runIsStartable(run: FoundRun) {
|
||||
const startableStatuses = ["PENDING", "QUEUED", "WAITING_ON_CONNECTIONS"] as const;
|
||||
const startableStatuses = ["PENDING", "WAITING_ON_CONNECTIONS"] as const;
|
||||
return startableStatuses.includes(run.status);
|
||||
}
|
||||
|
||||
async #queueRun(id: string) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "QUEUED",
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #startRun(id: string, run: FoundRun, runConnectionsByKey: RunConnectionsByKey) {
|
||||
const createRunConnections = Object.entries(runConnectionsByKey)
|
||||
.map(([key, runConnection]) =>
|
||||
@@ -69,89 +55,35 @@ export class StartRunService {
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const updateRunAndCreateExecution = async () => {
|
||||
const updateRun = async () => {
|
||||
if (run.preprocess) {
|
||||
// Start the jobRun and increment the jobCount
|
||||
await this.#prismaClient.jobRun.update({
|
||||
return await this.#prismaClient.jobRun.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "PREPROCESSING",
|
||||
queue: {
|
||||
update: {
|
||||
jobCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
runConnections: {
|
||||
create: createRunConnections,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await this.#prismaClient.jobRunExecution.create({
|
||||
data: {
|
||||
run: {
|
||||
connect: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
status: "PENDING",
|
||||
reason: "PREPROCESS",
|
||||
retryLimit: PREPROCESS_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Start the jobRun and increment the jobCount
|
||||
await this.#prismaClient.jobRun.update({
|
||||
return await this.#prismaClient.jobRun.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
queue: {
|
||||
update: {
|
||||
jobCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
status: "QUEUED",
|
||||
queuedAt: new Date(),
|
||||
runConnections: {
|
||||
create: createRunConnections,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await this.#prismaClient.jobRunExecution.create({
|
||||
data: {
|
||||
run: {
|
||||
connect: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
status: "PENDING",
|
||||
reason: "EXECUTE_JOB",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const execution = await updateRunAndCreateExecution();
|
||||
const updatedRun = await updateRun();
|
||||
|
||||
const job = await workerQueue.enqueue("performRunExecution", {
|
||||
id: execution.id,
|
||||
});
|
||||
|
||||
await this.#prismaClient.jobRunExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: {
|
||||
graphileJobId: job.id,
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue("startQueuedRuns", {
|
||||
id: run.queueId,
|
||||
});
|
||||
await enqueueRunExecutionV2(updatedRun, this.#prismaClient);
|
||||
}
|
||||
|
||||
async #handleMissingConnections(id: string, runConnectionsByKey: RunConnectionsByKey) {
|
||||
|
||||
@@ -55,8 +55,7 @@ export class DeliverHttpSourceRequestService {
|
||||
|
||||
const clientApi = new EndpointApi(
|
||||
httpSourceRequest.environment.apiKey,
|
||||
httpSourceRequest.endpoint.url,
|
||||
httpSourceRequest.endpoint.slug
|
||||
httpSourceRequest.endpoint.url
|
||||
);
|
||||
|
||||
const { response, events } = await clientApi.deliverHttpSourceRequest({
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import { EXECUTE_JOB_RETRY_LIMIT } from "~/consts";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import {
|
||||
FetchOperationSchema,
|
||||
FetchRequestInit,
|
||||
@@ -10,9 +6,13 @@ import {
|
||||
RedactString,
|
||||
calculateRetryAt,
|
||||
} from "@trigger.dev/core";
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
|
||||
import { formatUnknownError } from "~/utils/formatErrors.server";
|
||||
import { safeJsonFromResponse } from "~/utils/json";
|
||||
import { logger } from "../logger.server";
|
||||
import { formatUnknownError } from "~/utils/formatErrors.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
|
||||
type FoundTask = Awaited<ReturnType<typeof findTask>>;
|
||||
|
||||
@@ -192,7 +192,7 @@ export class PerformTaskOperationService {
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeTaskWithError(task: Task, output: any) {
|
||||
async #resumeTaskWithError(task: NonNullable<FoundTask>, output: any) {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.task.update({
|
||||
where: { id: task.id },
|
||||
@@ -243,34 +243,8 @@ export class PerformTaskOperationService {
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunExecution(task: Task, prisma: PrismaClientOrTransaction) {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
const newJobExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: task.runId,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
|
||||
const graphileJob = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: graphileJob.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
async #resumeRunExecution(task: NonNullable<FoundTask>, prisma: PrismaClientOrTransaction) {
|
||||
await enqueueRunExecutionV2(task.run, prisma);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +279,11 @@ async function findTask(prisma: PrismaClient, id: string) {
|
||||
where: { id },
|
||||
include: {
|
||||
attempts: true,
|
||||
run: {
|
||||
include: {
|
||||
queue: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { TriggerClient } from "@trigger.dev/sdk";
|
||||
import { PostHog } from "posthog-node";
|
||||
import { env } from "~/env.server";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { Project } from "~/models/project.server";
|
||||
import type { User } from "~/models/user.server";
|
||||
|
||||
type Options = {
|
||||
postHogApiKey?: string;
|
||||
trigger?: {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
class Telemetry {
|
||||
#posthogClient: PostHog | undefined = undefined;
|
||||
#triggerClient: TriggerClient | undefined = undefined;
|
||||
|
||||
constructor({ postHogApiKey, trigger }: Options) {
|
||||
if (postHogApiKey) {
|
||||
this.#posthogClient = new PostHog(postHogApiKey, { host: "https://app.posthog.com" });
|
||||
} else {
|
||||
console.log("No PostHog API key, so analytics won't track");
|
||||
}
|
||||
|
||||
if (trigger) {
|
||||
this.#triggerClient = new TriggerClient({
|
||||
id: "triggerdotdev",
|
||||
apiKey: trigger.apiKey,
|
||||
apiUrl: trigger.apiUrl,
|
||||
});
|
||||
console.log("Created telemetry TriggerClient");
|
||||
}
|
||||
}
|
||||
|
||||
user = {
|
||||
identify: ({ user, isNewUser }: { user: User; isNewUser: boolean }) => {
|
||||
if (this.#posthogClient === undefined) return;
|
||||
this.#posthogClient.identify({
|
||||
distinctId: user.id,
|
||||
properties: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
if (isNewUser) {
|
||||
this.#capture({
|
||||
userId: user.id,
|
||||
event: "user created",
|
||||
eventProperties: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
this.#triggerClient?.sendEvent({
|
||||
name: "user.created",
|
||||
payload: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
organization = {
|
||||
identify: ({ organization }: { organization: Organization }) => {
|
||||
if (this.#posthogClient === undefined) return;
|
||||
this.#posthogClient.groupIdentify({
|
||||
groupType: "organization",
|
||||
groupKey: organization.id,
|
||||
properties: {
|
||||
name: organization.title,
|
||||
slug: organization.slug,
|
||||
createdAt: organization.createdAt,
|
||||
updatedAt: organization.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
new: ({
|
||||
userId,
|
||||
organization,
|
||||
organizationCount,
|
||||
}: {
|
||||
userId: string;
|
||||
organization: Organization;
|
||||
organizationCount: number;
|
||||
}) => {
|
||||
if (this.#posthogClient === undefined) return;
|
||||
this.#capture({
|
||||
userId,
|
||||
event: "organization created",
|
||||
organizationId: organization.id,
|
||||
eventProperties: {
|
||||
id: organization.id,
|
||||
slug: organization.slug,
|
||||
title: organization.title,
|
||||
createdAt: organization.createdAt,
|
||||
updatedAt: organization.updatedAt,
|
||||
},
|
||||
userProperties: {
|
||||
organizationCount: organizationCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
project = {
|
||||
identify: ({ project }: { project: Project }) => {
|
||||
if (this.#posthogClient === undefined) return;
|
||||
this.#posthogClient.groupIdentify({
|
||||
groupType: "project",
|
||||
groupKey: project.id,
|
||||
properties: {
|
||||
name: project.name,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
new: ({
|
||||
userId,
|
||||
organizationId,
|
||||
project,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
project: Project;
|
||||
}) => {
|
||||
if (this.#posthogClient === undefined) return;
|
||||
this.#capture({
|
||||
userId,
|
||||
event: "project created",
|
||||
organizationId,
|
||||
eventProperties: {
|
||||
id: project.id,
|
||||
|
||||
title: project.name,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
#capture(event: CaptureEvent) {
|
||||
if (this.#posthogClient === undefined) return;
|
||||
let groups: Record<string, string> = {};
|
||||
|
||||
if (event.organizationId) {
|
||||
groups = {
|
||||
...groups,
|
||||
organization: event.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.projectId) {
|
||||
groups = {
|
||||
...groups,
|
||||
project: event.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.jobId) {
|
||||
groups = {
|
||||
...groups,
|
||||
workflow: event.jobId,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.environmentId) {
|
||||
groups = {
|
||||
...groups,
|
||||
environment: event.environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
let properties: Record<string, any> = {};
|
||||
if (event.eventProperties) {
|
||||
properties = {
|
||||
...properties,
|
||||
...event.eventProperties,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.userProperties) {
|
||||
properties = {
|
||||
...properties,
|
||||
$set: event.userProperties,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.userOnceProperties) {
|
||||
properties = {
|
||||
...properties,
|
||||
$set_once: event.userOnceProperties,
|
||||
};
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
distinctId: event.userId,
|
||||
event: event.event,
|
||||
properties,
|
||||
groups,
|
||||
};
|
||||
this.#posthogClient.capture(eventData);
|
||||
}
|
||||
}
|
||||
|
||||
type CaptureEvent = {
|
||||
userId: string;
|
||||
event: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
jobId?: string;
|
||||
environmentId?: string;
|
||||
eventProperties?: Record<string, any>;
|
||||
userProperties?: Record<string, any>;
|
||||
userOnceProperties?: Record<string, any>;
|
||||
};
|
||||
|
||||
export const telemetry = new Telemetry({
|
||||
postHogApiKey: env.POSTHOG_PROJECT_KEY,
|
||||
trigger:
|
||||
env.TELEMETRY_TRIGGER_API_KEY && env.TELEMETRY_TRIGGER_API_URL
|
||||
? {
|
||||
apiKey: env.TELEMETRY_TRIGGER_API_KEY,
|
||||
apiUrl: env.TELEMETRY_TRIGGER_API_URL,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -45,7 +45,7 @@ export class InitializeTriggerService {
|
||||
},
|
||||
});
|
||||
|
||||
const clientApi = new EndpointApi(environment.apiKey, endpoint.url, endpoint.slug);
|
||||
const clientApi = new EndpointApi(environment.apiKey, endpoint.url);
|
||||
|
||||
const registerMetadata = await clientApi.initializeTrigger(dynamicTrigger.slug, payload.params);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export class RegisterDynamicScheduleService {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
event: SCHEDULED_EVENT,
|
||||
event: [SCHEDULED_EVENT],
|
||||
source: "trigger.dev",
|
||||
payloadFilter: {},
|
||||
contextFilter: {},
|
||||
|
||||
@@ -76,7 +76,7 @@ export class RegisterTriggerSourceService {
|
||||
create: {
|
||||
dispatchableId: triggerSource.id,
|
||||
environmentId: environment.id,
|
||||
event: payload.rule.event,
|
||||
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
|
||||
source: payload.rule.source,
|
||||
payloadFilter: payload.rule.payload,
|
||||
contextFilter: payload.rule.context,
|
||||
@@ -86,7 +86,7 @@ export class RegisterTriggerSourceService {
|
||||
},
|
||||
},
|
||||
update: {
|
||||
event: payload.rule.event,
|
||||
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
|
||||
source: payload.rule.source,
|
||||
payloadFilter: payload.rule.payload,
|
||||
contextFilter: payload.rule.context,
|
||||
|
||||
@@ -6,14 +6,14 @@ import { env } from "~/env.server";
|
||||
import { ZodWorker } from "~/platform/zodWorker.server";
|
||||
import { sendEmail } from "./email.server";
|
||||
import { IndexEndpointService } from "./endpoints/indexEndpoint.server";
|
||||
import { RecurringEndpointIndexService } from "./endpoints/recurringEndpointIndex.server";
|
||||
import { DeliverEventService } from "./events/deliverEvent.server";
|
||||
import { InvokeDispatcherService } from "./events/invokeDispatcher.server";
|
||||
import { integrationAuthRepository } from "./externalApis/integrationAuthRepository.server";
|
||||
import { IntegrationConnectionCreatedService } from "./externalApis/integrationConnectionCreated.server";
|
||||
import { MissingConnectionCreatedService } from "./runs/missingConnectionCreated.server";
|
||||
import { PerformRunExecutionService } from "./runs/performRunExecution.server";
|
||||
import { RunFinishedService } from "./runs/runFinished.server";
|
||||
import { StartQueuedRunsService } from "./runs/startQueuedRuns.server";
|
||||
import { PerformRunExecutionV1Service } from "./runs/performRunExecutionV1.server";
|
||||
import { PerformRunExecutionV2Service } from "./runs/performRunExecutionV2.server";
|
||||
import { StartRunService } from "./runs/startRun.server";
|
||||
import { DeliverScheduledEventService } from "./schedules/deliverScheduledEvent.server";
|
||||
import { ActivateSourceService } from "./sources/activateSource.server";
|
||||
@@ -29,13 +29,9 @@ const workerCatalog = {
|
||||
}),
|
||||
scheduleEmail: DeliverEmailSchema,
|
||||
startRun: z.object({ id: z.string() }),
|
||||
performRunExecution: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
performTaskOperation: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
runFinished: z.object({ id: z.string() }),
|
||||
deliverHttpSourceRequest: z.object({ id: z.string() }),
|
||||
refreshOAuthToken: z.object({
|
||||
organizationId: z.string(),
|
||||
@@ -45,7 +41,7 @@ const workerCatalog = {
|
||||
id: z.string(),
|
||||
orphanedEvents: z.array(z.string()).optional(),
|
||||
}),
|
||||
startQueuedRuns: z.object({ id: z.string() }),
|
||||
|
||||
deliverEvent: z.object({ id: z.string() }),
|
||||
"events.invokeDispatcher": z.object({
|
||||
id: z.string(),
|
||||
@@ -63,10 +59,24 @@ const workerCatalog = {
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
performRunExecution: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
performRunExecutionV2: z.object({
|
||||
id: z.string(),
|
||||
reason: z.enum(["EXECUTE_JOB", "PREPROCESS"]),
|
||||
resumeTaskId: z.string().optional(),
|
||||
isRetry: z.boolean(),
|
||||
}),
|
||||
};
|
||||
|
||||
let workerQueue: ZodWorker<typeof workerCatalog>;
|
||||
let executionWorker: ZodWorker<typeof executionWorkerCatalog>;
|
||||
|
||||
declare global {
|
||||
var __worker__: ZodWorker<typeof workerCatalog>;
|
||||
var __executionWorker__: ZodWorker<typeof executionWorkerCatalog>;
|
||||
}
|
||||
|
||||
// this is needed because in development we don't want to restart
|
||||
@@ -75,28 +85,71 @@ declare global {
|
||||
// in production we'll have a single connection to the DB.
|
||||
if (env.NODE_ENV === "production") {
|
||||
workerQueue = getWorkerQueue();
|
||||
executionWorker = getExecutionWorkerQueue();
|
||||
} else {
|
||||
if (!global.__worker__) {
|
||||
global.__worker__ = getWorkerQueue();
|
||||
}
|
||||
workerQueue = global.__worker__;
|
||||
|
||||
if (!global.__executionWorker__) {
|
||||
global.__executionWorker__ = getExecutionWorkerQueue();
|
||||
}
|
||||
|
||||
executionWorker = global.__executionWorker__;
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
await workerQueue.initialize();
|
||||
if (env.WORKER_ENABLED === "true") {
|
||||
await workerQueue.initialize();
|
||||
}
|
||||
|
||||
if (env.EXECUTION_WORKER_ENABLED === "true") {
|
||||
await executionWorker.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "workerQueue",
|
||||
prisma,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: 5,
|
||||
pollInterval: 1000,
|
||||
concurrency: env.WORKER_CONCURRENCY,
|
||||
pollInterval: env.WORKER_POLL_INTERVAL,
|
||||
noPreparedStatements: env.DATABASE_URL !== env.DIRECT_URL,
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.WORKER_CONCURRENCY,
|
||||
},
|
||||
schema: workerCatalog,
|
||||
recurringTasks: {
|
||||
// Run this every 5 minutes
|
||||
autoIndexProductionEndpoints: {
|
||||
pattern: "*/5 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
const service = new RecurringEndpointIndexService();
|
||||
|
||||
await service.call(payload.ts);
|
||||
},
|
||||
},
|
||||
// Run this every hour
|
||||
purgeOldIndexings: {
|
||||
pattern: "0 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
// Delete indexings that are older than 7 days
|
||||
await prisma.endpointIndex.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
"events.invokeDispatcher": {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new InvokeDispatcherService();
|
||||
@@ -105,6 +158,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
"events.deliverScheduled": {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 5,
|
||||
handler: async ({ id, payload }, job) => {
|
||||
const service = new DeliverScheduledEventService();
|
||||
@@ -113,6 +167,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
connectionCreated: {
|
||||
priority: 10, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new IntegrationConnectionCreatedService();
|
||||
@@ -121,6 +176,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
missingConnectionCreated: {
|
||||
priority: 10, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new MissingConnectionCreatedService();
|
||||
@@ -128,24 +184,8 @@ function getWorkerQueue() {
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
runFinished: {
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new RunFinishedService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
startQueuedRuns: {
|
||||
maxAttempts: 3,
|
||||
queueName: (payload) => `queue:${payload.id}`,
|
||||
handler: async (payload, job) => {
|
||||
const service = new StartQueuedRunsService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
activateSource: {
|
||||
priority: 10, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new ActivateSourceService();
|
||||
@@ -154,7 +194,8 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
deliverHttpSourceRequest: {
|
||||
maxAttempts: 5,
|
||||
priority: 1, // smaller number = higher priority
|
||||
maxAttempts: 14,
|
||||
handler: async (payload, job) => {
|
||||
const service = new DeliverHttpSourceRequestService();
|
||||
|
||||
@@ -162,23 +203,16 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
startRun: {
|
||||
maxAttempts: 8,
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 4,
|
||||
handler: async (payload, job) => {
|
||||
const service = new StartRunService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performRunExecution: {
|
||||
queueName: (payload) => `runs:${payload.id}`,
|
||||
maxAttempts: 1,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformRunExecutionService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performTaskOperation: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
queueName: (payload) => `tasks:${payload.id}`,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
@@ -196,6 +230,8 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
indexEndpoint: {
|
||||
priority: 1, // smaller number = higher priority
|
||||
maxAttempts: 7,
|
||||
handler: async (payload, job) => {
|
||||
const service = new IndexEndpointService();
|
||||
|
||||
@@ -203,6 +239,8 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
deliverEvent: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 5,
|
||||
handler: async (payload, job) => {
|
||||
const service = new DeliverEventService();
|
||||
|
||||
@@ -210,8 +248,9 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
refreshOAuthToken: {
|
||||
priority: 8, // smaller number = higher priority
|
||||
queueName: "internal-queue",
|
||||
maxAttempts: 10,
|
||||
maxAttempts: 7,
|
||||
handler: async (payload, job) => {
|
||||
await integrationAuthRepository.refreshConnection({
|
||||
connectionId: payload.connectionId,
|
||||
@@ -222,4 +261,42 @@ function getWorkerQueue() {
|
||||
});
|
||||
}
|
||||
|
||||
export { workerQueue };
|
||||
function getExecutionWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "executionWorker",
|
||||
prisma,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: env.EXECUTION_WORKER_CONCURRENCY,
|
||||
pollInterval: env.EXECUTION_WORKER_POLL_INTERVAL,
|
||||
noPreparedStatements: env.DATABASE_URL !== env.DIRECT_URL,
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.EXECUTION_WORKER_CONCURRENCY,
|
||||
},
|
||||
schema: executionWorkerCatalog,
|
||||
tasks: {
|
||||
performRunExecution: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 1,
|
||||
handler: async (payload, job) => {
|
||||
// This is a legacy task that we don't use anymore, but needs to be here for backwards compatibility
|
||||
// TODO: remove this once all performRunExecution tasks have been processed
|
||||
const service = new PerformRunExecutionV1Service();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performRunExecutionV2: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 12,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformRunExecutionV2Service();
|
||||
|
||||
await service.call(payload.id, payload.reason, payload.isRetry, payload.resumeTaskId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { executionWorker, workerQueue };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Roboto+Mono:wght@300;400;500;600;700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Roboto+Mono:wght@300;400;500;600;700&display=swap&family=Poppins:wght@600&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -45,13 +45,10 @@ export function useMatchesData(
|
||||
const paths = Array.isArray(id) ? id : [id];
|
||||
|
||||
// Get the first matching route
|
||||
const route = paths.reduce(
|
||||
(acc, path) => {
|
||||
if (acc) return acc;
|
||||
return matchingRoutes.find((route) => route.id === path);
|
||||
},
|
||||
undefined as RouteMatch | undefined
|
||||
);
|
||||
const route = paths.reduce((acc, path) => {
|
||||
if (acc) return acc;
|
||||
return matchingRoutes.find((route) => route.id === path);
|
||||
}, undefined as RouteMatch | undefined);
|
||||
|
||||
return route;
|
||||
}
|
||||
@@ -76,7 +73,7 @@ export function hydrateDates(object: any): any {
|
||||
if (
|
||||
typeof object === "string" &&
|
||||
object.match(/\d{4}-\d{2}-\d{2}/) &&
|
||||
!isNaN(Date.parse(object))
|
||||
!Number.isNaN(Date.parse(object))
|
||||
) {
|
||||
return new Date(object);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ExternalScriptsFunction } from "remix-utils";
|
||||
import { BreadcrumbItem } from "~/components/navigation/Breadcrumb";
|
||||
|
||||
export type Handle = {
|
||||
breadcrumb?: BreadcrumbItem;
|
||||
expandSidebar?: boolean;
|
||||
scripts?: ExternalScriptsFunction;
|
||||
};
|
||||
|
||||
@@ -43,3 +43,20 @@ export async function safeBodyFromResponse<T>(
|
||||
return parsedJson.data;
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeParseBodyFromResponse<T>(
|
||||
response: Response,
|
||||
schema: z.Schema<T>
|
||||
): Promise<z.SafeParseReturnType<unknown, T> | undefined> {
|
||||
try {
|
||||
const unknownJson = await response.json();
|
||||
|
||||
if (!unknownJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedJson = schema.safeParse(unknownJson);
|
||||
|
||||
return parsedJson;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,6 @@ export function confirmBasicDetailsPath() {
|
||||
return `/confirm-basic-details`;
|
||||
}
|
||||
|
||||
export function invitationCodePath() {
|
||||
return `/invitation-code`;
|
||||
}
|
||||
|
||||
export function acceptInvitePath(token: string) {
|
||||
return `/invite-accept?token=${token}`;
|
||||
}
|
||||
@@ -131,6 +127,10 @@ export function projectEnvironmentsPath(organization: OrgForPath, project: Proje
|
||||
return `${projectPath(organization, project)}/environments`;
|
||||
}
|
||||
|
||||
export function projectStreamingPath(id: string) {
|
||||
return `/resources/projects/${id}/jobs/stream`;
|
||||
}
|
||||
|
||||
export function projectEnvironmentsStreamingPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eventStream } from "remix-utils";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type SseProps = {
|
||||
request: Request;
|
||||
@@ -30,12 +31,38 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }:
|
||||
};
|
||||
|
||||
return eventStream(request.signal, (send) => {
|
||||
const safeSend = (args: { event?: string; data: string }) => {
|
||||
try {
|
||||
send(args);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE, aborting", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
args,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug("Uknown error sending SSE, aborting", {
|
||||
error,
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
abort();
|
||||
}
|
||||
};
|
||||
|
||||
pinger = setInterval(() => {
|
||||
send({ event: "ping", data: new Date().toISOString() });
|
||||
safeSend({ event: "ping", data: new Date().toISOString() });
|
||||
}, pingInterval);
|
||||
|
||||
updater = setInterval(async () => {
|
||||
run(send, abort);
|
||||
run(safeSend, abort);
|
||||
}, updateInterval);
|
||||
|
||||
return abort;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:seed": "node prisma/seed.js",
|
||||
"db:seed:local": "ts-node prisma/seed.ts",
|
||||
"generate:sourcemaps": "remix build --sourcemap",
|
||||
"clean:sourcemaps": "run-s clean:sourcemaps:*",
|
||||
"clean:sourcemaps:public": "rimraf ./build/**/*.map",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@trigger.dev/companyicons": "^1.5.14",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"@uiw/react-codemirror": "^4.19.5",
|
||||
"class-variance-authority": "^0.5.2",
|
||||
"clsx": "^1.2.1",
|
||||
@@ -89,7 +91,6 @@
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-hotkeys-hook": "^3.4.7",
|
||||
|
||||
@@ -12,8 +12,19 @@ module.exports = {
|
||||
ignoredRouteFiles: ["**/.*"],
|
||||
devServerPort: 8002,
|
||||
serverModuleFormat: "cjs",
|
||||
serverDependenciesToBundle: ["marked", "axios", "@trigger.dev/core", "emails", "highlight.run"],
|
||||
serverDependenciesToBundle: [
|
||||
"marked",
|
||||
"axios",
|
||||
"@trigger.dev/core",
|
||||
"@trigger.dev/sdk",
|
||||
"emails",
|
||||
"highlight.run",
|
||||
],
|
||||
watchPaths: async () => {
|
||||
return ["../../packages/core/src/**/*", "../../packages/emails/src/**/*"];
|
||||
return [
|
||||
"../../packages/core/src/**/*",
|
||||
"../../packages/trigger-sdk/src/**/*",
|
||||
"../../packages/emails/src/**/*",
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+11
-7
@@ -54,14 +54,18 @@ app.all(
|
||||
|
||||
const port = process.env.REMIX_APP_PORT || 3000;
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
// require the built app so we're ready when the first request comes in
|
||||
require(BUILD_DIR);
|
||||
console.log(`✅ app ready: http://localhost:${port}`);
|
||||
});
|
||||
if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
const server = app.listen(port, () => {
|
||||
// require the built app so we're ready when the first request comes in
|
||||
require(BUILD_DIR);
|
||||
console.log(`✅ app ready: http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Handle shutdowns gracefully
|
||||
createTerminus(server, { signals: ["SIGINT", "SIGTERM"], timeout: 5000 });
|
||||
// Handle shutdowns gracefully
|
||||
createTerminus(server, { signals: ["SIGINT", "SIGTERM"], timeout: 5000 });
|
||||
} else {
|
||||
console.log(`✅ app ready (skipping http server)`);
|
||||
}
|
||||
|
||||
function purgeRequireCache() {
|
||||
// purge require cache on requests for "server side HMR" this won't let
|
||||
|
||||
@@ -76,6 +76,7 @@ module.exports = {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "sans-serif"],
|
||||
mono: ["Roboto Mono", "monospace"],
|
||||
title: ["Poppins", "sans-serif"]
|
||||
},
|
||||
fontSize: {
|
||||
xxs: [
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
RUN pnpm install --prod --no-frozen-lockfile
|
||||
COPY --from=pruner --chown=node:node /triggerdotdev/packages/database/prisma/schema.prisma /triggerdotdev/packages/database/prisma/schema.prisma
|
||||
RUN pnpx prisma@^4.16.0 generate --schema /triggerdotdev/packages/database/prisma/schema.prisma
|
||||
# RUN pnpm add @prisma/client@5.1.1 -w
|
||||
RUN pnpx prisma@4.16.0 generate --schema /triggerdotdev/packages/database/prisma/schema.prisma
|
||||
|
||||
## Builder (builds the webapp)
|
||||
FROM base AS builder
|
||||
|
||||
@@ -32,6 +32,7 @@ services:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
SESSION_SECRET: secret123
|
||||
MAGIC_LINK_SECRET: secret123
|
||||
REMIX_APP_PORT: 3030
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
```typescript Wait example
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "delay-job",
|
||||
name: "Delay Job",
|
||||
version: "0.0.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
```typescript
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
//... other options
|
||||
integrations: {
|
||||
slack,
|
||||
|
||||
@@ -4,9 +4,8 @@ description: "Integrations make it easy to use APIs in your Jobs"
|
||||
---
|
||||
|
||||
<Note>
|
||||
You can use any API in your Jobs by using existing Node.js SDKs or HTTP
|
||||
requests. Integrations just make it much easier especially when you want to
|
||||
use OAuth. And you get great logging.
|
||||
You can use any API in your Jobs by using existing Node.js SDKs or HTTP requests. Integrations
|
||||
just make it much easier especially when you want to use OAuth. And you get great logging.
|
||||
</Note>
|
||||
|
||||
An Integration is a package you install that makes it easy to work with a specific API. They:
|
||||
@@ -35,7 +34,7 @@ const slack = new Slack({
|
||||
id: "slack",
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "alert-on-new-github-issues",
|
||||
name: "Alert on new GitHub issues",
|
||||
version: "0.1.1",
|
||||
@@ -73,7 +72,7 @@ There are some things worth highlighting here:
|
||||
|
||||
### API Keys and Tokens
|
||||
|
||||
You the value in when creating your Integration client. They are never sent from your server by the Trigger.dev service, they are local to your servers. We recommend you use a secure method of storing these values and passing them to your code, like environment variables.
|
||||
You provide the API Key value when creating your Integration client. Keys aren't sent from your server by the Trigger.dev service, they are local to your servers. We recommend you use a secure method of storing these values and passing them to your code, like environment variables.
|
||||
|
||||
### OAuth
|
||||
|
||||
@@ -84,29 +83,16 @@ You can use OAuth to authenticate your internal team with an Integration or to a
|
||||
## References
|
||||
|
||||
<CardGroup>
|
||||
<Card
|
||||
title="Integrations Dashboard"
|
||||
icon="sidebar"
|
||||
href="documentation/guides/integrations"
|
||||
>
|
||||
The Integrations Dashboard allows you to manage your Integrations and setup
|
||||
OAuth.
|
||||
<Card title="Integrations Dashboard" icon="sidebar" href="documentation/guides/integrations">
|
||||
The Integrations Dashboard allows you to manage your Integrations and setup OAuth.
|
||||
</Card>
|
||||
<Card
|
||||
title="Trigger.dev Connect"
|
||||
icon="user-plus"
|
||||
href="/documentation/concepts/connect"
|
||||
>
|
||||
<Card title="Trigger.dev Connect" icon="user-plus" href="/documentation/concepts/connect">
|
||||
Authenticate your users with an Integration using Trigger.dev Connect.
|
||||
</Card>
|
||||
<Card title="View Integrations" icon="grid-2" href="/integrations">
|
||||
Trigger.dev integrates with a wide range of services.
|
||||
</Card>
|
||||
<Card
|
||||
title="Create an Integration"
|
||||
icon="square-plus"
|
||||
href="/integrations/create"
|
||||
>
|
||||
<Card title="Create an Integration" icon="square-plus" href="/integrations/create">
|
||||
Create an Integration for your own use or as a public package.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -17,7 +17,7 @@ A Job is made up of a few things:
|
||||
|
||||
```ts
|
||||
//Job definition – uses the client
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
// 1. Metadata
|
||||
id: "event-1",
|
||||
name: "Run when the foo.bar event happens",
|
||||
@@ -51,11 +51,7 @@ Events [trigger](/documentation/concepts/triggers) Jobs. Jobs generate a [Run](/
|
||||
<Card title="Job SDK reference" icon="wrench" href="/sdk/job">
|
||||
Detailed SDK reference for Jobs.
|
||||
</Card>
|
||||
<Card
|
||||
title="Managing Jobs Dashboard"
|
||||
icon="globe"
|
||||
href="/documentation/guides/managing-jobs"
|
||||
>
|
||||
<Card title="Managing Jobs Dashboard" icon="globe" href="/documentation/guides/managing-jobs">
|
||||
Viewing and managing your Jobs in the Dashboard.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -10,7 +10,7 @@ description: "When a [Job](/documentation/concepts/jobs) is [Triggered](/documen
|
||||
A Run is a record of the execution of a Job. It is created from `run()` function of a Job.
|
||||
|
||||
```ts
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "event-1",
|
||||
name: "Run when the foo.bar event happens",
|
||||
version: "0.0.1",
|
||||
@@ -23,12 +23,15 @@ new Job(client, {
|
||||
// 1. Run function with params
|
||||
run: async (payload, io, ctx) => {
|
||||
// 2. Regular code and Tasks
|
||||
// 3. Optionally return data from run execution
|
||||
return { status: 'success' }
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
1. The `run()` function is called with some useful parameters. More on that in a second.
|
||||
2. Inside the run function you can write regular code and use [Tasks](/documentation/concepts/tasks).
|
||||
3. You can return data, which will then be retrievable with [getRun](/sdk/triggerclient/instancemethods/getrun) or the [React hooks](/documentation/guides/react-hooks).
|
||||
|
||||
## Resumability
|
||||
|
||||
@@ -64,11 +67,7 @@ The `context` object gives you access to information about the current Run, Job,
|
||||
## References
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Viewing Runs Dashboard"
|
||||
icon="globe"
|
||||
href="/documentation/guides/viewing-runs"
|
||||
>
|
||||
<Card title="Viewing Runs Dashboard" icon="globe" href="/documentation/guides/viewing-runs">
|
||||
View all Runs for a Job, all the way down to individual Tasks.
|
||||
</Card>
|
||||
<Card title="`io` SDK Reference" icon="wrench" href="/sdk/io">
|
||||
|
||||
@@ -10,7 +10,7 @@ description: "Tasks are individual building blocks of a Run."
|
||||
In the `run()` function you can use regular code and you can use Tasks.
|
||||
|
||||
```ts
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "new-user",
|
||||
name: "Run when a new user signs up",
|
||||
version: "0.0.1",
|
||||
@@ -44,13 +44,9 @@ new Job(client, {
|
||||
await io.wait("wait", 60 * 60 * 3); // wait for 3 hours
|
||||
|
||||
// You can wrap your own code in a Task, for retrying, resumability and logging
|
||||
const response = await io.runTask(
|
||||
"my-task",
|
||||
{ name: "My Task" },
|
||||
async () => {
|
||||
return await longRunningCode(payload.userId);
|
||||
}
|
||||
);
|
||||
const response = await io.runTask("my-task", { name: "My Task" }, async () => {
|
||||
return await longRunningCode(payload.userId);
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
@@ -76,28 +72,16 @@ The first param of all Tasks is a `key`. This is a unique identifier for the Tas
|
||||
## References
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Resumability"
|
||||
icon="clock"
|
||||
href="/documentation/concepts/resumability"
|
||||
>
|
||||
<Card title="Resumability" icon="clock" href="/documentation/concepts/resumability">
|
||||
Runs can be very long-running. Learn how we handle this.
|
||||
</Card>
|
||||
<Card
|
||||
title="Integrations"
|
||||
icon="grid-2"
|
||||
href="/documentation/concepts/integrations"
|
||||
>
|
||||
<Card title="Integrations" icon="grid-2" href="/documentation/concepts/integrations">
|
||||
Integrations utilize Tasks.
|
||||
</Card>
|
||||
<Card title="`io` SDK Reference" icon="wrench" href="/sdk/io">
|
||||
The `io` object allows you to easily run a Task yourself.
|
||||
</Card>
|
||||
<Card
|
||||
title="Viewing Runs Dashboard"
|
||||
icon="globe"
|
||||
href="/documentation/guides/viewing-runs"
|
||||
>
|
||||
<Card title="Viewing Runs Dashboard" icon="globe" href="/documentation/guides/viewing-runs">
|
||||
View all Runs for a Job, all the way down to individual Tasks.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -17,7 +17,7 @@ const dynamicSchedule = new DynamicSchedule(client, {
|
||||
});
|
||||
|
||||
//2. create a Job that is attached to the dynamic schedule
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "user-dynamicinterval",
|
||||
name: "User Dynamic Interval",
|
||||
version: "0.1.1",
|
||||
@@ -41,7 +41,7 @@ async function registerUserCronJob(userId: string, userSchedule: string) {
|
||||
}
|
||||
|
||||
//5. Register inside other Jobs
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "register-dynamicinterval",
|
||||
name: "Register Dynamic Interval",
|
||||
version: "0.1.1",
|
||||
@@ -77,7 +77,7 @@ const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, {
|
||||
});
|
||||
|
||||
//2. create a Job that is attached to the dynamic trigger
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "listen-for-dynamic-trigger",
|
||||
name: "Listen for dynamic trigger",
|
||||
version: "0.1.1",
|
||||
@@ -87,9 +87,7 @@ new Job(client, {
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.slack.postMessage("Slack 📝", {
|
||||
text: `New Issue opened on repo: ${
|
||||
payload.issue.html_url
|
||||
}. \n\n${JSON.stringify(ctx)}`,
|
||||
text: `New Issue opened on repo: ${payload.issue.html_url}. \n\n${JSON.stringify(ctx)}`,
|
||||
channel: "C04GWUTDC3W",
|
||||
});
|
||||
},
|
||||
@@ -105,7 +103,7 @@ async function registerRepo(owner: string, repo: string) {
|
||||
}
|
||||
|
||||
//4. Register inside other Jobs
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "new-repo",
|
||||
name: "New repo",
|
||||
version: "0.1.1",
|
||||
|
||||
@@ -23,7 +23,7 @@ You can always start out by using `z.any()` as your schema, and then later on yo
|
||||
## Example
|
||||
|
||||
```ts
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "new-user-slack",
|
||||
name: "New user slack message",
|
||||
version: "0.1.0",
|
||||
@@ -58,9 +58,8 @@ new Job(client, {
|
||||
```
|
||||
|
||||
<Note>
|
||||
You can subscribe to the same event from multiple different Jobs. This is
|
||||
useful if you want to send an event to multiple different services or if you
|
||||
want to keep each Job small and simple.
|
||||
You can subscribe to the same event from multiple different Jobs. This is useful if you want to
|
||||
send an event to multiple different services or if you want to keep each Job small and simple.
|
||||
</Note>
|
||||
|
||||
## Sending events
|
||||
@@ -84,7 +83,7 @@ await client.sendEvent({
|
||||
You can use `io.sendEvent()` to send events from inside a Job run, to trigger another. [View the SDK reference](/sdk/io/sendevent).
|
||||
|
||||
```ts
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "event-1",
|
||||
name: "Run when the foo.bar event happens",
|
||||
version: "0.0.1",
|
||||
@@ -108,6 +107,8 @@ new Job(client, {
|
||||
|
||||
They are declarative pattern-matching rules, modeled after [AWS EventBridge patterns](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html).
|
||||
|
||||
Here's a more detailed explanation of [Event filters](/documentation/guides/event-filter)
|
||||
|
||||
Given the following custom event payload:
|
||||
|
||||
```json
|
||||
|
||||
@@ -15,7 +15,7 @@ This job will run every 60 seconds, starting 60 seconds after this Job is first
|
||||
```ts
|
||||
import { Job, intervalTrigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "scheduled-job-1",
|
||||
name: "Scheduled Job 1",
|
||||
version: "0.1.1",
|
||||
@@ -43,7 +43,7 @@ This job will run at 2:30pm every Monday. You can get help with [CRON syntax](ht
|
||||
```ts
|
||||
import { Job, cronTrigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "scheduled-job-2",
|
||||
name: "Scheduled Job 2",
|
||||
version: "0.1.1",
|
||||
|
||||
@@ -32,7 +32,7 @@ const github = new Github({
|
||||
token: process.env.GITHUB_API_KEY!,
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "critical-issue-alert",
|
||||
name: "Critical Issue Alert",
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -42,7 +42,7 @@ const slack = new Slack({
|
||||
id: "slack",
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "critical-issue-alert",
|
||||
name: "Critical Issue Alert",
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
---
|
||||
title: "Event Filters"
|
||||
description: "They are declarative pattern-matching rules, modeled after [AWS EventBridge patterns](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html)."
|
||||
---
|
||||
|
||||
Given the following custom event payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": "jexAgaeJFJsrGfans1pxqm",
|
||||
"type": "15 Min Meeting",
|
||||
"price": 0,
|
||||
"title": "15 Min Meeting between Eric Allam and John Doe",
|
||||
"length": 15,
|
||||
"status": "ACCEPTED",
|
||||
"endTime": "2023-01-25T16:00:00Z",
|
||||
"bookingId": 198052,
|
||||
"organizer": {
|
||||
"id": 32794,
|
||||
"name": "Eric Allam",
|
||||
"email": "eric@trigger.dev",
|
||||
"language": { "locale": "en" },
|
||||
"timeZone": "Europe/London"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The following event filter would match the event:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": ["15 Min Meeting"],
|
||||
"status": ["ACCEPTED", "REJECTED"],
|
||||
"organizer": {
|
||||
"name": ["Eric Allam"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For an event pattern to match an event, the event must contain all the field names listed in the event pattern. The field names must also appear in the event with the same nesting structure.
|
||||
|
||||
The value of each field name in the event pattern must be an array of strings, numbers, or booleans. The event pattern matches the event if the value of the field name in the event is equal to any of the values in the array.
|
||||
|
||||
Effectively, each array is an OR condition, and the entire event pattern is an AND condition.
|
||||
|
||||
So the above event filter will match because `status == "ACCEPTED"`, and it would also match if `status == "REJECTED"`.
|
||||
|
||||
## String Filters
|
||||
|
||||
String filters are used to match string values within the event payload. You can filter events based on exact string matches, case-insensitive matches, starts-with, ends-with, and more.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `name: ["John"]`: Triggers the job if the name field in the event payload is exactly "John".
|
||||
- `name: [{ $startsWith: "Jo" }]`: Triggers the job if the name field starts with "Jo".
|
||||
- `name: [{ $endsWith: "hn" }]`: Triggers the job if the name field ends with "hn".
|
||||
- `name: [{ $ignoreCaseEquals: "john" }]` : Triggers the job if the name field is equal to "john" regardless of case.
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "notify-hr-new-hires-specific-hobbies",
|
||||
name: "Notify HR of New Hires with Specific Hobbies",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "employee.hired",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
hobbies: z.array(z.string()),
|
||||
}),
|
||||
filter: {
|
||||
name: [{ $startsWith: "Jo" }], // Only trigger for employees whose name starts with "Jo"
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "notify-hr-new-hires-specific-hobbies",
|
||||
name: "Notify HR of New Hires with Specific Hobbies",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "employee.hired",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
hobbies: z.array(z.string()),
|
||||
}),
|
||||
filter: {
|
||||
name: ["John"], // Only trigger for employees whose name is "John"
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
## Boolean Filters
|
||||
|
||||
Boolean filters are used to filter events based on boolean values within the event payload.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `paidPlan: [true]`: Triggers the job if the `PaidPlan` field in the event payload is `true`.
|
||||
- `isAdmin: [false]`: Triggers the job if the `isAdmin` field in the event payload is `false`.
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "notify-admins-paid-users",
|
||||
name: "Notify Admins of New Paid Users",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.created",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
paidPlan: z.boolean(),
|
||||
isAdmin: z.boolean(),
|
||||
}),
|
||||
filter: {
|
||||
paidPlan: [true],
|
||||
isAdmin: [true], // Only trigger for paid users who are also admins
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "send-premium-content",
|
||||
name: "Send Premium Content to Subscribed Users",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "content.available",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
subscriptionStatus: z.boolean(),
|
||||
}),
|
||||
filter: {
|
||||
subscriptionStatus: [true], // Only trigger for users with an active subscription
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
## Number Filters
|
||||
|
||||
Number filters are used to filter events based on numeric values within the event payload. It can also be used to perform numeric comparisons on values within the event payload.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `age [18]`: Triggers the job if the `age` field in the event payload is equal to `18`.
|
||||
- `score: [{ $gt: 90 }]` : Triggers the job if the score field is greater than `90`.
|
||||
- `score: [{ $gte: 90 }]` : Triggers the job if the score field is greater than or equal to `90`.
|
||||
- `score: [{ $lt: 90 }]` : Triggers the job if the score field is less than `90`.
|
||||
- `score: [{ $lte: 90 }]` : Triggers the job if the score field is less than or equal to `90`.
|
||||
- `age: [{ $gt: 20 }, { $lt: 40 }]`: Triggers the job if the `age` field is greater than 20 and less than 40.
|
||||
- `score: [{ $between: [90, 110] }]`: Triggers the job if the `score` field is between 90 and 110.
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "send-discount-high-scores",
|
||||
name: "Send Discount to High Scoring Customers",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "customer.score.updated",
|
||||
schema: z.object({
|
||||
customerId: z.string(),
|
||||
score: z.number(),
|
||||
}),
|
||||
filter: {
|
||||
score: [{ $gt: 90 }], // Only trigger for customers with a score greater than 90
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "notify-upcoming-birthdays",
|
||||
name: "Notify Users of Upcoming Birthdays",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.birthday",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
age: z.number(),
|
||||
}),
|
||||
filter: {
|
||||
age: [25], // Only trigger for users who are turning 25
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "send-discount-code",
|
||||
name: "Send Discount Code",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.purchase",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
age: z.number(),
|
||||
}),
|
||||
filter: {
|
||||
age: [{ $gt: 18 }, { $lt: 30 }], // Only trigger for users aged between 18 and 30
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "update-user-level",
|
||||
name: "Update User Level",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.scoreUpdated",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
score: z.number(),
|
||||
}),
|
||||
filter: {
|
||||
score: [{ $between: [50, 100] }], // Only trigger for scores between 50 and 100 (inclusive)
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
## Array Filters
|
||||
|
||||
Array filters are used to filter events based on the content of arrays within the event payload.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `hobbies: [{ $includes: "reading" }]`: Triggers the job if the hobbies array includes the value "reading".
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "recommend-books-avid-readers",
|
||||
name: "Recommend Books to Avid Readers",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.preferences.updated",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
hobbies: z.array(z.string()),
|
||||
}),
|
||||
filter: {
|
||||
hobbies: [{ $includes: "reading" }], // Only trigger for users whose hobbies include "reading"
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
//run function
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Existence Filters
|
||||
|
||||
Existence filters are used to filter events based on the presence or absence of certain keys within the event payload.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `name: [{ $exists: true }]` : Triggers the job if the `name` field exists in the event payload.
|
||||
- `foo: [{ $exists: false }]` : Triggers the job if the foo field does not exist in the event payload.
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "send-welcome-email",
|
||||
name: "Send Welcome Email",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.created",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
filter: {
|
||||
name: [{ $exists: true }], // Only trigger for events where the 'name' field exists
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "notify-admin-missing-field",
|
||||
name: "Notify Admins of Missing Field",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.updated",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
}),
|
||||
filter: {
|
||||
foo: [{ $exists: false }], // Only trigger if the 'foo' field does not exist
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
## Combining Filters
|
||||
|
||||
Filters can be combined to create more complex conditions for triggering jobs.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `name: ["Alice"], age: [30]` : Triggers the job if the name is "Alice" and the age is 30.
|
||||
- `name: ["Alice"], age: [{ $gt: 20 }, { $lt: 40 }]` : Triggers the job if the name is "Alice" and the age is between 20 and 40.
|
||||
- `name: ["Alice", "Bob"]` : Triggers the job if the name is either "Alice" or "Bob".
|
||||
- `name: ["Alice", "Bob"], age: [30]` : Triggers the job if the name is either "Alice" or "Bob" and the age is 30.
|
||||
|
||||
### Examples
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "notify-vip-event",
|
||||
name: "Notify VIP Customers",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "user.registration",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
totalPurchases: z.number(),
|
||||
}),
|
||||
filter: {
|
||||
age: [{ $gt: 25 }, { $lt: 60 }], // Trigger for users aged between 25 and 60
|
||||
totalPurchases: [{ $gte: 3 }], // Trigger for users with 3 or more total purchases
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "process-orders",
|
||||
name: "Process Orders",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "order.placed",
|
||||
schema: z.object({
|
||||
orderId: z.string(),
|
||||
customerId: z.string(),
|
||||
region: z.string(),
|
||||
totalAmount: z.number(),
|
||||
}),
|
||||
filter: {
|
||||
region: ["US", "Canada"], // Trigger for orders from US or Canada
|
||||
totalAmount: [{ $gt: 100 }, { $lt: 1000 }], // Trigger for orders with a total amount between 100 and 1000
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
## Using with Stripe triggers
|
||||
|
||||
Here is an example of a Stripe Trigger with an Event Filter:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "stripe-on-subscription-created",
|
||||
name: "Stripe On Subscription Created",
|
||||
version: "0.1.0",
|
||||
trigger: stripe.onCustomerSubscriptionCreated({
|
||||
filter: {
|
||||
currency: ["usd"],
|
||||
},
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.logger.info("ctx", { ctx });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The job is triggered by the `onCustomerSubscriptionCreated` event from Stripe. It is configured to trigger only when certain conditions are met. In this case, the job is triggered when a subscription is created with the following condition:
|
||||
|
||||
- Currency: Only subscriptions with the currency "USD" will trigger this job.
|
||||
|
||||
## Using with Supabase triggers
|
||||
|
||||
Here is an example of a Supabase Trigger with an Event Filter:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "supabase-management-example-objects-storage",
|
||||
name: "Supabase Management Example Object Storage",
|
||||
version: "0.1.0",
|
||||
trigger: triggers.onInserted({
|
||||
schema: "storage",
|
||||
table: "objects",
|
||||
filter: {
|
||||
record: {
|
||||
bucket_id: ["example_bucket"], // Only trigger for objects in the "example_bucket" bucket
|
||||
name: [
|
||||
{
|
||||
$endsWith: ".png", // Only trigger for objects with a name ending in ".png"
|
||||
},
|
||||
],
|
||||
path_tokens: [
|
||||
{
|
||||
$includes: "images", // Only trigger for objects with a path that includes the token "images"
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
supabase,
|
||||
},
|
||||
run: async (payload, io, ctx) => {},
|
||||
});
|
||||
```
|
||||
|
||||
- This Listens for insert events in the "objects" table of the "storage" schema.
|
||||
- Conditions for Triggering:
|
||||
- Object belongs to "example_bucket".
|
||||
- Object's name ends with ".png".
|
||||
- Object's path includes the token "images".
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: Using Manual Setup
|
||||
description: How to Manually Initialize Trigger.dev in your Next.js project
|
||||
---
|
||||
|
||||
<Accordion defaultOpen title="Don't have a Next.js project yet to add Trigger.dev to? No problem, you can complete the Manual Setup using a blank Next.js project:">
|
||||
Create a blank project by running the `create-next-app` command in your terminal:
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest
|
||||
```
|
||||
|
||||
Trigger.dev works with either the Pages or App Router configuration.
|
||||
|
||||
</Accordion>
|
||||
|
||||
## Installing Required Packages
|
||||
To begin, install the necessary packages in your Next.js project directory. You can choose one of the following package managers:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
|
||||
npm i @trigger.dev/sdk @trigger-dev/nextjs
|
||||
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm install @trigger.dev/sdk @trigger-dev/nextjs
|
||||
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn add @trigger.dev/sdk @trigger-dev/nextjs
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
<br />
|
||||
|
||||
<Note>Ensure that you execute this command within a Next.js project.</Note>
|
||||
|
||||
## Obtaining the Development API Key
|
||||
To locate your development API key, login to the [Trigger.dev
|
||||
dashboard](https://cloud.trigger.dev) and select the Project you want to
|
||||
connect to. Then click on the Environments & API Keys tab in the left menu.
|
||||
You can copy your development API Key from the field at the top of this page.
|
||||
(Your development key will start with `tr_dev_`).
|
||||
|
||||
## Adding Environment Variables
|
||||
Create a `.env.local` file at the root of your project and include your Trigger API key and URL like this:
|
||||
|
||||
```bash
|
||||
|
||||
TRIGGER_API_KEY=ENTER_YOUR_DEVELOPMENT_API_KEY_HERE
|
||||
TRIGGER_API_URL=https://cloud.trigger.dev
|
||||
|
||||
```
|
||||
|
||||
Replace `ENTER_YOUR_DEVELOPMENT_API_KEY_HERE` with the actual API key obtained from the previous step.
|
||||
|
||||
## Configuring the Trigger Client
|
||||
|
||||
To set up the Trigger Client for your project, follow these steps:
|
||||
|
||||
1. **Create Configuration File:**
|
||||
|
||||
In your project directory, create a configuration file named `trigger.ts` or `trigger.js`, depending on whether your project uses TypeScript (`.ts`) or JavaScript (`.js`).
|
||||
|
||||
2. **Choose Directory:**
|
||||
|
||||
Depending on your project structure, choose the appropriate directory for the configuration file. If your project uses a `src` directory, create the file within it. Otherwise, create it directly in the project root.
|
||||
|
||||
3. **Add Configuration Code:**
|
||||
|
||||
Open the configuration file you created and add the following code:
|
||||
|
||||
```typescript
|
||||
// trigger.ts (for TypeScript) or trigger.js (for JavaScript)
|
||||
|
||||
import { TriggerClient } from "@trigger.dev/sdk";
|
||||
|
||||
export const client = new TriggerClient({
|
||||
id: "my-app",
|
||||
apiKey: process.env.TRIGGER_API_KEY,
|
||||
apiUrl: process.env.TRIGGER_API_URL,
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
Replace **"my-app"** with an appropriate identifier for your project. The **apiKey** and **apiUrl** are obtained from the environment variables you set earlier.
|
||||
|
||||
4. **File Location:**
|
||||
|
||||
Depending on your project structure, save the configuration file in the appropriate location:
|
||||
- If your project uses a **src** directory, save the file within the **src** directory.
|
||||
- If your project does not use a **src** directory, save the file in the project root.
|
||||
|
||||
**Example Directory Structure with src:**
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── src/
|
||||
├── trigger.ts
|
||||
├── other files...
|
||||
```
|
||||
|
||||
**Example Directory Structure without src:**
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── trigger.ts
|
||||
├── other files...
|
||||
```
|
||||
|
||||
By following these steps, you'll configure the Trigger Client to work with your project, regardless of whether you have a separate **src** directory and whether you're using TypeScript or JavaScript files.
|
||||
|
||||
## Creating the API Route
|
||||
|
||||
To establish an API route for interacting with Trigger.dev, follow these steps based on your project's file type and structure
|
||||
|
||||
<Tabs>
|
||||
<Tab title="App Directory">
|
||||
1. Create a new file named `route.(ts/js)` within the `app/api/trigger/` directory.
|
||||
2. Add the following code to `route.(ts/js)`:
|
||||
|
||||
```typescript
|
||||
import { createAppRoute } from "@trigger.dev/nextjs";
|
||||
import { client } from "@/trigger";
|
||||
import "@/Jobs";
|
||||
|
||||
export const { POST, dynamic } = createAppRoute(client);
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Pages Directory">
|
||||
1. Create a new file named `trigger.(ts/js)` within the `pages/api/` directory.
|
||||
2. Add the following code to `trigger.(ts/js)`:
|
||||
|
||||
```typescript
|
||||
import { createPagesRoute } from "@trigger.dev/nextjs";
|
||||
import { client } from "@/trigger";
|
||||
import "@/Jobs";
|
||||
|
||||
const { handler, config } = createPagesRoute(client);
|
||||
export { config };
|
||||
export default handler;
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Warning>In the code blocks, replace "@/trigger" with the appropriate path to your Trigger Client configuration file, and adjust the path to the Jobs folder accordingly. Make sure to provide the correct paths if your project isn't utilizing the Next.js alias feature.</Warning>
|
||||
|
||||
## Creating the Example Job
|
||||
1. Create a folder named `Jobs` alongside your `app` or `pages` directory
|
||||
2. Inside the `Jobs` folder, add two files named `example.(ts/js)` and `index.(ts/js)`.
|
||||
<CodeGroup>
|
||||
|
||||
```typescript example.(ts/js)
|
||||
import { eventTrigger } from "@trigger.dev/sdk";
|
||||
import { client } from "@/trigger";
|
||||
|
||||
// your first job
|
||||
client.defineJob({
|
||||
id: "example-job",
|
||||
name: "Example Job",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "example.event",
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.logger.info("Hello world!", { payload });
|
||||
|
||||
return {
|
||||
message: "Hello world!",
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```typescript index.ts/index.(ts/js)
|
||||
// import all your job files here
|
||||
|
||||
export * from "./examples"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Additonal Job Definitions
|
||||
You can define more job definitions by creating additional files in the `Jobs` folder and exporting them in `index` file.
|
||||
|
||||
For example, in `index.(ts/js)`, you can export other job files like this:
|
||||
|
||||
```typescript
|
||||
// import all your job files here
|
||||
|
||||
export * from "./examples"
|
||||
export * from "./other-job-file";
|
||||
```
|
||||
|
||||
## Adding Configuration to `package.json`
|
||||
|
||||
Inside the `package.json` file, add the following configuration under the root object:
|
||||
|
||||
```json
|
||||
"trigger.dev": {
|
||||
"endpointId": "my-app"
|
||||
}
|
||||
```
|
||||
|
||||
Your `package.json` file might look something like this:
|
||||
```json
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
// ... other dependencies
|
||||
},
|
||||
"trigger.dev": {
|
||||
"endpointId": "my-app"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace **"my-app"** with the appropriate identifier you used during the step for creating the Trigger Client.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Start your Next.js project locally locally, and then execute the `dev` CLI command to run Trigger.dev locally. You should run this command every time you want to use Trigger.dev locally.
|
||||
|
||||

|
||||
|
||||
<Warning>
|
||||
Make sure your Next.js site is running locally before continuing. You must
|
||||
also leave this `dev` terminal command running while you develop.
|
||||
</Warning>
|
||||
|
||||
In a **new terminal window or tab** run:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npx @trigger.dev/cli@latest dev
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm dlx @trigger.dev/cli@latest dev
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn dlx @trigger.dev/cli@latest dev
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
<br />
|
||||
<Note>
|
||||
You can optionally pass the port if you're not running on 3000 by adding
|
||||
`--port 3001` to the end
|
||||
</Note>
|
||||
|
||||
<Tip>If your existing Next.js project utilizes middleware and you encounter any issues, such as potential conflicts with Trigger.dev, it's recommended to refer to the troubleshooting guide at [Middleware](/documentation/guides/platforms/nextjs#middleware) for assistance. This guide can help you address any concerns related to middleware conflicts and ensure the smooth functioning of your project with Trigger.dev.</Tip>
|
||||
@@ -20,15 +20,15 @@ Add the `@trigger.dev/react` package to your project:
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm install @trigger.dev/react@next
|
||||
npm install @trigger.dev/react@latest
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm install @trigger.dev/react@next
|
||||
pnpm install @trigger.dev/react@latest
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn add @trigger.dev/react@next
|
||||
yarn add @trigger.dev/react@latest
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -42,9 +42,9 @@ In the Trigger.dev dashboard you should go to your Project and then the "Environ
|
||||
You should copy the `PUBLIC` API key for the dev environment.
|
||||
|
||||
<Accordion title="What's a public API key?">
|
||||
A public API key is a key that can be used in the browser. It can only be used
|
||||
to read certain data from the API and can not write data. This means that it
|
||||
can be used to get the status of a Job Run, but not to start a new Job Run.
|
||||
A public API key is a key that can be used in the browser. It can only be used to read certain
|
||||
data from the API and can not write data. This means that it can be used to get the status of a
|
||||
Job Run, but not to start a new Job Run.
|
||||
</Accordion>
|
||||
|
||||
### 3. Add the env var to your project
|
||||
@@ -67,17 +67,11 @@ The [TriggerProvider](/sdk/react/triggerprovider) component is a React Context P
|
||||
Generally you'll want to add this to the root of your app, so that it's available everywhere. However, you can add it lower in the hierarchy but it must be above any of the hooks.
|
||||
|
||||
```tsx app/layout.tsx
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<TriggerProvider
|
||||
publicApiKey={process.env.NEXT_PUBLIC_TRIGGER_API_KEY!}
|
||||
>
|
||||
<TriggerProvider publicApiKey={process.env.NEXT_PUBLIC_TRIGGER_API_KEY!}>
|
||||
{children}
|
||||
</TriggerProvider>
|
||||
</body>
|
||||
@@ -141,9 +135,9 @@ export default function EventDetails({ eventId }: { eventId: string }) {
|
||||
The `useRunDetails` hook will get the details of a specific Run. You can use this to show the status of a specific Run.
|
||||
|
||||
<Accordion title="How do I get a Run id?">
|
||||
You can call [client.getRuns()](/sdk/triggerclient/instancemethods/getruns)
|
||||
with a Job id to get a list of the most recent Runs for that Job. You can then
|
||||
pass that run id to your frontend to use in the hook.
|
||||
You can call [client.getRuns()](/sdk/triggerclient/instancemethods/getruns) with a Job id to get a
|
||||
list of the most recent Runs for that Job. You can then pass that run id to your frontend to use
|
||||
in the hook.
|
||||
</Accordion>
|
||||
|
||||
This component will show the details of a Run and the status of each task in the Run:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user