Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe2fd63de | |||
| 145fb24e4d | |||
| 2382d4a5c5 | |||
| adbcfe6f20 | |||
| 0754e99507 | |||
| dfb0a79abc | |||
| c808e40f61 | |||
| a9bb53b529 | |||
| 2636d9091e | |||
| 878da3c01f | |||
| 588461188f | |||
| d4145de9a7 | |||
| 188c4b0e24 | |||
| ecd050bece | |||
| b24aeea592 | |||
| 30ba73c4f1 | |||
| bf6a2a0319 | |||
| 4fc5de0dac | |||
| 11ff63a9d2 | |||
| 0d1bdac8ab | |||
| 9f076631ae | |||
| a0d663c0fb | |||
| 8fd68e30e9 | |||
| 5c13512091 | |||
| 1d7e5737a0 | |||
| 305e3b7ef2 | |||
| 03721cb18d | |||
| 773a6e2c81 | |||
| 2f13ac100f | |||
| a10782490f | |||
| 6ad91123f2 | |||
| 81f2d5e4ec | |||
| f249d8defa | |||
| 09aef5cda7 | |||
| 3028b6ad9d | |||
| 060b650845 | |||
| 6186a14398 | |||
| 916a353660 | |||
| 699878a5b1 | |||
| d2c9b64212 | |||
| 3102deccfb | |||
| 97c1b51332 | |||
| 4ab7082954 | |||
| 760f5de248 | |||
| de7e8c783e | |||
| 45b7af53e6 | |||
| aa7458fe37 | |||
| 1fd1d26780 | |||
| 6979eb6e66 | |||
| b0f926d28d | |||
| 82d8e7d3f3 | |||
| a214411218 | |||
| 2806cee7de | |||
| bd28556bf3 | |||
| 19a8d39fb0 | |||
| a907e2a988 | |||
| 403acac5ef | |||
| 708ebbde14 | |||
| 3ce5397072 | |||
| ee79ab30a9 | |||
| 2b21f5ba8a | |||
| 8c27236bc9 | |||
| b8d191052b | |||
| 979f5f88ae | |||
| fef1670533 | |||
| 3897e6e695 | |||
| fc083de102 | |||
| c7c5fd7fac | |||
| 4630960478 | |||
| 302bd02ff1 | |||
| b5db9f5e5c | |||
| 4b64c2d020 | |||
| 0c01e58db9 | |||
| 367ce2e412 | |||
| 92f80fd87c | |||
| 135cb4921f | |||
| 6cbc4f1190 | |||
| 1bf84548d7 | |||
| ce95744e98 | |||
| 0493cebaed | |||
| ac98219b76 | |||
| c8ddc19d5a | |||
| c6df574689 | |||
| 232b7cd26a | |||
| b9dc7ce89e | |||
| 8cf85443d5 | |||
| dd10717628 | |||
| 8bd1ca8b66 | |||
| 7f5ce165a0 | |||
| 7df1b90928 | |||
| 57a7e00bfa | |||
| 7362feed71 | |||
| b1b9321ad2 | |||
| a69f756e34 | |||
| 18a02829e3 | |||
| 0f6e580641 | |||
| 796f1209f2 | |||
| 4ce96b7d28 | |||
| b86ffa0d3d | |||
| 3ee7cd6ff6 | |||
| 74686c00cb | |||
| 8f3e550d03 | |||
| 65cff19ead | |||
| 591422b8cf | |||
| bbaa6ba156 | |||
| e20fa3c2ec | |||
| 57514f8771 | |||
| ef1a1ff343 | |||
| 2fbaa30b9f | |||
| 1199e934c2 | |||
| 83bcefe10c | |||
| 4ca9f182d7 | |||
| 5dec3ed4e6 | |||
| 955961eb5f | |||
| 33184a8130 | |||
| 5611768854 | |||
| a3278cb8a2 | |||
| d0e6442872 | |||
| 05b5ed6973 | |||
| 859425f093 | |||
| 1e374d30d0 | |||
| 9f649d2a27 | |||
| 0a20b6e05a | |||
| 78e2d0e9f9 | |||
| 24c8f08267 | |||
| 12de1b0d34 | |||
| b62df731fc | |||
| e347389b64 | |||
| 10f92a6124 | |||
| b957aa1752 | |||
| 3159619852 | |||
| 300c102b31 | |||
| 9e0b152c0e | |||
| a998d6c505 | |||
| 4e78da319c | |||
| 2c37091b08 | |||
| 5f739ee312 | |||
| fccba7db98 |
+20
-4
@@ -1,11 +1,27 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"changelog": [
|
||||
"@remix-run/changelog-github",
|
||||
{
|
||||
"repo": "triggerdotdev/trigger.dev"
|
||||
}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [["@trigger.dev/*"]],
|
||||
"fixed": [
|
||||
[
|
||||
"@trigger.dev/*"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["webapp", "emails", "@trigger.dev/database"]
|
||||
}
|
||||
"ignore": [
|
||||
"webapp",
|
||||
"emails",
|
||||
"@trigger.dev/database"
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "beta",
|
||||
"initialVersions": {
|
||||
"webapp": "1.0.0",
|
||||
"@trigger.dev/airtable": "2.0.14",
|
||||
"@trigger.dev/github": "2.0.14",
|
||||
"@trigger.dev/openai": "2.0.14",
|
||||
"@trigger.dev/plain": "2.0.14",
|
||||
"@trigger.dev/resend": "2.0.14",
|
||||
"@trigger.dev/sendgrid": "2.0.14",
|
||||
"@trigger.dev/slack": "2.0.14",
|
||||
"@trigger.dev/stripe": "2.0.14",
|
||||
"@trigger.dev/supabase": "2.0.14",
|
||||
"@trigger.dev/typeform": "2.0.14",
|
||||
"@trigger.dev/astro": "2.0.14",
|
||||
"@trigger.dev/cli": "2.0.14",
|
||||
"@trigger.dev/core": "2.0.14",
|
||||
"@trigger.dev/database": "0.0.0",
|
||||
"emails": "1.0.0",
|
||||
"@trigger.dev/eslint-plugin": "2.0.14",
|
||||
"@trigger.dev/express": "2.0.14",
|
||||
"@trigger.dev/integration-kit": "2.0.14",
|
||||
"@trigger.dev/nextjs": "2.0.14",
|
||||
"@trigger.dev/react": "2.0.14",
|
||||
"@trigger.dev/sdk": "2.0.14"
|
||||
},
|
||||
"changesets": [
|
||||
"young-trains-give"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
"@trigger.dev/airtable": minor
|
||||
"@trigger.dev/github": minor
|
||||
"@trigger.dev/openai": minor
|
||||
"@trigger.dev/plain": minor
|
||||
"@trigger.dev/resend": minor
|
||||
"@trigger.dev/sendgrid": minor
|
||||
"@trigger.dev/slack": minor
|
||||
"@trigger.dev/stripe": minor
|
||||
"@trigger.dev/supabase": minor
|
||||
"@trigger.dev/typeform": minor
|
||||
"@trigger.dev/astro": minor
|
||||
"@trigger.dev/cli": minor
|
||||
"@trigger.dev/core": minor
|
||||
"@trigger.dev/eslint-plugin": minor
|
||||
"@trigger.dev/express": minor
|
||||
"@trigger.dev/integration-kit": minor
|
||||
"@trigger.dev/nextjs": minor
|
||||
"@trigger.dev/react": minor
|
||||
"@trigger.dev/sdk": minor
|
||||
---
|
||||
|
||||
Integrations are now simpler and support authentication during webhook registration
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- improvements/*
|
||||
tags:
|
||||
- "v.docker.*"
|
||||
paths:
|
||||
@@ -95,6 +96,12 @@ jobs:
|
||||
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:
|
||||
@@ -154,6 +161,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -167,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}"
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- ".github/workflows/release.yml"
|
||||
- "packages/**"
|
||||
- "!packages/**/*.md"
|
||||
- "changesets/**"
|
||||
- ".changeset/**"
|
||||
- "integrations/**"
|
||||
- "!integrations/**/*.md"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
Vendored
+7
-16
@@ -5,29 +5,20 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "pnpm run dev --filter webapp",
|
||||
"name": "Run webapp",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"cwd": "${workspaceFolder}"
|
||||
"request": "launch",
|
||||
"name": "Debug WebApp",
|
||||
"command": "pnpm run dev --filter webapp",
|
||||
"envFile": "${workspaceFolder}/apps/webapp/.env",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Chrome webapp",
|
||||
"url": "http://localhost:3000",
|
||||
"url": "http://localhost:3030",
|
||||
"webRoot": "${workspaceFolder}/apps/webapp/app"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Current Test File",
|
||||
"autoAttachChildProcesses": true,
|
||||
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
|
||||
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
|
||||
"args": ["run", "${relativeFile}"],
|
||||
"smartStep": true,
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+13
-11
@@ -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
|
||||
```
|
||||
@@ -136,38 +136,40 @@ pnpm run dev
|
||||
3. Create a new temporary Next.js app in examples directory
|
||||
|
||||
```sh
|
||||
pnpm create next-app@latest
|
||||
cd ./examples
|
||||
pnpm create next-app@latest test-cli --ts --no-eslint --tailwind --app --src-dir --import-alias "@/*"
|
||||
```
|
||||
|
||||
Follow the prompts to create a TypeScript project using the App Directory.
|
||||
|
||||
4. Then once that's finished, add the `@trigger.dev/cli` to the `devDependencies` of the newly created Next.js app's `package.json` file, like so:
|
||||
|
||||
```json
|
||||
{
|
||||
// other package.json properties
|
||||
"devDependencies": { "@trigger.dev/cli": "workspace:*" }
|
||||
}
|
||||
```
|
||||
|
||||
5. Open a new terminal window, navigate into the example, and initialize the CLI:
|
||||
5. Back in the terminal, navigate into the example, and initialize the CLI. When prompted, select `self-hosted` and enter `localhost:3030` if you are testing against the local instance of Trigger.dev, or you can just use the Trigger.dev cloud. When asked for an API key, use the key you copied earlier.
|
||||
|
||||
```sh
|
||||
cd examples/your-newly-created-nextjs-project
|
||||
cd ./test-cli
|
||||
pnpm i
|
||||
pnpm exec trigger-cli init
|
||||
```
|
||||
|
||||
6. When prompted, select `self-hosted` and enter `localhost:3030` for your local version of the webapp. When asked for an API key, use the key you copied earlier.
|
||||
6. If you are just testing the `init` command, you can stop here. If you'd like to test the `dev` command, first start the Next.js app on port 3000:
|
||||
|
||||
7. Run the CLI
|
||||
```sh
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
7. Open a new terminal window, and then run the `dev` command like so:
|
||||
|
||||
```sh
|
||||
pnpm exec trigger-cli dev
|
||||
```
|
||||
|
||||
8. After running the CLI, start your newly created Next.js project. You should now be able to see the changes.
|
||||
|
||||
9. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR.
|
||||
8. 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
|
||||
|
||||
|
||||
@@ -2,180 +2,180 @@
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
@@ -186,16 +186,16 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright [2023] [Trigger.dev]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -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,15 @@
|
||||
import { ApiEventLog } from "@trigger.dev/core";
|
||||
import { EventRecord } from "@trigger.dev/database";
|
||||
|
||||
export function eventRecordToApiJson(eventRecord: EventRecord): ApiEventLog {
|
||||
return {
|
||||
id: eventRecord.eventId,
|
||||
name: eventRecord.name,
|
||||
payload: eventRecord.payload as any,
|
||||
context: eventRecord.context as any,
|
||||
timestamp: eventRecord.timestamp,
|
||||
deliverAt: eventRecord.deliverAt,
|
||||
deliveredAt: eventRecord.deliveredAt,
|
||||
cancelledAt: eventRecord.cancelledAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variant = {
|
||||
small:
|
||||
"py-[0.25rem] px-1.5 text-xxs font-normal inline-flex items-center justify-center whitespace-nowrap rounded-[0.125rem]",
|
||||
normal:
|
||||
"py-1 px-1.5 text-xs font-normal inline-flex items-center justify-center whitespace-nowrap rounded-sm",
|
||||
};
|
||||
|
||||
type ActiveBadgeProps = {
|
||||
active: boolean;
|
||||
className?: string;
|
||||
badgeSize?: keyof typeof variant;
|
||||
};
|
||||
|
||||
export function ActiveBadge({ active, className, badgeSize = "normal" }: ActiveBadgeProps) {
|
||||
switch (active) {
|
||||
case true:
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-slate-800 text-green-500", className)}>
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
case false:
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-slate-800 text-dimmed", className)}>
|
||||
Disabled
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function MissingIntegrationBadge({
|
||||
className,
|
||||
badgeSize = "normal",
|
||||
}: {
|
||||
className?: string;
|
||||
badgeSize?: keyof typeof variant;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-rose-600 text-white", className)}>
|
||||
Missing Integration
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewBadge({
|
||||
className,
|
||||
badgeSize = "normal",
|
||||
}: {
|
||||
className?: string;
|
||||
badgeSize?: keyof typeof variant;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-green-600 text-background", className)}>New!</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnvironmentLabel } from "./environments/EnvironmentLabel";
|
||||
import { DateTime } from "./primitives/DateTime";
|
||||
import { ActiveBadge } from "./ActiveBadge";
|
||||
|
||||
export type JobEnvironment = {
|
||||
type: RuntimeEnvironmentType;
|
||||
lastRun?: Date;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type JobStatusTableProps = {
|
||||
environments: JobEnvironment[];
|
||||
};
|
||||
|
||||
export function JobStatusTable({ environments }: JobStatusTableProps) {
|
||||
return (
|
||||
<Table fullWidth>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Last Run</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Version</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Status</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{environment.lastRun ? <DateTime date={environment.lastRun} /> : "Never Run"}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.version}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<ActiveBadge active={environment.enabled} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import type { Language, PrismTheme } from "prism-react-renderer";
|
||||
import Highlight, { defaultProps } from "prism-react-renderer";
|
||||
import { forwardRef, useCallback, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { ClipboardDocumentCheckIcon, ClipboardIcon } from "@heroicons/react/24/solid";
|
||||
import { Clipboard, ClipboardCheck, ClipboardCheckIcon } from "lucide-react";
|
||||
|
||||
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
|
||||
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
|
||||
@@ -192,6 +191,9 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
);
|
||||
|
||||
// if there are more than 1000 lines, don't highlight
|
||||
const shouldHighlight = lineCount <= 1000;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-md border border-slate-800", className)}
|
||||
@@ -229,99 +231,113 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"relative mr-2 font-mono text-xs leading-relaxed",
|
||||
inheritedClassName
|
||||
)}
|
||||
style={inheritedStyle}
|
||||
{shouldHighlight ? (
|
||||
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
{tokens
|
||||
.map((line, index) => {
|
||||
if (
|
||||
index === tokens.length - 1 &&
|
||||
line.length === 1 &&
|
||||
line[0].content === "\n"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
<pre
|
||||
className={cn(
|
||||
"relative mr-2 font-mono text-xs leading-relaxed",
|
||||
inheritedClassName
|
||||
)}
|
||||
style={inheritedStyle}
|
||||
dir="ltr"
|
||||
>
|
||||
{tokens
|
||||
.map((line, index) => {
|
||||
if (
|
||||
index === tokens.length - 1 &&
|
||||
line.length === 1 &&
|
||||
line[0].content === "\n"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = index + 1;
|
||||
const lineProps = getLineProps({ line, key: index });
|
||||
const lineNumber = index + 1;
|
||||
const lineProps = getLineProps({ line, key: index });
|
||||
|
||||
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
|
||||
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
|
||||
|
||||
let shouldDim = hasAnyHighlights;
|
||||
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
|
||||
shouldDim = false;
|
||||
}
|
||||
let shouldDim = hasAnyHighlights;
|
||||
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
|
||||
shouldDim = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineProps.key}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
lineProps.className
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldDim ? dimAmount : undefined,
|
||||
...lineProps.style,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={
|
||||
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
|
||||
}
|
||||
style={{
|
||||
width: `calc(8 * ${maxLineWidth / 16}rem)`,
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
return (
|
||||
<div
|
||||
key={lineProps.key}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
lineProps.className
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldDim ? dimAmount : undefined,
|
||||
...lineProps.style,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={
|
||||
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
|
||||
}
|
||||
style={{
|
||||
width: `calc(8 * ${maxLineWidth / 16}rem)`,
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
return (
|
||||
<span
|
||||
key={tokenProps.key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
return (
|
||||
<span
|
||||
key={tokenProps.key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="w-4 flex-none" />
|
||||
</div>
|
||||
<div className="w-4 flex-none" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Highlight>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function EnvironmentLabel({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
|
||||
"inline-flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
|
||||
environmentColorClassName(environment),
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -57,32 +57,53 @@ export function HowToSetupYourProject() {
|
||||
if (events !== null) {
|
||||
// This uses https://www.npmjs.com/package/canvas-confetti
|
||||
if ("confetti" in window && typeof window.confetti !== "undefined") {
|
||||
var duration = 2.5 * 1000;
|
||||
var end = Date.now() + duration;
|
||||
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();
|
||||
|
||||
(function frame() {
|
||||
// launch a few confetti from the left edge
|
||||
// @ts-ignore
|
||||
window.confetti({
|
||||
particleCount: 7,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0 },
|
||||
});
|
||||
// and launch a few from the right edge
|
||||
// @ts-ignore
|
||||
window.confetti({
|
||||
particleCount: 7,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1 },
|
||||
});
|
||||
|
||||
// keep going until we are out of time
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
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();
|
||||
@@ -393,6 +414,60 @@ export function HowToUseThisIntegration({ integration, help, integrationClient }
|
||||
);
|
||||
}
|
||||
|
||||
export function HowToDisableAJob({
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Paragraph spacing>
|
||||
To disable a job, you need to set the <InlineCode>enabled</InlineCode> property to{" "}
|
||||
<InlineCode>false</InlineCode>.
|
||||
</Paragraph>
|
||||
<StepNumber
|
||||
stepNumber="1"
|
||||
title={
|
||||
<>
|
||||
Set <InlineCode>enabled</InlineCode> to <InlineCode>false</InlineCode>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<CodeBlock
|
||||
showLineNumbers={false}
|
||||
className="mb-4"
|
||||
code={`client.defineJob({
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
version: "${version}",
|
||||
enabled: false,
|
||||
// ...rest of your Job definition
|
||||
});`}
|
||||
/>
|
||||
</StepContentContainer>
|
||||
<StepNumber
|
||||
stepNumber="2"
|
||||
title={
|
||||
<>
|
||||
Run the <InlineCode>@trigger.dev/cli dev</InlineCode> command
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
If you aren't already running the <InlineCode>dev</InlineCode> command, run it now.
|
||||
</Paragraph>
|
||||
<TriggerDevCommand />
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HowToUseApiKeysAndEndpoints() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { JobStatusTable } from "../JobsStatusTable";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Header1, Header2 } from "../primitives/Headers";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type JobEnvironment = {
|
||||
type: RuntimeEnvironmentType;
|
||||
lastRun?: Date;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type DeleteJobDialogContentProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
environments: JobEnvironment[];
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
export function DeleteJobDialogContent({
|
||||
title,
|
||||
slug,
|
||||
environments,
|
||||
id,
|
||||
redirectTo,
|
||||
}: DeleteJobDialogContentProps) {
|
||||
const canDelete = environments.every((environment) => !environment.enabled);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const isLoading =
|
||||
fetcher.state === "submitting" ||
|
||||
(fetcher.state === "loading" && fetcher.formMethod === "DELETE");
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-6">
|
||||
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||
<Header1>{title}</Header1>
|
||||
<Paragraph variant="small">ID: {slug}</Paragraph>
|
||||
</div>
|
||||
<JobStatusTable environments={environments} />
|
||||
|
||||
<Header2
|
||||
className={cn(
|
||||
canDelete ? "border-rose-500 bg-rose-500/10" : "border-amber-500 bg-amber-500/10",
|
||||
"rounded border px-3.5 py-2 text-center text-bright"
|
||||
)}
|
||||
>
|
||||
{canDelete
|
||||
? "Are you sure you want to delete this Job?"
|
||||
: "You can't delete this Job until all env are disabled"}
|
||||
</Header2>
|
||||
<Paragraph variant="small" className="px-6 text-center">
|
||||
{canDelete ? (
|
||||
<>
|
||||
This will permanently delete the Job <span className="strong text-bright">{title}</span>
|
||||
. This includes the deletion of all Run history. This cannot be undone.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This Job is still active in an environment. You need to disable it in your Job code
|
||||
first before it can be deleted.{" "}
|
||||
<TextLink to="https://trigger.dev/docs/documentation/guides/jobs/managing#disabling-jobs">
|
||||
Learn how to disable a Job
|
||||
</TextLink>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</Paragraph>
|
||||
{canDelete ? (
|
||||
<fetcher.Form
|
||||
method="delete"
|
||||
action={`/resources/jobs/${id}${redirectTo ? `?redirectTo=${redirectTo}` : ""}`}
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="danger/large" fullWidth>
|
||||
{isLoading ? (
|
||||
<Spinner color="white" />
|
||||
) : (
|
||||
<>
|
||||
<NamedIcon
|
||||
name="trash-can"
|
||||
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
|
||||
/>
|
||||
Delete this Job
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
) : (
|
||||
<Button variant="danger/large" fullWidth disabled>
|
||||
<>
|
||||
<NamedIcon
|
||||
name="trash-can"
|
||||
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
|
||||
/>
|
||||
Delete this Job
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ActiveBadge, MissingIntegrationBadge, NewBadge } from "../ActiveBadge";
|
||||
|
||||
type JobStatusBadgeProps = {
|
||||
enabled: boolean;
|
||||
hasIntegrationsRequiringAction: boolean;
|
||||
hasRuns: boolean;
|
||||
badgeSize?: "small" | "normal";
|
||||
};
|
||||
|
||||
export function JobStatusBadge({
|
||||
enabled,
|
||||
hasIntegrationsRequiringAction,
|
||||
hasRuns,
|
||||
badgeSize = "normal",
|
||||
}: JobStatusBadgeProps) {
|
||||
if (!enabled) {
|
||||
return <ActiveBadge active={false} badgeSize={badgeSize} />;
|
||||
}
|
||||
|
||||
if (hasIntegrationsRequiringAction) {
|
||||
return <MissingIntegrationBadge badgeSize={badgeSize} />;
|
||||
}
|
||||
|
||||
if (!hasRuns) {
|
||||
return <NewBadge badgeSize={badgeSize} />;
|
||||
}
|
||||
|
||||
return <ActiveBadge active={true} badgeSize={badgeSize} />;
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
import { jobPath } from "~/utils/pathBuilder";
|
||||
import { ProjectJob } from "~/hooks/useJobs";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { JobRunStatus } from "~/models/job.server";
|
||||
import { jobPath, jobTestPath } from "~/utils/pathBuilder";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { LabelValueStack } from "../primitives/LabelValueStack";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "../primitives/Popover";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellChevron,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "../primitives/Table";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { runStatusTitle } from "../runs/RunStatuses";
|
||||
import { ProjectJob } from "~/hooks/useJobs";
|
||||
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";
|
||||
import { DeleteJobDialogContent } from "./DeleteJobModalContent";
|
||||
import { JobStatusBadge } from "./JobStatusBadge";
|
||||
|
||||
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
|
||||
const organization = useOrganization();
|
||||
@@ -35,6 +38,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableHeaderCell>Integrations</TableHeaderCell>
|
||||
<TableHeaderCell>Properties</TableHeaderCell>
|
||||
<TableHeaderCell>Last run</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -43,13 +47,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
jobs.map((job) => {
|
||||
const path = jobPath(organization, project, job);
|
||||
return (
|
||||
<TableRow
|
||||
key={job.id}
|
||||
className={cn(
|
||||
(job.hasIntegrationsRequiringAction && "bg-rose-500/20") ||
|
||||
(job.lastRun === undefined && "bg-green-500/20")
|
||||
)}
|
||||
>
|
||||
<TableRow key={job.id} className="group">
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-2">
|
||||
<NamedIcon name={job.event.icon} className="h-8 w-8" />
|
||||
@@ -145,13 +143,39 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<LabelValueStack label={"Never run"} value={"–"} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellChevron to={path}>
|
||||
{job.lastRun === undefined && (
|
||||
<Badge className="mr-4" variant="green">
|
||||
New Job!
|
||||
</Badge>
|
||||
)}
|
||||
</TableCellChevron>
|
||||
<TableCell to={path}>
|
||||
<JobStatusBadge
|
||||
enabled={job.status === "ACTIVE"}
|
||||
hasIntegrationsRequiringAction={job.hasIntegrationsRequiringAction}
|
||||
hasRuns={job.lastRun !== undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCellMenu isSticky>
|
||||
<PopoverMenuItem to={path} title="View Job" icon="eye" />
|
||||
<PopoverMenuItem
|
||||
to={jobTestPath(organization, project, job)}
|
||||
title="Test Job"
|
||||
icon="beaker"
|
||||
/>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="menu-item" LeadingIcon="trash-can">
|
||||
Delete Job
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCellMenu>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { docsRoot } from "~/utils/pathBuilder";
|
||||
|
||||
export function NavBar() {
|
||||
return (
|
||||
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-divide py-1 pl-1 pr-2.5">
|
||||
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-uiBorder py-1 pl-1 pr-2.5">
|
||||
<div className="flex gap-0.5">
|
||||
<Link to="/" className="p-2">
|
||||
<LogoIcon className="h-5 w-5" />
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ProjectSideMenu() {
|
||||
variants={menuVariants}
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
className={cn(
|
||||
"flex h-full flex-col justify-between overflow-hidden border-r border-slate-850 p-1 transition duration-300 ease-in-out"
|
||||
"flex h-full flex-col justify-between overflow-hidden border-r border-uiBorder p-1 transition duration-300 ease-in-out"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -6,8 +6,6 @@ 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> & {
|
||||
|
||||
@@ -87,6 +87,14 @@ const variant = {
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"danger/large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50",
|
||||
button:
|
||||
"h-10 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600",
|
||||
icon: "h-5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"menu-item": {
|
||||
textColor: "text-bright",
|
||||
button:
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
@@ -25,7 +26,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100",
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -42,15 +43,29 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-lg animate-in sm:max-w-lg sm:rounded-lg sm:zoom-in-90",
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 p-6 pt-11 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<hr className="-ml-6 w-[calc(100%_+_3rem)]" />
|
||||
{children}
|
||||
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
|
||||
<div className="flex gap-x-2">
|
||||
<ShortcutKey
|
||||
shortcut={{
|
||||
windows: {
|
||||
key: "esc",
|
||||
},
|
||||
mac: {
|
||||
key: "esc",
|
||||
},
|
||||
}}
|
||||
variant={"small"}
|
||||
/>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</div>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function FormTitle({
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 flex flex-col gap-3 pb-4",
|
||||
divide ? "border-b border-slate-800" : "",
|
||||
divide ? "border-b border-uiBorder" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
EnvelopeIcon,
|
||||
EyeIcon,
|
||||
FingerPrintIcon,
|
||||
FlagIcon,
|
||||
FolderIcon,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
QrCodeIcon,
|
||||
SquaresPlusIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
UserCircleIcon,
|
||||
UserGroupIcon,
|
||||
UserIcon,
|
||||
@@ -50,7 +52,7 @@ import {
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { CompanyIcon, hasIcon } from "@trigger.dev/companyicons";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { ActivityIcon, HourglassIcon } from "lucide-react";
|
||||
import { DynamicTriggerIcon } from "~/assets/icons/DynamicTriggerIcon";
|
||||
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
|
||||
import { ScheduleIcon } from "~/assets/icons/ScheduleIcon";
|
||||
@@ -100,6 +102,7 @@ const icons = {
|
||||
folder: (className: string) => <FolderIcon className={cn("text-indigo-600", className)} />,
|
||||
envelope: (className: string) => <EnvelopeIcon className={cn("text-cyan-500", className)} />,
|
||||
environment: (className: string) => <KeyIcon className={cn("text-yellow-500", className)} />,
|
||||
eye: (className: string) => <EyeIcon className={cn("text-blue-500", className)} />,
|
||||
globe: (className: string) => <GlobeAltIcon className={cn("text-fuchsia-600", className)} />,
|
||||
"hand-raised": (className: string) => (
|
||||
<HandRaisedIcon className={cn("text-amber-400", className)} />
|
||||
@@ -129,6 +132,7 @@ const icons = {
|
||||
<PlusSmallIcon className={cn("text-green-600", className)} />
|
||||
),
|
||||
property: (className: string) => <Cog8ToothIcon className={cn("text-slate-600", className)} />,
|
||||
pulse: (className: string) => <ActivityIcon className={cn("text-green-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)} />,
|
||||
@@ -139,6 +143,7 @@ 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)} />,
|
||||
"trash-can": (className: string) => <TrashIcon className={cn("text-rose-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)} />,
|
||||
|
||||
@@ -13,7 +13,7 @@ type WithChildren = {
|
||||
|
||||
export function PageHeader({ children, hideBorder }: WithChildren & { hideBorder?: boolean }) {
|
||||
return (
|
||||
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-slate-800 pb-4")}>
|
||||
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-uiBorder pb-4")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,8 @@ import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/solid";
|
||||
import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import { LinkButton } from "./Buttons";
|
||||
import { IconNames } from "./NamedIcon";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
@@ -91,6 +90,24 @@ function PopoverArrowTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverVerticalEllipseTrigger({
|
||||
isOpen,
|
||||
className,
|
||||
...props
|
||||
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex items-center justify-end gap-1 rounded px-1.5 py-1.5 text-dimmed transition hover:bg-slate-750 hover:text-bright",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<EllipsisVerticalIcon className={cn("h-5 w-5 transition group-hover:text-bright")} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
@@ -98,4 +115,5 @@ export {
|
||||
PopoverSectionHeader,
|
||||
PopoverArrowTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
};
|
||||
|
||||
@@ -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-white/10 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-uiBorder",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
@@ -154,7 +154,7 @@ const SheetContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 p-2">
|
||||
<div className="flex items-center gap-2 border-b border-uiBorder p-2">
|
||||
<SheetPrimitive.Close className="rounded-sm p-1 transition hover:bg-slate-800 disabled:pointer-events-none">
|
||||
<NamedIcon name="close" className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivE
|
||||
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 flex shrink-0 items-center gap-4 border-b border-slate-800 py-3.5",
|
||||
"mx-4 flex shrink-0 items-center gap-4 border-b border-uiBorder py-3.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -194,7 +194,7 @@ export const SheetFooter = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("shrink-0", className)} {...props}>
|
||||
<div className="mx-4 border-t border-slate-800 py-3">{children}</div>
|
||||
<div className="mx-4 border-t border-uiBorder py-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ReactNode, forwardRef } from "react";
|
||||
import { ReactNode, forwardRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./Badge";
|
||||
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
|
||||
|
||||
type TableProps = {
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className, containerClassName, children }, ref) => {
|
||||
({ className, containerClassName, children, fullWidth }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-slate-900 scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
|
||||
containerClassName
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-uiBorder scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
>
|
||||
<table ref={ref} className={cn("w-full divide-y bg-midnight-850", className)}>
|
||||
<table ref={ref} className={cn("w-full divide-y", className)}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -37,7 +39,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
return (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("rounded-t-md", "relative divide-y divide-slate-850", className)}
|
||||
className={cn("rounded-t-md", "relative divide-y divide-uiBorder bg-slate-850", className)}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
@@ -53,7 +55,7 @@ type TableBodyProps = {
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
({ className, children }, ref) => {
|
||||
return (
|
||||
<tbody ref={ref} className={cn("relative divide-y divide-slate-850", className)}>
|
||||
<tbody ref={ref} className={cn("relative divide-y divide-uiBorder", className)}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
@@ -63,12 +65,13 @@ export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
type TableRowProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className, children }, ref) => {
|
||||
({ className, disabled, children }, ref) => {
|
||||
return (
|
||||
<tr ref={ref} className={cn("group w-full", className)}>
|
||||
<tr ref={ref} className={cn(disabled && "opacity-50", "group w-full", className)}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
@@ -103,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle text-xs font-semibold uppercase text-slate-400",
|
||||
"px-4 py-3 align-middle text-xs font-normal uppercase tracking-wider text-dimmed",
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
@@ -118,10 +121,11 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
type TableCellProps = TableCellBasicProps & {
|
||||
to?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
hasAction?: boolean;
|
||||
};
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
({ className, alignment = "left", children, colSpan, to, onClick }, ref) => {
|
||||
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
|
||||
let alignmentClassName = "text-left";
|
||||
switch (alignment) {
|
||||
case "center":
|
||||
@@ -133,7 +137,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
|
||||
const flexClasses = cn(
|
||||
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-slate-400",
|
||||
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-dimmed",
|
||||
alignment === "left"
|
||||
? "justify-start text-left"
|
||||
: alignment === "center"
|
||||
@@ -145,8 +149,10 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs text-slate-400 transition group-hover:bg-slate-850/50",
|
||||
to || onClick ? "cursor-pointer" : "px-4 py-3 align-middle",
|
||||
"text-xs text-slate-400",
|
||||
to || onClick || hasAction
|
||||
? "cursor-pointer group-hover:bg-slate-900"
|
||||
: "px-4 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
className
|
||||
)}
|
||||
@@ -168,23 +174,64 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
);
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
|
||||
|
||||
export const TableCellChevron = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
className?: string;
|
||||
to?: string;
|
||||
children?: ReactNode;
|
||||
isSticky?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
>(({ className, to, children, onClick }, ref) => {
|
||||
>(({ className, to, children, isSticky, onClick }, ref) => {
|
||||
return (
|
||||
<TableCell className={className} to={to} onClick={onClick} ref={ref} alignment="right">
|
||||
<TableCell
|
||||
className={cn(isSticky && stickyStyles, className)}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="h-4 w-4 text-dimmed transition group-hover:text-bright" />
|
||||
</TableCell>
|
||||
);
|
||||
});
|
||||
|
||||
export const TableCellMenu = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
isSticky?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
>(({ className, children, isSticky, onClick }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<TableCell
|
||||
className={cn(isSticky && stickyStyles, className)}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
hasAction={true}
|
||||
>
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverVerticalEllipseTrigger isOpen={isOpen} />
|
||||
<PopoverContent
|
||||
className="w-fit max-w-[10rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="end"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TableCell>
|
||||
);
|
||||
});
|
||||
|
||||
type TableBlankRowProps = {
|
||||
className?: string;
|
||||
colSpan: number;
|
||||
|
||||
@@ -12,7 +12,7 @@ export type TabsProps = {
|
||||
|
||||
export function Tabs({ tabs, className }: TabsProps) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-slate-800`, className)}>
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-uiBorder`, className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<NavLink key={index} to={tab.to} className="group flex flex-col items-center pt-1" end>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -54,7 +54,7 @@ export function RunCompletedDetail({ run }: { run: MatchedRun }) {
|
||||
<RunPanelDivider />
|
||||
{run.error && <RunPanelError text={run.error.message} stackTrace={run.error.stack} />}
|
||||
{run.output ? (
|
||||
<CodeBlock language="json" code={run.output} />
|
||||
<CodeBlock language="json" code={run.output} maxLines={36} />
|
||||
) : (
|
||||
run.output === null && <Paragraph variant="small">This run returned nothing</Paragraph>
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DetailedTask } from "~/presenters/TaskDetailsPresenter.server";
|
||||
import {
|
||||
RunPanel,
|
||||
RunPanelBody,
|
||||
@@ -29,22 +28,16 @@ import {
|
||||
} from "../primitives/Table";
|
||||
import { TaskAttemptStatusLabel } from "./TaskAttemptStatus";
|
||||
import { TaskStatusIcon } from "./TaskStatus";
|
||||
import { ClientOnly } from "remix-utils";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
|
||||
|
||||
export function TaskDetail({ task }: { task: DetailedTask }) {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
startedAt,
|
||||
completedAt,
|
||||
status,
|
||||
delayUntil,
|
||||
params,
|
||||
properties,
|
||||
output,
|
||||
style,
|
||||
attempts,
|
||||
} = task;
|
||||
const { name, description, icon, status, params, properties, output, style, attempts } = task;
|
||||
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt) : undefined;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt) : undefined;
|
||||
const delayUntil = task.delayUntil ? new Date(task.delayUntil) : undefined;
|
||||
|
||||
return (
|
||||
<RunPanel selected={false}>
|
||||
@@ -150,7 +143,9 @@ export function TaskDetail({ task }: { task: DetailedTask }) {
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Header3>Output</Header3>
|
||||
{output ? (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} />
|
||||
<ClientOnly fallback={<Spinner />}>
|
||||
{() => <CodeBlock code={output} maxLines={35} />}
|
||||
</ClientOnly>
|
||||
) : (
|
||||
<Paragraph variant="small">No output</Paragraph>
|
||||
)}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function RunsTable({
|
||||
<TableCell to={path}>
|
||||
{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
<TableCellChevron to={path} isSticky />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -20,7 +20,6 @@ function BadgesExample() {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,13 +253,11 @@ function ButtonList({ primary }: { primary: string }) {
|
||||
<Header1 className="mb-2 mt-8">Large buttons</Header1>
|
||||
<div className="grid grid-cols-1 gap-8 border-b border-slate-700 pb-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button variant="primary/large" fullWidth>
|
||||
<NamedIcon name={"github"} className={"mr-1.5 h-4 w-4"} />
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<Button variant="secondary/large" fullWidth>
|
||||
<NamedIcon
|
||||
name={"envelope"}
|
||||
@@ -267,6 +265,13 @@ function ButtonList({ primary }: { primary: string }) {
|
||||
/>
|
||||
Continue with Email
|
||||
</Button>
|
||||
<Button variant="danger/large" fullWidth>
|
||||
<NamedIcon
|
||||
name={"trash-can"}
|
||||
className={"mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"}
|
||||
/>
|
||||
This is a delete button
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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 = 10000;
|
||||
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,
|
||||
@@ -29,6 +30,8 @@ export type PrismaTransactionOptions = {
|
||||
|
||||
/** Sets the transaction isolation level. By default this is set to the value currently configured in your database. */
|
||||
isolationLevel?: Prisma.TransactionIsolationLevel;
|
||||
|
||||
rethrowPrismaErrors?: boolean;
|
||||
};
|
||||
|
||||
export async function $transaction<R>(
|
||||
@@ -52,6 +55,10 @@ export async function $transaction<R>(
|
||||
name: error.name,
|
||||
});
|
||||
|
||||
if (options?.rethrowPrismaErrors) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,8 +91,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,8 +108,7 @@ function getClient() {
|
||||
const client = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: DATABASE_URL,
|
||||
// We can't set directUrl here, and we don't have to
|
||||
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,8 @@ 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(),
|
||||
@@ -31,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>;
|
||||
|
||||
@@ -8,6 +8,10 @@ export type ProjectJob = UseDataFunctionReturn<typeof loader>["projectJobs"][num
|
||||
|
||||
export const jobsMatchId =
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
|
||||
|
||||
// This is only used in the JobsMenu component, which is the breadcrumb job list dropdown.
|
||||
// This dropdown is only shown once you have selected a job, so we can assume that
|
||||
// the route above has loaded and we can use the data from it.
|
||||
export function useOptionalJobs(matches?: RouteMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: jobsMatchId,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
skipRetrying?: boolean;
|
||||
executionCount?: number;
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt: options.runAt,
|
||||
jobKey: `job_run:${run.id}:${options.executionCount ?? 0}${
|
||||
options.resumeTaskId ? `:task:${options.resumeTaskId}` : ""
|
||||
}`,
|
||||
maxAttempts: options.skipRetrying ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) {
|
||||
return await executionWorker.dequeue(`job_run:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
}
|
||||
@@ -102,6 +102,25 @@ export async function jsonWithSuccessMessage(
|
||||
});
|
||||
}
|
||||
|
||||
export async function jsonWithErrorMessage(
|
||||
data: any,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setErrorMessage(session, message, options);
|
||||
|
||||
return json(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function redirectWithSuccessMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
|
||||
@@ -49,6 +49,7 @@ export function getOrganizations({ userId }: { userId: User["id"] }) {
|
||||
jobs: {
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ExternalAccount, Integration, TriggerSource } from "@trigger.dev/database";
|
||||
import { ConnectionAuth } from "@trigger.dev/core";
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type ResolvableTriggerSource = TriggerSource & {
|
||||
integration: Integration;
|
||||
externalAccount: ExternalAccount | null;
|
||||
};
|
||||
|
||||
export async function resolveSourceConnection(
|
||||
tx: PrismaClientOrTransaction,
|
||||
source: ResolvableTriggerSource
|
||||
): Promise<ConnectionAuth | undefined> {
|
||||
if (source.integration.authSource !== "HOSTED") return;
|
||||
|
||||
const connection = await getConnection(tx, source);
|
||||
|
||||
if (!connection) {
|
||||
logger.error(
|
||||
`Integration connection not found for source ${source.id}, integration ${source.integration.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await integrationAuthRepository.getCredentials(connection);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth2",
|
||||
scopes: response.scopes,
|
||||
accessToken: response.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnection(tx: PrismaClientOrTransaction, source: ResolvableTriggerSource) {
|
||||
if (source.externalAccount) {
|
||||
return tx.integrationConnection.findFirst({
|
||||
where: {
|
||||
integrationId: source.integration.id,
|
||||
externalAccountId: source.externalAccount.id,
|
||||
},
|
||||
include: {
|
||||
dataReference: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.integrationConnection.findFirst({
|
||||
where: {
|
||||
integrationId: source.integration.id,
|
||||
},
|
||||
include: {
|
||||
dataReference: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,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";
|
||||
@@ -76,7 +77,12 @@ 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;
|
||||
@@ -85,6 +91,7 @@ export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
|
||||
};
|
||||
|
||||
export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#name: string;
|
||||
#schema: TMessageCatalog;
|
||||
#prisma: PrismaClient;
|
||||
#runnerOptions: RunnerOptions;
|
||||
@@ -93,6 +100,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#runner?: GraphileRunner;
|
||||
|
||||
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
|
||||
this.#name = options.name;
|
||||
this.#schema = options.schema;
|
||||
this.#prisma = options.prisma;
|
||||
this.#runnerOptions = options.runnerOptions;
|
||||
@@ -100,12 +108,16 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#recurringTasks = options.recurringTasks;
|
||||
}
|
||||
|
||||
get graphileWorkerSchema() {
|
||||
return this.#runnerOptions.schema ?? "graphile_worker";
|
||||
}
|
||||
|
||||
public async initialize(): Promise<boolean> {
|
||||
if (this.#runner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug("Initializing worker queue with options", {
|
||||
this.#logDebug("Initializing worker queue with options", {
|
||||
runnerOptions: this.#runnerOptions,
|
||||
});
|
||||
|
||||
@@ -121,6 +133,54 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -133,23 +193,35 @@ 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 optionsWithoutTx = removeUndefinedKeys(omit(options ?? {}, ["tx"]));
|
||||
const taskWithoutJobKey = omit(task, ["jobKey"]);
|
||||
|
||||
// Make sure options passed in to enqueue take precedence over task options
|
||||
const spec = {
|
||||
...taskWithoutJobKey,
|
||||
...optionsWithoutTx,
|
||||
...task,
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -167,6 +239,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,
|
||||
@@ -174,7 +257,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
tx: PrismaClientOrTransaction
|
||||
) {
|
||||
const results = await tx.$queryRawUnsafe(
|
||||
`SELECT * FROM graphile_worker.add_job(
|
||||
`SELECT * FROM ${this.graphileWorkerSchema}.add_job(
|
||||
identifier => $1::text,
|
||||
payload => $2::json,
|
||||
queue_name => $3::text,
|
||||
@@ -192,8 +275,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);
|
||||
@@ -209,6 +292,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 ${this.graphileWorkerSchema}.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 = {};
|
||||
|
||||
@@ -324,4 +433,17 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#logDebug(message: string, args?: any) {
|
||||
logger.debug(`[worker][${this.#name}] ${message}`, args);
|
||||
}
|
||||
}
|
||||
|
||||
function removeUndefinedKeys<T extends object>(obj: T): T {
|
||||
for (let key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key) && obj[key] === undefined) {
|
||||
delete obj[key];
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export class IntegrationClientPresenter {
|
||||
slug: projectSlug,
|
||||
},
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,6 +71,7 @@ export class IntegrationsPresenter {
|
||||
slug: projectSlug,
|
||||
},
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export class JobListPresenter {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
@@ -92,6 +93,7 @@ export class JobListPresenter {
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
organization: orgWhere,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
@@ -162,11 +164,19 @@ export class JobListPresenter {
|
||||
properties = [...properties, ...versionProperties];
|
||||
}
|
||||
|
||||
const environments = job.aliases.map((alias) => ({
|
||||
type: alias.environment.type,
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: alias.version.version,
|
||||
status: alias.version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
@@ -179,6 +189,7 @@ export class JobListPresenter {
|
||||
),
|
||||
lastRun,
|
||||
properties,
|
||||
environments,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -80,6 +80,7 @@ export class ProjectPresenter {
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ title: "asc" }],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DisplayPropertiesSchema, StyleSchema } from "@trigger.dev/core";
|
||||
import { StyleSchema } from "@trigger.dev/core";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { mergeProperties } from "~/utils/mergeProperties.server";
|
||||
|
||||
@@ -7,8 +7,6 @@ type DetailsProps = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type DetailedTask = NonNullable<Awaited<ReturnType<TaskDetailsPresenter["call"]>>>;
|
||||
|
||||
export class TaskDetailsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -87,6 +85,7 @@ export class TaskDetailsPresenter {
|
||||
|
||||
return {
|
||||
...task,
|
||||
output: task.output ? JSON.stringify(task.output, null, 2) : undefined,
|
||||
connection: task.runConnection,
|
||||
params: task.params as Record<string, any>,
|
||||
properties: mergeProperties(task.properties, task.outputProperties),
|
||||
|
||||
@@ -62,6 +62,22 @@ export class TriggerSourcePresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
dynamicTrigger: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
sourceRegistrationJob: {
|
||||
select: {
|
||||
job: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: triggerSourceId,
|
||||
@@ -73,10 +89,15 @@ export class TriggerSourcePresenter {
|
||||
}
|
||||
|
||||
const runListPresenter = new RunListPresenter(this.#prismaClient);
|
||||
const runList = trigger.sourceRegistrationJob
|
||||
const jobSlug = getJobSlug(
|
||||
trigger.sourceRegistrationJob?.job.slug,
|
||||
trigger.dynamicTrigger?.sourceRegistrationJob?.job.slug
|
||||
);
|
||||
|
||||
const runList = jobSlug
|
||||
? await runListPresenter.call({
|
||||
userId,
|
||||
jobSlug: trigger.sourceRegistrationJob.job.slug,
|
||||
jobSlug,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
direction,
|
||||
@@ -95,7 +116,21 @@ export class TriggerSourcePresenter {
|
||||
params: trigger.params,
|
||||
registrationJob: trigger.sourceRegistrationJob?.job,
|
||||
runList,
|
||||
dynamic: trigger.dynamicTrigger
|
||||
? { id: trigger.dynamicTrigger.id, slug: trigger.dynamicTrigger.slug }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getJobSlug(
|
||||
sourceRegistrationJobSlug: string | undefined,
|
||||
dynamicSourceRegistrationJobSlug: string | undefined
|
||||
) {
|
||||
if (sourceRegistrationJobSlug) {
|
||||
return sourceRegistrationJobSlug;
|
||||
}
|
||||
|
||||
return dynamicSourceRegistrationJobSlug;
|
||||
}
|
||||
|
||||
+6
-7
@@ -73,8 +73,6 @@ export default function Page() {
|
||||
|
||||
const { filterText, setFilterText, filteredItems } = useFilterJobs(jobs);
|
||||
|
||||
const { width, height } = useWindowSize();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
@@ -104,7 +102,6 @@ export default function Page() {
|
||||
</Callout>
|
||||
)}
|
||||
<div className="mb-2 flex flex-col">
|
||||
<Header2 spacing>Jobs</Header2>
|
||||
<div className="flex w-full">
|
||||
<Input
|
||||
placeholder="Search Jobs"
|
||||
@@ -122,9 +119,11 @@ export default function Page() {
|
||||
noResultsText={`No Jobs match ${filterText}. Try a different search
|
||||
query.`}
|
||||
/>
|
||||
{jobs.length === 1 && jobs.every((r) => r.lastRun === undefined) && (
|
||||
<RunYourJobPrompt />
|
||||
)}
|
||||
{jobs.length === 1 &&
|
||||
jobs.every((r) => r.lastRun === undefined) &&
|
||||
jobs.every((i) => i.hasIntegrationsRequiringAction === false) && (
|
||||
<RunYourJobPrompt />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<HowToSetupYourProject />
|
||||
@@ -194,7 +193,7 @@ function ExampleJobs() {
|
||||
<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"
|
||||
className="flex w-full items-center rounded border-b border-uiBorder py-2 transition hover:border-transparent hover:bg-slate-800"
|
||||
>
|
||||
{example.icon}
|
||||
<Paragraph variant="small">
|
||||
|
||||
+3
-3
@@ -355,7 +355,7 @@ function ConnectedIntegrationsList({
|
||||
<TableCell to={path}>
|
||||
<DateTime date={client.createdAt} includeSeconds={false} />
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
<TableCellChevron to={path} isSticky />
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -454,7 +454,7 @@ function IntegrationsWithMissingFields({
|
||||
integration={integration}
|
||||
organizationId={organizationId}
|
||||
button={
|
||||
<ChevronRightIcon className="h-4 w-4 text-slate-700 transition group-hover:text-bright" />
|
||||
<ChevronRightIcon className="h-4 w-4 text-dimmed transition group-hover:text-bright" />
|
||||
}
|
||||
callbackUrl={callbackUrl}
|
||||
existingIntegration={client}
|
||||
@@ -482,7 +482,7 @@ function AddIntegrationConnection({
|
||||
icon?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex h-11 w-full items-center gap-2 rounded-md p-1 pr-3 transition hover:bg-slate-850">
|
||||
<div className="group flex h-11 w-full items-center gap-2 rounded-md p-1 pr-3 transition hover:bg-slate-900">
|
||||
<NamedIconInBox
|
||||
name={icon ?? identifier}
|
||||
className="h-9 w-9 flex-none transition group-hover:border-slate-750"
|
||||
|
||||
+18
-13
@@ -1,5 +1,7 @@
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { Await, useLoaderData } from "@remix-run/react";
|
||||
import { LoaderArgs, SerializeFrom, defer } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TaskDetail } from "~/components/run/TaskDetail";
|
||||
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
@@ -10,23 +12,26 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const { taskParam } = TaskParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TaskDetailsPresenter();
|
||||
const task = await presenter.call({
|
||||
const taskPromise = presenter.call({
|
||||
userId,
|
||||
id: taskParam,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
task,
|
||||
return defer({
|
||||
taskPromise,
|
||||
});
|
||||
};
|
||||
|
||||
export type DetailedTask = NonNullable<Awaited<SerializeFrom<typeof loader>["taskPromise"]>>;
|
||||
|
||||
export default function Page() {
|
||||
const { task } = useTypedLoaderData<typeof loader>();
|
||||
return <TaskDetail task={task} />;
|
||||
const { taskPromise } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
|
||||
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
+63
-6
@@ -1,11 +1,68 @@
|
||||
import { ComingSoon } from "~/components/ComingSoon";
|
||||
import { JobEnvironment, JobStatusTable } from "~/components/JobsStatusTable";
|
||||
import { HowToDisableAJob } from "~/components/helpContent/HelpContentText";
|
||||
import { DeleteJobDialogContent } from "~/components/jobs/DeleteJobModalContent";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { projectJobsPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export default function Page() {
|
||||
const job = useJob();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Job settings"
|
||||
description="Disable a Job, archive it and more."
|
||||
icon="settings"
|
||||
/>
|
||||
<Help defaultOpen>
|
||||
{(open) => (
|
||||
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2 className="mb-2 flex items-center gap-1">Environments</Header2>
|
||||
<HelpTrigger title="How do disable a Job?" />
|
||||
</div>
|
||||
<JobStatusTable environments={job.environments} />
|
||||
<div className="mt-4 flex w-full items-center justify-end gap-x-3">
|
||||
{job.status === "ACTIVE" && (
|
||||
<Paragraph variant="small">
|
||||
Disable this Job in all environments before deleting
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="danger/small"
|
||||
leadingIconClassName="text-bright"
|
||||
LeadingIcon="trash-can"
|
||||
>
|
||||
I want to delete this Job
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
id={job.id}
|
||||
redirectTo={projectJobsPath(organization, project)}
|
||||
/>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<HelpContent title="How to disable a Job">
|
||||
<HowToDisableAJob id={job.slug} version={job.version} name={job.title} />
|
||||
</HelpContent>
|
||||
</div>
|
||||
)}
|
||||
</Help>
|
||||
);
|
||||
}
|
||||
|
||||
+13
@@ -2,6 +2,7 @@ import { Outlet, useLocation } from "@remix-run/react";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment } from "react";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { JobStatusBadge } from "~/components/jobs/JobStatusBadge";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { JobsMenu } from "~/components/navigation/JobsMenu";
|
||||
import { BreadcrumbLink } from "~/components/navigation/NavBar";
|
||||
@@ -134,6 +135,18 @@ export default function Job() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PageInfoProperty
|
||||
icon="pulse"
|
||||
label={"STATUS"}
|
||||
value={
|
||||
<JobStatusBadge
|
||||
enabled={job.status === "ACTIVE"}
|
||||
hasIntegrationsRequiringAction={job.hasIntegrationsRequiringAction}
|
||||
hasRuns={job.lastRun !== undefined}
|
||||
badgeSize="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
<PageInfoGroup alignment="right">
|
||||
<Paragraph variant="extra-small" className="text-slate-600">
|
||||
|
||||
+14
-2
@@ -43,6 +43,7 @@ import { parse } from "@conform-to/zod";
|
||||
import { z } from "zod";
|
||||
import { ActivateSourceService } from "~/services/sources/activateSource.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const user = await requireUser(request);
|
||||
@@ -90,7 +91,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
try {
|
||||
const service = new ActivateSourceService();
|
||||
|
||||
const result = await service.call(triggerParam, submission.value.jobId);
|
||||
const result = await service.call(triggerParam);
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
externalTriggerPath({ slug: organizationSlug }, { slug: projectParam }, { id: triggerParam }),
|
||||
@@ -167,6 +168,17 @@ export default function Page() {
|
||||
<NamedIcon name={trigger.active ? "active" : "inactive"} className="h-4 w-4" />
|
||||
}
|
||||
/>
|
||||
{trigger.dynamic && (
|
||||
<PageInfoProperty
|
||||
label="Dynamic"
|
||||
value={
|
||||
<span className="flex items-center gap-0.5">
|
||||
<NamedIcon name="dynamic" className="h-4 w-4" />
|
||||
{trigger.dynamic.slug}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PageInfoProperty
|
||||
label="Environment"
|
||||
value={<EnvironmentLabel environment={trigger.environment} />}
|
||||
@@ -206,7 +218,7 @@ export default function Page() {
|
||||
</Button>
|
||||
</Callout>
|
||||
</Form>
|
||||
) : (
|
||||
) : trigger.dynamic ? null : (
|
||||
<Callout variant="error" className="justiy-between mb-4 items-center">
|
||||
This External Trigger hasn't registered successfully. Contact support for help:{" "}
|
||||
{trigger.id}
|
||||
|
||||
+16
-13
@@ -1,5 +1,7 @@
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { Await, useLoaderData } from "@remix-run/react";
|
||||
import { LoaderArgs, defer } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TaskDetail } from "~/components/run/TaskDetail";
|
||||
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
@@ -10,23 +12,24 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TaskDetailsPresenter();
|
||||
const task = await presenter.call({
|
||||
const taskPromise = presenter.call({
|
||||
userId,
|
||||
id: taskParam,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
task,
|
||||
return defer({
|
||||
taskPromise,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { task } = useTypedLoaderData<typeof loader>();
|
||||
return <TaskDetail task={task} />;
|
||||
const { taskPromise } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
|
||||
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function Page() {
|
||||
<OrgAdminHeader />
|
||||
<PageBody>
|
||||
<Header2>Members</Header2>
|
||||
<ul className="flex w-full max-w-md flex-col divide-y divide-slate-800 border-b border-slate-800">
|
||||
<ul className="flex w-full max-w-md flex-col divide-y divide-uiBorder border-b border-uiBorder">
|
||||
{members.map((member) => (
|
||||
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
|
||||
<UserAvatar
|
||||
|
||||
@@ -19,7 +19,10 @@ export async function action({ request }: ActionArgs) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: body.type,
|
||||
content: body,
|
||||
content: {
|
||||
...body,
|
||||
example: { id: body.type, name: body.type, icon: "stripe", payload: body.data.object },
|
||||
},
|
||||
readOnly: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { UpdateTriggerSourceBodySchema } from "@trigger.dev/core";
|
||||
import { UpdateTriggerSourceBodyV1Schema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { UpdateSourceService } from "~/services/sources/updateSource.server";
|
||||
import { UpdateSourceServiceV1 } from "~/services/sources/updateSourceV1.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
endpointSlug: z.string(),
|
||||
@@ -40,13 +40,13 @@ export async function action({ request, params }: ActionArgs) {
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
const body = UpdateTriggerSourceBodySchema.safeParse(anyBody);
|
||||
const body = UpdateTriggerSourceBodyV1Schema.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new UpdateSourceService();
|
||||
const service = new UpdateSourceServiceV1();
|
||||
|
||||
try {
|
||||
const source = await service.call({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { RegisterTriggerBodySchema } from "@trigger.dev/core";
|
||||
import { RegisterTriggerBodySchemaV1 } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { RegisterTriggerSourceService } from "~/services/triggers/registerTriggerSource.server";
|
||||
import { RegisterTriggerSourceServiceV1 } from "~/services/triggers/registerTriggerSourceV1.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
endpointSlug: z.string(),
|
||||
@@ -41,13 +41,13 @@ export async function action({ request, params }: ActionArgs) {
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
const body = RegisterTriggerBodySchema.safeParse(anyBody);
|
||||
const body = RegisterTriggerBodySchemaV1.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new RegisterTriggerSourceService();
|
||||
const service = new RegisterTriggerSourceServiceV1();
|
||||
|
||||
try {
|
||||
const registration = await service.call({
|
||||
|
||||
+6
@@ -1,4 +1,5 @@
|
||||
import { ActionArgs, LoaderArgs, json } from "@remix-run/server-runtime";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -99,6 +100,9 @@ export class TriggerEndpointIndexHookService {
|
||||
slug: endpointSlug,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
environment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpoint) {
|
||||
@@ -122,6 +126,8 @@ export class TriggerEndpointIndexHookService {
|
||||
},
|
||||
{
|
||||
runAt: new Date(Date.now() + 5000),
|
||||
maxAttempts:
|
||||
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { eventRecordToApiJson } from "~/api.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { CancelEventService } from "~/services/events/cancelEvent.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
eventId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid or Missing eventId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { eventId } = parsed.data;
|
||||
|
||||
const service = new CancelEventService();
|
||||
try {
|
||||
const updatedEvent = await service.call(authenticatedEnv, eventId);
|
||||
|
||||
if (!updatedEvent) {
|
||||
return json({ error: "Event not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(eventRecordToApiJson(updatedEvent));
|
||||
} catch (err) {
|
||||
logger.error("CancelEventService.call() error", {
|
||||
error: err,
|
||||
});
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { cors } from "remix-utils";
|
||||
import { GetEvent } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
@@ -32,9 +32,36 @@ export async function loader({ request, params }: LoaderArgs) {
|
||||
|
||||
const { eventId } = parsed.data;
|
||||
|
||||
const event = await prisma.eventRecord.findFirst({
|
||||
const event = await findEventRecord(eventId, authenticatedEnv.id);
|
||||
|
||||
if (!event) {
|
||||
return apiCors(request, json({ error: "Event not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
return apiCors(request, json(toJSON(event)));
|
||||
}
|
||||
|
||||
function toJSON(eventRecord: FoundEventRecord): GetEvent {
|
||||
return {
|
||||
id: eventRecord.eventId,
|
||||
name: eventRecord.name,
|
||||
createdAt: eventRecord.createdAt,
|
||||
updatedAt: eventRecord.updatedAt,
|
||||
runs: eventRecord.runs.map((run) => ({
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: run.completedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type FoundEventRecord = NonNullable<Awaited<ReturnType<typeof findEventRecord>>>;
|
||||
|
||||
async function findEventRecord(eventId: string, environmentId: string) {
|
||||
return await prisma.eventRecord.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
eventId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -48,14 +75,10 @@ export async function loader({ request, params }: LoaderArgs) {
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: eventId,
|
||||
environmentId: authenticatedEnv.id,
|
||||
eventId_environmentId: {
|
||||
eventId,
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return apiCors(request, json({ error: "Event not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
return apiCors(request, json(event));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SendEventBodySchema } from "@trigger.dev/core";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { IngestSendEvent } from "~/services/events/ingestSendEvent.server";
|
||||
import { eventRecordToApiJson } from "~/api.server";
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
// Ensure this is a POST request
|
||||
@@ -33,5 +34,9 @@ export async function action({ request }: ActionArgs) {
|
||||
|
||||
const event = await service.call(authenticatedEnv, body.data.event, body.data.options);
|
||||
|
||||
return json(event);
|
||||
if (!event) {
|
||||
return json({ error: "Failed to create event" }, { status: 500 });
|
||||
}
|
||||
|
||||
return json(eventRecordToApiJson(event));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -157,7 +183,7 @@ export class RunTaskService {
|
||||
},
|
||||
},
|
||||
parent: taskBody.parentId ? { connect: { id: taskBody.parentId } } : undefined,
|
||||
name: taskBody.name,
|
||||
name: taskBody.name ?? "Task",
|
||||
description: taskBody.description,
|
||||
status,
|
||||
startedAt: new Date(),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { UpdateTriggerSourceBodyV2Schema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { UpdateSourceServiceV2 } from "~/services/sources/updateSourceV2.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
endpointSlug: z.string(),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
logger.info("Updating source", { url: request.url });
|
||||
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "PUT") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
logger.info("Invalid params", { params });
|
||||
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
const body = UpdateTriggerSourceBodyV2Schema.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new UpdateSourceServiceV2();
|
||||
|
||||
try {
|
||||
const source = await service.call({
|
||||
environment: authenticatedEnv,
|
||||
payload: body.data,
|
||||
endpointSlug: parsedParams.data.endpointSlug,
|
||||
id: parsedParams.data.id,
|
||||
});
|
||||
|
||||
return json(source);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error activating http source", {
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
REGISTER_SOURCE_EVENT_V2,
|
||||
RegisterSourceEventV2,
|
||||
RegisterTriggerBodySchemaV2,
|
||||
} from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { IngestSendEvent } from "~/services/events/ingestSendEvent.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { RegisterTriggerSourceServiceV2 } from "~/services/triggers/registerTriggerSourceV2.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
endpointSlug: z.string(),
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
logger.info("Registering trigger", { url: request.url });
|
||||
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "PUT") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
logger.info("Invalid params", { params });
|
||||
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
const body = RegisterTriggerBodySchemaV2.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new RegisterTriggerSourceServiceV2();
|
||||
|
||||
try {
|
||||
const registration = await service.call({
|
||||
environment: authenticatedEnv,
|
||||
payload: body.data,
|
||||
endpointSlug: parsedParams.data.endpointSlug,
|
||||
id: parsedParams.data.id,
|
||||
key: parsedParams.data.key,
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
return json({ error: "Could not register trigger" }, { status: 500 });
|
||||
}
|
||||
|
||||
//the source is already active
|
||||
if (registration.source.active) {
|
||||
return json(registration);
|
||||
}
|
||||
|
||||
const payload: RegisterSourceEventV2 = {
|
||||
...registration,
|
||||
dynamicTriggerId: parsedParams.data.id,
|
||||
};
|
||||
|
||||
const ingestEventService = new IngestSendEvent();
|
||||
await ingestEventService.call(
|
||||
authenticatedEnv,
|
||||
{
|
||||
id: registration.id,
|
||||
name: REGISTER_SOURCE_EVENT_V2,
|
||||
source: "trigger.dev",
|
||||
payload,
|
||||
}
|
||||
//todo accountId?
|
||||
// {accountId: body.data.accountId}
|
||||
);
|
||||
|
||||
return json(registration);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error registering trigger", {
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ActionFunction } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
jsonWithErrorMessage,
|
||||
jsonWithSuccessMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { DeleteJobService } from "~/services/jobs/deleteJob.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
const ParamSchema = z.object({
|
||||
jobId: z.string(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { jobId } = ParamSchema.parse(params);
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
// Find the job
|
||||
const job = await prisma.job.findFirst({
|
||||
where: {
|
||||
id: jobId,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return jsonWithErrorMessage({ ok: false }, request, `Job could not be scheduled for deletion.`);
|
||||
}
|
||||
try {
|
||||
const deleteJobService = new DeleteJobService();
|
||||
|
||||
await deleteJobService.call(job);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
|
||||
logger.debug("Job scheduled for deletion", {
|
||||
url,
|
||||
redirectTo,
|
||||
job,
|
||||
});
|
||||
|
||||
if (typeof redirectTo === "string" && redirectTo.length > 0) {
|
||||
return redirectWithSuccessMessage(
|
||||
redirectTo,
|
||||
request,
|
||||
`Job ${job.slug} has been scheduled for deletion.`
|
||||
);
|
||||
}
|
||||
|
||||
return jsonWithSuccessMessage(
|
||||
{ ok: true },
|
||||
request,
|
||||
`Job ${job.slug} has been scheduled for deletion.`
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
return jsonWithErrorMessage(
|
||||
{ ok: false },
|
||||
request,
|
||||
`Job could not be scheduled for deletion: ${message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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,6 +1,7 @@
|
||||
import {
|
||||
ApiEventLog,
|
||||
DeliverEventResponseSchema,
|
||||
DeserializedJson,
|
||||
ErrorWithStackSchema,
|
||||
HttpSourceRequest,
|
||||
HttpSourceResponseSchema,
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
PongResponseSchema,
|
||||
PreprocessRunBody,
|
||||
PreprocessRunResponseSchema,
|
||||
RegisterTriggerBody,
|
||||
RegisterTriggerBodySchema,
|
||||
RegisterTriggerBodySchemaV1,
|
||||
RegisterTriggerBodyV1,
|
||||
RunJobBody,
|
||||
RunJobResponseSchema,
|
||||
ValidateResponse,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
} from "@trigger.dev/core";
|
||||
import { safeBodyFromResponse, safeParseBodyFromResponse } from "~/utils/json";
|
||||
import { logger } from "./logger.server";
|
||||
import { ConnectionAuth } from "@trigger.dev/core";
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
export class EndpointApiError extends Error {
|
||||
constructor(message: string, stack?: string) {
|
||||
@@ -162,6 +165,8 @@ export class EndpointApi {
|
||||
}
|
||||
|
||||
async executeJobRequest(options: RunJobBody) {
|
||||
const startTimeInMs = performance.now();
|
||||
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -176,6 +181,7 @@ export class EndpointApi {
|
||||
response,
|
||||
parser: RunJobResponseSchema,
|
||||
errorParser: ErrorWithStackSchema,
|
||||
durationInMs: Math.floor(performance.now() - startTimeInMs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,7 +199,7 @@ export class EndpointApi {
|
||||
return { response, parser: PreprocessRunResponseSchema };
|
||||
}
|
||||
|
||||
async initializeTrigger(id: string, params: any): Promise<RegisterTriggerBody | undefined> {
|
||||
async initializeTrigger(id: string, params: any): Promise<RegisterTriggerBodyV1 | undefined> {
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -227,7 +233,7 @@ export class EndpointApi {
|
||||
body: anyBody,
|
||||
});
|
||||
|
||||
return RegisterTriggerBodySchema.parse(anyBody);
|
||||
return RegisterTriggerBodySchemaV1.parse(anyBody);
|
||||
}
|
||||
|
||||
async deliverHttpSourceRequest(options: {
|
||||
@@ -237,6 +243,8 @@ export class EndpointApi {
|
||||
params: any;
|
||||
data: any;
|
||||
request: HttpSourceRequest;
|
||||
auth?: ConnectionAuth;
|
||||
metadata?: any;
|
||||
}) {
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
@@ -251,7 +259,9 @@ export class EndpointApi {
|
||||
"x-ts-http-url": options.request.url,
|
||||
"x-ts-http-method": options.request.method,
|
||||
"x-ts-http-headers": JSON.stringify(options.request.headers),
|
||||
...(options.auth && { "x-ts-auth": JSON.stringify(options.auth) }),
|
||||
...(options.dynamicId && { "x-ts-dynamic-id": options.dynamicId }),
|
||||
...(options.metadata && { "x-ts-metadata": JSON.stringify(options.metadata) }),
|
||||
},
|
||||
body: options.request.rawBody,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { env } from "~/env.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
|
||||
|
||||
@@ -51,6 +52,9 @@ export class CreateEndpointService {
|
||||
slug: id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
environment: true,
|
||||
},
|
||||
create: {
|
||||
environment: {
|
||||
connect: {
|
||||
@@ -83,7 +87,11 @@ export class CreateEndpointService {
|
||||
id: endpoint.id,
|
||||
source: "INTERNAL",
|
||||
},
|
||||
{ tx }
|
||||
{
|
||||
tx,
|
||||
maxAttempts:
|
||||
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return endpoint;
|
||||
|
||||
@@ -4,14 +4,18 @@ import { findEndpoint } from "~/models/endpoint.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { RegisterJobService } from "../jobs/registerJob.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { RegisterSourceService } from "../sources/registerSource.server";
|
||||
import { RegisterSourceServiceV1 } from "../sources/registerSourceV1.server";
|
||||
import { RegisterDynamicScheduleService } from "../triggers/registerDynamicSchedule.server";
|
||||
import { RegisterDynamicTriggerService } from "../triggers/registerDynamicTrigger.server";
|
||||
import { DisableJobService } from "../jobs/disableJob.server";
|
||||
import { RegisterSourceServiceV2 } from "../sources/registerSourceV2.server";
|
||||
|
||||
export class IndexEndpointService {
|
||||
#prismaClient: PrismaClient;
|
||||
#registerJobService = new RegisterJobService();
|
||||
#registerSourceService = new RegisterSourceService();
|
||||
#disableJobService = new DisableJobService();
|
||||
#registerSourceServiceV1 = new RegisterSourceServiceV1();
|
||||
#registerSourceServiceV2 = new RegisterSourceServiceV2();
|
||||
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
|
||||
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
|
||||
|
||||
@@ -57,29 +61,114 @@ export class IndexEndpointService {
|
||||
sources: 0,
|
||||
dynamicTriggers: 0,
|
||||
dynamicSchedules: 0,
|
||||
disabledJobs: 0,
|
||||
};
|
||||
|
||||
const existingJobs = await this.#prismaClient.job.findMany({
|
||||
where: {
|
||||
projectId: endpoint.projectId,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
aliases: {
|
||||
where: {
|
||||
name: "latest",
|
||||
environmentId: endpoint.environmentId,
|
||||
},
|
||||
include: {
|
||||
version: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!job.enabled) {
|
||||
continue;
|
||||
const disabledJob = await this.#disableJobService
|
||||
.call(endpoint, { slug: job.id, version: job.version })
|
||||
.catch((error) => {
|
||||
logger.error("Failed to disable job", {
|
||||
endpointId: endpoint.id,
|
||||
job,
|
||||
error,
|
||||
});
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
if (disabledJob) {
|
||||
indexStats.disabledJobs++;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const registeredVersion = await this.#registerJobService.call(endpoint, job);
|
||||
|
||||
if (registeredVersion) {
|
||||
indexStats.jobs++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to register job", {
|
||||
endpointId: endpoint.id,
|
||||
job,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#registerJobService.call(endpoint, job);
|
||||
// TODO: we need to do this for sources, dynamic triggers, and dynamic schedules
|
||||
const missingJobs = existingJobs.filter((job) => {
|
||||
return !jobs.find((j) => j.id === job.slug);
|
||||
});
|
||||
|
||||
indexStats.jobs++;
|
||||
} catch (error) {
|
||||
logger.error("Failed to register job", {
|
||||
endpointId: endpoint.id,
|
||||
job,
|
||||
error,
|
||||
});
|
||||
if (missingJobs.length > 0) {
|
||||
logger.debug("Disabling missing jobs", {
|
||||
endpointId: endpoint.id,
|
||||
missingJobIds: missingJobs.map((job) => job.slug),
|
||||
});
|
||||
|
||||
for (const job of missingJobs) {
|
||||
const latestVersion = job.aliases[0]?.version;
|
||||
|
||||
if (!latestVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const disabledJob = await this.#disableJobService
|
||||
.call(endpoint, {
|
||||
slug: job.slug,
|
||||
version: latestVersion.version,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Failed to disable job", {
|
||||
endpointId: endpoint.id,
|
||||
job,
|
||||
error,
|
||||
});
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
if (disabledJob) {
|
||||
indexStats.disabledJobs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
await this.#registerSourceService.call(endpoint, source);
|
||||
switch (source.version) {
|
||||
default:
|
||||
case "1": {
|
||||
await this.#registerSourceServiceV1.call(endpoint, source);
|
||||
break;
|
||||
}
|
||||
case "2": {
|
||||
await this.#registerSourceServiceV2.call(endpoint, source);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
indexStats.sources++;
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { CreateEndpointError } from "./createEndpoint.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
|
||||
|
||||
@@ -35,6 +36,9 @@ export class ValidateCreateEndpointService {
|
||||
slug: validationResult.endpointId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
environment: true,
|
||||
},
|
||||
create: {
|
||||
environment: {
|
||||
connect: {
|
||||
@@ -67,7 +71,11 @@ export class ValidateCreateEndpointService {
|
||||
id: endpoint.id,
|
||||
source: "INTERNAL",
|
||||
},
|
||||
{ tx }
|
||||
{
|
||||
tx,
|
||||
maxAttempts:
|
||||
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return endpoint;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { EventRecord } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
|
||||
export class CancelEventService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
eventId: string
|
||||
): Promise<EventRecord | undefined> {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
const event = await tx.eventRecord.findUnique({
|
||||
where: {
|
||||
eventId_environmentId: {
|
||||
eventId: eventId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.cancelledAt) {
|
||||
return event;
|
||||
}
|
||||
|
||||
//update the cancelledAt column in the eventRecord table
|
||||
const updatedEvent = await tx.eventRecord.update({
|
||||
where: { id: event.id },
|
||||
data: { cancelledAt: new Date() },
|
||||
});
|
||||
|
||||
// Dequeue the event after the db has been updated
|
||||
await workerQueue.dequeue(`event:${event.id}`, { tx });
|
||||
|
||||
return updatedEvent;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { RawEvent, SendEventOptions } from "@trigger.dev/core";
|
||||
import { $transaction, PrismaClientOrTransaction, PrismaErrorSchema, prisma } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
export class IngestSendEvent {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
@@ -36,71 +37,87 @@ export class IngestSendEvent {
|
||||
try {
|
||||
const deliverAt = this.#calculateDeliverAt(options);
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
const externalAccount = options?.accountId
|
||||
? await tx.externalAccount.findUniqueOrThrow({
|
||||
where: {
|
||||
environmentId_identifier: {
|
||||
environmentId: environment.id,
|
||||
identifier: options.accountId,
|
||||
return await $transaction(
|
||||
this.#prismaClient,
|
||||
async (tx) => {
|
||||
const externalAccount = options?.accountId
|
||||
? await tx.externalAccount.findUniqueOrThrow({
|
||||
where: {
|
||||
environmentId_identifier: {
|
||||
environmentId: environment.id,
|
||||
identifier: options.accountId,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Create a new event in the database
|
||||
const eventLog = await tx.eventRecord.create({
|
||||
data: {
|
||||
organization: {
|
||||
connect: {
|
||||
id: environment.organizationId,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Create a new event in the database
|
||||
const eventLog = await tx.eventRecord.create({
|
||||
data: {
|
||||
organization: {
|
||||
connect: {
|
||||
id: environment.organizationId,
|
||||
project: {
|
||||
connect: {
|
||||
id: environment.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
project: {
|
||||
connect: {
|
||||
id: environment.projectId,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
eventId: event.id,
|
||||
name: event.name,
|
||||
timestamp: event.timestamp ?? new Date(),
|
||||
payload: event.payload ?? {},
|
||||
context: event.context ?? {},
|
||||
source: event.source ?? "trigger.dev",
|
||||
sourceContext,
|
||||
deliverAt: deliverAt,
|
||||
externalAccount: externalAccount
|
||||
? {
|
||||
connect: {
|
||||
id: externalAccount.id,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: environment.id,
|
||||
});
|
||||
|
||||
if (this.deliverEvents) {
|
||||
// Produce a message to the event bus
|
||||
await workerQueue.enqueue(
|
||||
"deliverEvent",
|
||||
{
|
||||
id: eventLog.id,
|
||||
},
|
||||
},
|
||||
eventId: event.id,
|
||||
name: event.name,
|
||||
timestamp: event.timestamp ?? new Date(),
|
||||
payload: event.payload ?? {},
|
||||
context: event.context ?? {},
|
||||
source: event.source ?? "trigger.dev",
|
||||
sourceContext,
|
||||
deliverAt: deliverAt,
|
||||
externalAccount: externalAccount
|
||||
? {
|
||||
connect: {
|
||||
id: externalAccount.id,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
});
|
||||
{ runAt: eventLog.deliverAt, tx, jobKey: `event:${eventLog.id}` }
|
||||
);
|
||||
}
|
||||
|
||||
if (this.deliverEvents) {
|
||||
// Produce a message to the event bus
|
||||
await workerQueue.enqueue(
|
||||
"deliverEvent",
|
||||
{
|
||||
id: eventLog.id,
|
||||
},
|
||||
{ runAt: eventLog.deliverAt, tx }
|
||||
);
|
||||
}
|
||||
|
||||
return eventLog;
|
||||
});
|
||||
return eventLog;
|
||||
},
|
||||
{ rethrowPrismaErrors: true }
|
||||
);
|
||||
} catch (error) {
|
||||
const prismaError = PrismaErrorSchema.safeParse(error);
|
||||
|
||||
if (!prismaError.success) {
|
||||
logger.debug("Error parsing prisma error", {
|
||||
error,
|
||||
parseError: prismaError.error.format(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If the error is a Prisma unique constraint error, it means that the event already exists
|
||||
if (prismaError.success && prismaError.data.code === "P2002") {
|
||||
logger.debug("Event already exists, finding and returning", { event, environment });
|
||||
|
||||
return this.#prismaClient.eventRecord.findUniqueOrThrow({
|
||||
where: {
|
||||
eventId_environmentId: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { airtable } from "./integrations/airtable";
|
||||
import { github } from "./integrations/github";
|
||||
import { openai } from "./integrations/openai";
|
||||
import { plain } from "./integrations/plain";
|
||||
import { resend } from "./integrations/resend";
|
||||
import { sendgrid } from "./integrations/sendgrid";
|
||||
import { slack } from "./integrations/slack";
|
||||
import { stripe } from "./integrations/stripe";
|
||||
import { supabaseManagement, supabase } from "./integrations/supabase";
|
||||
import { supabase, supabaseManagement } from "./integrations/supabase";
|
||||
import { typeform } from "./integrations/typeform";
|
||||
import type { Integration } from "./types";
|
||||
|
||||
@@ -29,13 +31,15 @@ export class IntegrationCatalog {
|
||||
}
|
||||
|
||||
export const integrationCatalog = new IntegrationCatalog({
|
||||
airtable,
|
||||
github,
|
||||
openai,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
import type { Integration } from "../types";
|
||||
import type { HelpSample, Integration } from "../types";
|
||||
|
||||
function usageSample(hasApiKey: boolean): HelpSample {
|
||||
return {
|
||||
title: "Using the client",
|
||||
code: `
|
||||
import { Airtable } from "@trigger.dev/airtable";
|
||||
|
||||
const airtable = new Airtable({
|
||||
id: "__SLUG__"${hasApiKey ? ",\n token: process.env.AIRTABLE_API_KEY!" : ""}
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "alert-on-new-github-issues",
|
||||
name: "Alert on new GitHub issues",
|
||||
version: "0.1.1",
|
||||
trigger: github.triggers.repo({
|
||||
event: events.onIssueOpened,
|
||||
owner: "triggerdotdev",
|
||||
repo: "trigger.dev",
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
//wrap the SDK call in runTask
|
||||
const { data } = await io.runTask(
|
||||
"create-card",
|
||||
{ name: "Create card" },
|
||||
async () => {
|
||||
//create a project card using the underlying client
|
||||
return io.github.client.rest.projects.createCard({
|
||||
column_id: 123,
|
||||
note: "test",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
//log the url of the created card
|
||||
await io.logger.info(data.url);
|
||||
},
|
||||
});
|
||||
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export const airtable: Integration = {
|
||||
identifier: "airtable",
|
||||
@@ -70,18 +112,13 @@ export const airtable: Integration = {
|
||||
},
|
||||
],
|
||||
help: {
|
||||
samples: [
|
||||
{
|
||||
title: "Creating the client",
|
||||
code: `
|
||||
import { Airtable } from "@trigger.dev/airtable";
|
||||
|
||||
const airtable = new Airtable({
|
||||
id: "__SLUG__"
|
||||
});
|
||||
`,
|
||||
},
|
||||
],
|
||||
samples: [usageSample(false)],
|
||||
},
|
||||
},
|
||||
apiKey: {
|
||||
type: "apikey",
|
||||
help: {
|
||||
samples: [usageSample(true)],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Job } from "@trigger.dev/database";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { telemetry } from "../telemetry.server";
|
||||
|
||||
export class DeleteJobService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(job: Job) {
|
||||
// Make sure that all the latest versions are disabled
|
||||
const latestVersions = await this.#prismaClient.jobAlias.findMany({
|
||||
where: {
|
||||
jobId: job.id,
|
||||
name: "latest",
|
||||
},
|
||||
include: {
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
|
||||
const allDisabled = latestVersions.every((alias) => alias.version.status === "DISABLED");
|
||||
|
||||
if (!allDisabled) {
|
||||
throw new Error("All latest versions must be disabled before deleting a job");
|
||||
}
|
||||
|
||||
// Okay now we need to delete a job by setting the deletedAt field and enqueuing a job to cleanup the job
|
||||
await this.#prismaClient.job.update({
|
||||
where: {
|
||||
id: job.id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
telemetry.project.deletedJob({
|
||||
job,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { JobVersion } from "@trigger.dev/database";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
|
||||
import type { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { DisableScheduleSourceService } from "../schedules/disableScheduleSource.server";
|
||||
|
||||
export type DisableJobServiceOptions = {
|
||||
slug: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export class DisableJobService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
endpointIdOrEndpoint: string | ExtendedEndpoint,
|
||||
options: DisableJobServiceOptions
|
||||
) {
|
||||
const endpoint =
|
||||
typeof endpointIdOrEndpoint === "string"
|
||||
? await findEndpoint(endpointIdOrEndpoint)
|
||||
: endpointIdOrEndpoint;
|
||||
|
||||
return this.#disableJob(endpoint.environment, options);
|
||||
}
|
||||
|
||||
async #disableJob(
|
||||
environment: AuthenticatedEnvironment,
|
||||
options: DisableJobServiceOptions
|
||||
): Promise<JobVersion | undefined> {
|
||||
// Find the job
|
||||
const job = await this.#prismaClient.job.findUnique({
|
||||
where: {
|
||||
projectId_slug: {
|
||||
projectId: environment.projectId,
|
||||
slug: options.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobVersion = await this.#prismaClient.jobVersion.findUnique({
|
||||
where: {
|
||||
jobId_version_environmentId: {
|
||||
jobId: job.id,
|
||||
version: options.version,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!jobVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (jobVersion.status === "DISABLED") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upsert the JobVersion
|
||||
const updatedJobVersion = await this.#prismaClient.jobVersion.update({
|
||||
where: {
|
||||
id: jobVersion.id,
|
||||
},
|
||||
data: {
|
||||
status: "DISABLED",
|
||||
},
|
||||
});
|
||||
|
||||
await this.#disableEventDispatcher(updatedJobVersion);
|
||||
|
||||
return updatedJobVersion;
|
||||
}
|
||||
|
||||
async #disableEventDispatcher(jobVersion: JobVersion) {
|
||||
const eventDispatcher = await this.#prismaClient.eventDispatcher.update({
|
||||
where: {
|
||||
dispatchableId_environmentId: {
|
||||
dispatchableId: jobVersion.jobId,
|
||||
environmentId: jobVersion.environmentId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const service = new DisableScheduleSourceService();
|
||||
|
||||
await service.call({
|
||||
key: jobVersion.jobId,
|
||||
dispatcher: eventDispatcher,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -34,7 +34,21 @@ export class RegisterJobService {
|
||||
endpoint: Endpoint,
|
||||
environment: AuthenticatedEnvironment,
|
||||
metadata: JobMetadata
|
||||
): Promise<JobVersion> {
|
||||
): Promise<JobVersion | undefined> {
|
||||
// Check the job doesn't already exist and is deleted
|
||||
const existingJob = await this.#prismaClient.job.findUnique({
|
||||
where: {
|
||||
projectId_slug: {
|
||||
projectId: environment.projectId,
|
||||
slug: metadata.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingJob && existingJob.deletedAt && !metadata.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = new Map<string, Integration>();
|
||||
|
||||
for (const [, jobIntegration] of Object.entries(metadata.integrations)) {
|
||||
@@ -155,6 +169,7 @@ export class RegisterJobService {
|
||||
},
|
||||
update: {
|
||||
title: metadata.name,
|
||||
deletedAt: metadata.enabled ? null : undefined,
|
||||
},
|
||||
include: {
|
||||
integrations: {
|
||||
@@ -166,13 +181,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 +198,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 +250,12 @@ export class RegisterJobService {
|
||||
version: metadata.version,
|
||||
eventSpecification,
|
||||
preprocessRuns: metadata.preprocessRuns,
|
||||
startPosition: metadata.startPosition === "initial" ? "INITIAL" : "LATEST",
|
||||
startPosition: "LATEST",
|
||||
status: "ACTIVE",
|
||||
},
|
||||
update: {
|
||||
startPosition: metadata.startPosition === "initial" ? "INITIAL" : "LATEST",
|
||||
status: "ACTIVE",
|
||||
startPosition: "LATEST",
|
||||
eventSpecification,
|
||||
preprocessRuns: metadata.preprocessRuns,
|
||||
queue: {
|
||||
@@ -411,6 +418,7 @@ export class RegisterJobService {
|
||||
type: "JOB_VERSION",
|
||||
id: jobVersion.id,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -436,7 +444,7 @@ export class RegisterJobService {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
event: SCHEDULED_EVENT,
|
||||
event: [SCHEDULED_EVENT],
|
||||
source: "trigger.dev",
|
||||
payloadFilter: {},
|
||||
contextFilter: {},
|
||||
@@ -454,6 +462,7 @@ export class RegisterJobService {
|
||||
type: "JOB_VERSION",
|
||||
id: jobVersion.id,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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,6 @@
|
||||
import { EXECUTE_JOB_RETRY_LIMIT } from "~/consts";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
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"];
|
||||
|
||||
@@ -18,7 +18,7 @@ export class ContinueRunService {
|
||||
const run = await tx.jobRun.findUniqueOrThrow({
|
||||
where: { id: runId },
|
||||
include: {
|
||||
queue: true,
|
||||
environment: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,77 +26,22 @@ export class ContinueRunService {
|
||||
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, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export class CreateRunService {
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx, queueName: `job-queue:${jobQueue.id}` }
|
||||
{ tx }
|
||||
);
|
||||
|
||||
return run;
|
||||
|
||||
+41
-106
@@ -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 { eventRecordToApiJson } from "~/api.server";
|
||||
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) {
|
||||
@@ -55,7 +54,7 @@ export class PerformRunExecutionService {
|
||||
const { run } = execution;
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = ApiEventLogSchema.parse({ ...run.event, id: run.eventId });
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
const startedAt = new Date();
|
||||
|
||||
await this.#prismaClient.jobRunExecution.update({
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,7 +174,7 @@ export class PerformRunExecutionService {
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = ApiEventLogSchema.parse({ ...run.event, id: run.eventId });
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
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,727 @@
|
||||
import {
|
||||
CachedTask,
|
||||
RunJobError,
|
||||
RunJobResumeWithTask,
|
||||
RunJobRetryWithTask,
|
||||
RunJobSuccess,
|
||||
RunSourceContextSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { eventRecordToApiJson } from "~/api.server";
|
||||
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 type PerformRunExecutionV2Input = {
|
||||
id: string;
|
||||
reason: "PREPROCESS" | "EXECUTE_JOB";
|
||||
isRetry: boolean;
|
||||
resumeTaskId?: string;
|
||||
};
|
||||
|
||||
export class PerformRunExecutionV2Service {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(input: PerformRunExecutionV2Input) {
|
||||
const run = await findRun(this.#prismaClient, input.id);
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (input.reason) {
|
||||
case "PREPROCESS": {
|
||||
await this.#executePreprocessing(run);
|
||||
break;
|
||||
}
|
||||
case "EXECUTE_JOB": {
|
||||
await this.#executeJob(run, input);
|
||||
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 = eventRecordToApiJson(run.event);
|
||||
|
||||
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, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
async #executeJob(run: FoundRun, input: PerformRunExecutionV2Input) {
|
||||
const { isRetry, resumeTaskId } = input;
|
||||
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(run);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
const { executionCount } = await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: `Could not resolve all connections for run ${run.id}. This should not happen`,
|
||||
});
|
||||
}
|
||||
|
||||
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, durationInMs } = 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`,
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
} else {
|
||||
// If the error is a 504 timeout, we should mark this execution as succeeded (by not throwing an error) and enqueue a new execution
|
||||
if (response.status === 504) {
|
||||
return await this.#resumeRunExecutionAfterTimeout(
|
||||
this.#prismaClient,
|
||||
run,
|
||||
input,
|
||||
durationInMs,
|
||||
executionCount
|
||||
);
|
||||
} 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",
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
|
||||
const status = safeBody.data.status;
|
||||
|
||||
switch (status) {
|
||||
case "SUCCESS": {
|
||||
await this.#completeRunWithSuccess(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RESUME_WITH_TASK": {
|
||||
await this.#resumeRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ERROR": {
|
||||
await this.#failRunWithError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RETRY_WITH_TASK": {
|
||||
await this.#retryRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
|
||||
|
||||
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, durationInMs: number) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status: "SUCCESS",
|
||||
output: data.output ?? undefined,
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunWithTask(
|
||||
run: FoundRun,
|
||||
data: RunJobResumeWithTask,
|
||||
isRetry: boolean,
|
||||
durationInMs: number,
|
||||
executionCount: number
|
||||
) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
executionCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunWithError(execution: FoundRun, data: RunJobError, durationInMs: number) {
|
||||
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,
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #retryRunWithTask(
|
||||
run: FoundRun,
|
||||
data: RunJobRetryWithTask,
|
||||
isRetry: boolean,
|
||||
durationInMs: number,
|
||||
executionCount: number
|
||||
) {
|
||||
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",
|
||||
run: {
|
||||
update: {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
runAt: data.retryAt,
|
||||
resumeTaskId: data.task.id,
|
||||
isRetry,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
executionCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunExecutionAfterTimeout(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
run: FoundRun,
|
||||
input: PerformRunExecutionV2Input,
|
||||
durationInMs: number,
|
||||
executionCount: number
|
||||
) {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
const executionDuration = run.executionDuration + durationInMs;
|
||||
|
||||
// If the execution duration is greater than the maximum execution time, we need to fail the run
|
||||
if (executionDuration >= run.organization.maximumExecutionTimePerRunInMs) {
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Execution timed out after ${
|
||||
run.organization.maximumExecutionTimePerRunInMs / 1000
|
||||
} seconds`,
|
||||
},
|
||||
"TIMED_OUT",
|
||||
durationInMs
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The run has timed out, so we need to enqueue a new execution
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
resumeTaskId: input.resumeTaskId,
|
||||
isRetry: input.isRetry,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
executionCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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" | "TIMED_OUT" = "FAILURE",
|
||||
durationInMs: number = 0
|
||||
): 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,
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #cancelExecution(run: FoundRun) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareTasksForRun(possibleTasks: FoundTask[]): CachedTask[] {
|
||||
const tasks = possibleTasks.filter((task) => task.status === "COMPLETED");
|
||||
|
||||
// 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)!;
|
||||
}
|
||||
|
||||
// Prepare tasks and calculate their sizes
|
||||
const availableTasks = tasks.map((task) => {
|
||||
const cachedTask = getCachedTask(task);
|
||||
return { task: cachedTask, size: getCachedTaskSize(cachedTask) };
|
||||
});
|
||||
|
||||
// Sort tasks in ascending order by size
|
||||
availableTasks.sort((a, b) => a.size - b.size);
|
||||
|
||||
// Select tasks using greedy approach
|
||||
const tasksToRun: CachedTask[] = [];
|
||||
let remainingSize = TOTAL_CACHED_TASK_BYTE_LIMIT;
|
||||
|
||||
for (const { task, size } of availableTasks) {
|
||||
if (size <= remainingSize) {
|
||||
tasksToRun.push(task);
|
||||
remainingSize -= size;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
runConnections: {
|
||||
include: {
|
||||
integration: true,
|
||||
connection: {
|
||||
include: {
|
||||
dataReference: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["COMPLETED"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
idempotencyKey: true,
|
||||
status: true,
|
||||
noop: true,
|
||||
output: true,
|
||||
parentId: true,
|
||||
},
|
||||
},
|
||||
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,12 @@
|
||||
import type { ConnectionType, Integration, IntegrationConnection } from "@trigger.dev/database";
|
||||
import { EXECUTE_JOB_RETRY_LIMIT, PREPROCESS_RETRY_LIMIT } from "~/consts";
|
||||
import {
|
||||
RuntimeEnvironmentType,
|
||||
type ConnectionType,
|
||||
type Integration,
|
||||
type IntegrationConnection,
|
||||
} from "@trigger.dev/database";
|
||||
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 +26,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,88 +60,36 @@ 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, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,6 +147,7 @@ async function findRun(tx: PrismaClientOrTransaction, id: string) {
|
||||
where: { id },
|
||||
include: {
|
||||
queue: true,
|
||||
environment: true,
|
||||
version: {
|
||||
include: {
|
||||
integrations: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { EventDispatcher } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
|
||||
export class DisableScheduleSourceService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ key, dispatcher }: { key: string; dispatcher: EventDispatcher }) {
|
||||
const scheduleSourceExists = await this.#prismaClient.scheduleSource.findUnique({
|
||||
where: {
|
||||
key_environmentId: {
|
||||
key,
|
||||
environmentId: dispatcher.environmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!scheduleSourceExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
const scheduleSource = await this.#prismaClient.scheduleSource.update({
|
||||
where: {
|
||||
key_environmentId: {
|
||||
key,
|
||||
environmentId: dispatcher.environmentId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
active: false,
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.dequeue(`scheduled:${scheduleSource.id}`, { tx });
|
||||
|
||||
return scheduleSource;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ export class NextScheduledEventService {
|
||||
},
|
||||
{
|
||||
runAt: scheduleTime,
|
||||
queueName: `scheduler:${scheduleSource.environmentId}`,
|
||||
tx,
|
||||
jobKey: `scheduled:${scheduleSource.id}`,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export class RegisterScheduleSourceService {
|
||||
},
|
||||
metadata: schedule.metadata ?? {},
|
||||
externalAccountId: externalAccount ? externalAccount.id : undefined,
|
||||
active: environment.autoEnableInternalSources,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user