Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+17
-4
@@ -1,11 +1,24 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,60 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export async function enqueueRunExecutionV2(
|
||||
run: JobRun,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options: EnqueueRunExecutionV2Options = {}
|
||||
) {
|
||||
const job = await executionWorker.enqueue(
|
||||
"performRunExecutionV2",
|
||||
{
|
||||
id: run.id,
|
||||
reason: run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB",
|
||||
resumeTaskId: options.resumeTaskId,
|
||||
isRetry: typeof options.isRetry === "boolean" ? options.isRetry : false,
|
||||
},
|
||||
{
|
||||
queueName: `job:${run.jobId}:env:${run.environmentId}`,
|
||||
tx,
|
||||
runAt: options.runAt,
|
||||
jobKey: `job_run:${run.id}`,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
|
||||
+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"
|
||||
|
||||
+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">
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,10 @@ export class EndpointApiError extends Error {
|
||||
}
|
||||
|
||||
export class EndpointApi {
|
||||
constructor(private apiKey: string, private url: string) {}
|
||||
constructor(
|
||||
private apiKey: string,
|
||||
private url: string
|
||||
) {}
|
||||
|
||||
async ping(endpointId: string): Promise<PongResponse> {
|
||||
const response = await safeFetch(this.url, {
|
||||
@@ -162,6 +165,10 @@ export class EndpointApi {
|
||||
}
|
||||
|
||||
async executeJobRequest(options: RunJobBody) {
|
||||
logger.debug("executeJobRequest()", {
|
||||
options,
|
||||
});
|
||||
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,10 +7,12 @@ import { logger } from "../logger.server";
|
||||
import { RegisterSourceService } from "../sources/registerSource.server";
|
||||
import { RegisterDynamicScheduleService } from "../triggers/registerDynamicSchedule.server";
|
||||
import { RegisterDynamicTriggerService } from "../triggers/registerDynamicTrigger.server";
|
||||
import { DisableJobService } from "../jobs/disableJob.server";
|
||||
|
||||
export class IndexEndpointService {
|
||||
#prismaClient: PrismaClient;
|
||||
#registerJobService = new RegisterJobService();
|
||||
#disableJobService = new DisableJobService();
|
||||
#registerSourceService = new RegisterSourceService();
|
||||
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
|
||||
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
|
||||
@@ -57,23 +59,98 @@ 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { plain } from "./integrations/plain";
|
||||
import { resend } from "./integrations/resend";
|
||||
import { slack } from "./integrations/slack";
|
||||
import { stripe } from "./integrations/stripe";
|
||||
import { sendgrid } from "./integrations/sendgrid";
|
||||
import { supabaseManagement, supabase } from "./integrations/supabase";
|
||||
import { typeform } from "./integrations/typeform";
|
||||
import type { Integration } from "./types";
|
||||
@@ -34,8 +35,9 @@ export const integrationCatalog = new IntegrationCatalog({
|
||||
plain,
|
||||
resend,
|
||||
slack,
|
||||
typeform,
|
||||
stripe,
|
||||
supabaseManagement,
|
||||
supabase,
|
||||
sendgrid,
|
||||
typeform,
|
||||
});
|
||||
|
||||
@@ -109,15 +109,9 @@ export class IntegrationConnectionCreatedService {
|
||||
});
|
||||
|
||||
// We need to start the run again
|
||||
await workerQueue.enqueue(
|
||||
"startRun",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{
|
||||
queueName: `job-queue:${run.queue.id}`,
|
||||
}
|
||||
);
|
||||
await workerQueue.enqueue("startRun", {
|
||||
id: run.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Integration } from "../types";
|
||||
|
||||
export const sendgrid: Integration = {
|
||||
identifier: "sendgrid",
|
||||
name: "SendGrid",
|
||||
packageName: "@trigger.dev/sendgrid@latest",
|
||||
authenticationMethods: {
|
||||
apikey: {
|
||||
type: "apikey",
|
||||
help: {
|
||||
samples: [
|
||||
{
|
||||
title: "Creating the client",
|
||||
code: `
|
||||
import { SendGrid } from "@trigger.dev/sendgrid";
|
||||
|
||||
const sendgrid = new SendGrid({
|
||||
id: "__SLUG__",
|
||||
apiKey: process.env.SENDGRID_API_KEY!,
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
title: "Using the client",
|
||||
code: `
|
||||
client.defineJob({
|
||||
id: "send-sendgrid-email",
|
||||
name: "Send SendGrid Email",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({
|
||||
name: "send.email",
|
||||
schema: z.object({
|
||||
to: z.string(),
|
||||
subject: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
}),
|
||||
integrations: {
|
||||
sendgrid,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.sendgrid.sendEmail({
|
||||
to: payload.to,
|
||||
from: "Trigger.dev <hello@email.trigger.dev>",
|
||||
subject: payload.subject,
|
||||
text: payload.text,
|
||||
});
|
||||
},
|
||||
});
|
||||
`,
|
||||
highlight: [
|
||||
[13, 15],
|
||||
[17, 22],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,601 @@
|
||||
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 class PerformRunExecutionV2Service {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
id: string,
|
||||
reason: "PREPROCESS" | "EXECUTE_JOB",
|
||||
isRetry: boolean = false,
|
||||
resumeTaskId?: string
|
||||
) {
|
||||
const run = await findRun(this.#prismaClient, id);
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "PREPROCESS": {
|
||||
await this.#executePreprocessing(run);
|
||||
break;
|
||||
}
|
||||
case "EXECUTE_JOB": {
|
||||
await this.#executeJob(run, isRetry, resumeTaskId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the preprocessing step of a run, which will send the payload to the endpoint and give the job
|
||||
// an opportunity to generate run properties based on the payload.
|
||||
// If the endpoint is not available, or the response is not ok,
|
||||
// the run execution will be marked as failed and the run will start
|
||||
async #executePreprocessing(run: FoundRun) {
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = 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, isRetry: boolean, resumeTaskId?: string) {
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(run);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecutionWithRetry({
|
||||
message: `Could not resolve all connections for run ${run.id}, attempting to retry`,
|
||||
});
|
||||
}
|
||||
|
||||
let resumedTask: Task | undefined;
|
||||
|
||||
if (resumeTaskId) {
|
||||
resumedTask =
|
||||
(await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
})) ?? undefined;
|
||||
|
||||
if (resumedTask) {
|
||||
resumedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
data: {
|
||||
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
|
||||
completedAt: resumedTask.noop ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
|
||||
|
||||
const { response, parser, errorParser } = await client.executeJobRequest({
|
||||
event,
|
||||
job: {
|
||||
id: run.version.job.slug,
|
||||
version: run.version.version,
|
||||
},
|
||||
run: {
|
||||
id: run.id,
|
||||
isTest: run.isTest,
|
||||
startedAt,
|
||||
isRetry,
|
||||
},
|
||||
environment: {
|
||||
id: run.environment.id,
|
||||
slug: run.environment.slug,
|
||||
type: run.environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.organization.id,
|
||||
slug: run.organization.slug,
|
||||
title: run.organization.title,
|
||||
},
|
||||
account: run.externalAccount
|
||||
? {
|
||||
id: run.externalAccount.identifier,
|
||||
metadata: run.externalAccount.metadata,
|
||||
}
|
||||
: undefined,
|
||||
connections: connections.auth,
|
||||
source: sourceContext.success ? sourceContext.data : undefined,
|
||||
tasks: prepareTasksForRun([run.tasks, resumedTask].flat().filter(Boolean)),
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug("Endpoint responded with non-200 status code", {
|
||||
status: response.status,
|
||||
runId: run.id,
|
||||
endpoint: run.endpoint.url,
|
||||
});
|
||||
|
||||
const errorBody = safeJsonZodParse(errorParser, rawBody);
|
||||
|
||||
if (errorBody && errorBody.success) {
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
errorBody.data
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(errorBody.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
const status = safeBody.data.status;
|
||||
|
||||
switch (status) {
|
||||
case "SUCCESS": {
|
||||
await this.#completeRunWithSuccess(run, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RESUME_WITH_TASK": {
|
||||
await this.#resumeRunWithTask(run, safeBody.data, isRetry);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ERROR": {
|
||||
await this.#failRunWithError(run, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RETRY_WITH_TASK": {
|
||||
await this.#retryRunWithTask(run, safeBody.data, isRetry);
|
||||
|
||||
break;
|
||||
}
|
||||
case "CANCELED": {
|
||||
await this.#cancelExecution(run);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #completeRunWithSuccess(run: FoundRun, data: RunJobSuccess) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status: "SUCCESS",
|
||||
output: data.output ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunWithTask(run: FoundRun, data: RunJobResumeWithTask, isRetry: boolean) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
// If the task has an operation, then the next performRunExecution will occur
|
||||
// when that operation has finished
|
||||
if (!data.task.operation) {
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
runAt: data.task.delayUntil ?? undefined,
|
||||
resumeTaskId: data.task.id,
|
||||
isRetry,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunWithError(execution: FoundRun, data: RunJobError) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
if (data.task) {
|
||||
await tx.task.update({
|
||||
where: {
|
||||
id: data.task.id,
|
||||
},
|
||||
data: {
|
||||
status: "ERRORED",
|
||||
completedAt: new Date(),
|
||||
output: data.error ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.#failRunExecution(tx, "EXECUTE_JOB", execution, data.error ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async #retryRunWithTask(run: FoundRun, data: RunJobRetryWithTask, isRetry: boolean) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
// We need to check for an existing task attempt
|
||||
const existingAttempt = await tx.taskAttempt.findFirst({
|
||||
where: {
|
||||
taskId: data.task.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
orderBy: {
|
||||
number: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAttempt) {
|
||||
await tx.taskAttempt.update({
|
||||
where: {
|
||||
id: existingAttempt.id,
|
||||
},
|
||||
data: {
|
||||
status: "ERRORED",
|
||||
error: formatError(data.error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// We need to create a new task attempt
|
||||
await tx.taskAttempt.create({
|
||||
data: {
|
||||
taskId: data.task.id,
|
||||
number: existingAttempt ? existingAttempt.number + 1 : 1,
|
||||
status: "PENDING",
|
||||
runAt: data.retryAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.task.update({
|
||||
where: {
|
||||
id: data.task.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING",
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
runAt: data.retryAt,
|
||||
resumeTaskId: data.task.id,
|
||||
isRetry,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunExecutionWithRetry(output: Record<string, any>): Promise<void> {
|
||||
throw new Error(JSON.stringify(output));
|
||||
}
|
||||
|
||||
async #failRunExecution(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
reason: "EXECUTE_JOB" | "PREPROCESS",
|
||||
run: FoundRun,
|
||||
output: Record<string, any>,
|
||||
status: "FAILURE" | "ABORTED" = "FAILURE"
|
||||
): Promise<void> {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
switch (reason) {
|
||||
case "EXECUTE_JOB": {
|
||||
// If the execution is an EXECUTE_JOB reason, we need to fail the run
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "PREPROCESS": {
|
||||
// If the status is ABORTED, we need to fail the run
|
||||
if (status === "ABORTED") {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
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)!;
|
||||
}
|
||||
|
||||
// Create a dynamic programming array to store intermediate results
|
||||
const dp: number[][] = [];
|
||||
for (let i = 0; i <= tasks.length; i++) {
|
||||
dp[i] = [];
|
||||
for (let j = 0; j <= TOTAL_CACHED_TASK_BYTE_LIMIT; j++) {
|
||||
dp[i][j] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the dynamic programming array
|
||||
for (let i = 1; i <= tasks.length; i++) {
|
||||
const task = tasks[i - 1];
|
||||
const cachedTask = getCachedTask(task);
|
||||
const taskSize = getCachedTaskSize(cachedTask);
|
||||
for (let j = 0; j <= TOTAL_CACHED_TASK_BYTE_LIMIT; j++) {
|
||||
if (taskSize <= j) {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - taskSize] + taskSize);
|
||||
} else {
|
||||
dp[i][j] = dp[i - 1][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse the dynamic programming array to find the included tasks
|
||||
const tasksToRun: CachedTask[] = [];
|
||||
let j = TOTAL_CACHED_TASK_BYTE_LIMIT;
|
||||
for (let i = tasks.length; i > 0 && j > 0; i--) {
|
||||
if (dp[i][j] !== dp[i - 1][j]) {
|
||||
const task = tasks[i - 1];
|
||||
const cachedTask = getCachedTask(task);
|
||||
tasksToRun.unshift(cachedTask);
|
||||
j -= getCachedTaskSize(cachedTask);
|
||||
}
|
||||
}
|
||||
|
||||
return tasksToRun;
|
||||
}
|
||||
|
||||
function prepareTaskForRun(task: FoundTask): CachedTask {
|
||||
return {
|
||||
id: task.idempotencyKey, // We should eventually move this back to task.id
|
||||
status: task.status,
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
noop: task.noop,
|
||||
output: task.output as any,
|
||||
parentId: task.parentId,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateCachedTaskSize(task: CachedTask): number {
|
||||
return JSON.stringify(task).length;
|
||||
}
|
||||
|
||||
async function findRun(prisma: PrismaClientOrTransaction, id: string) {
|
||||
return await prisma.jobRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
environment: true,
|
||||
endpoint: true,
|
||||
organization: true,
|
||||
externalAccount: true,
|
||||
queue: true,
|
||||
runConnections: {
|
||||
include: {
|
||||
integration: true,
|
||||
connection: {
|
||||
include: {
|
||||
dataReference: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["COMPLETED"],
|
||||
},
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
version: {
|
||||
include: {
|
||||
job: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { z } from "zod";
|
||||
import { RawEventSchema, SendEventOptionsSchema } from "@trigger.dev/core";
|
||||
import { IngestSendEvent } from "../events/ingestSendEvent.server";
|
||||
|
||||
const SendEventOutputSchema = z.object({
|
||||
events: z.array(RawEventSchema),
|
||||
options: SendEventOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export class RunFinishedService {
|
||||
#prismaClient: PrismaClient;
|
||||
#ingestEventService = new IngestSendEvent();
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const run = await this.#prismaClient.jobRun.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
environment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Make sure to start any queued runs once this run is finished
|
||||
await workerQueue.enqueue("startQueuedRuns", {
|
||||
id: run.queueId,
|
||||
});
|
||||
|
||||
if (
|
||||
run.status === "SUCCESS" &&
|
||||
run.output &&
|
||||
typeof run.output === "object" &&
|
||||
"events" in run.output
|
||||
) {
|
||||
// If the run successfully completes, we will parse the output and
|
||||
// if it's in the form of { events: Array<RawEvent> } then we will send the events
|
||||
const parsedOutput = SendEventOutputSchema.safeParse(run.output);
|
||||
|
||||
if (parsedOutput.success) {
|
||||
for (const newEvent of parsedOutput.data.events) {
|
||||
await this.#ingestEventService.call(run.environment, newEvent, parsedOutput.data.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
|
||||
export class StartQueuedRunsService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const queue = await this.#prismaClient.jobQueue.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
runs: {
|
||||
where: {
|
||||
status: "QUEUED",
|
||||
},
|
||||
orderBy: {
|
||||
queuedAt: "asc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.runs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.jobCount >= queue.maxJobs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = queue.runs[0];
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"startRun",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{
|
||||
queueName: `job-queue:${queue.id}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { RegisterScheduleBody } from "@trigger.dev/core";
|
||||
import { $transaction, PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { RegisterScheduleSourceService } from "./registerScheduleSource.server";
|
||||
|
||||
export class UnregisterScheduleService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
export class HandleHttpSourceService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -55,6 +56,8 @@ export class HandleHttpSourceService {
|
||||
{
|
||||
queueName: `endpoint-${triggerSource.endpointId}`,
|
||||
tx,
|
||||
maxAttempts:
|
||||
triggerSource.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import { EXECUTE_JOB_RETRY_LIMIT } from "~/consts";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { env } from "process";
|
||||
import { Run } from "~/presenters/RunPresenter.server";
|
||||
import {
|
||||
FetchOperationSchema,
|
||||
FetchRequestInit,
|
||||
@@ -10,9 +8,13 @@ import {
|
||||
RedactString,
|
||||
calculateRetryAt,
|
||||
} from "@trigger.dev/core";
|
||||
import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
|
||||
import { formatUnknownError } from "~/utils/formatErrors.server";
|
||||
import { safeJsonFromResponse } from "~/utils/json";
|
||||
import { logger } from "../logger.server";
|
||||
import { formatUnknownError } from "~/utils/formatErrors.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
|
||||
type FoundTask = Awaited<ReturnType<typeof findTask>>;
|
||||
|
||||
@@ -192,7 +194,7 @@ export class PerformTaskOperationService {
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeTaskWithError(task: Task, output: any) {
|
||||
async #resumeTaskWithError(task: NonNullable<FoundTask>, output: any) {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.task.update({
|
||||
where: { id: task.id },
|
||||
@@ -243,33 +245,9 @@ export class PerformTaskOperationService {
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunExecution(task: Task, prisma: PrismaClientOrTransaction) {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
const newJobExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: task.runId,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
|
||||
const graphileJob = await workerQueue.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: newJobExecution.id,
|
||||
},
|
||||
data: {
|
||||
graphileJobId: graphileJob.id,
|
||||
},
|
||||
});
|
||||
async #resumeRunExecution(task: NonNullable<FoundTask>, prisma: PrismaClientOrTransaction) {
|
||||
await enqueueRunExecutionV2(task.run, prisma, {
|
||||
skipRetrying: task.run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -305,6 +283,12 @@ async function findTask(prisma: PrismaClient, id: string) {
|
||||
where: { id },
|
||||
include: {
|
||||
attempts: true,
|
||||
run: {
|
||||
include: {
|
||||
environment: true,
|
||||
queue: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Job } from "@trigger.dev/database";
|
||||
import { TriggerClient } from "@trigger.dev/sdk";
|
||||
import { PostHog } from "posthog-node";
|
||||
import { env } from "~/env.server";
|
||||
@@ -149,6 +150,14 @@ class Telemetry {
|
||||
},
|
||||
});
|
||||
},
|
||||
deletedJob: ({ job }: { job: Job }) => {
|
||||
this.#triggerClient?.sendEvent({
|
||||
name: "job.deleted",
|
||||
payload: {
|
||||
id: job.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
#capture(event: CaptureEvent) {
|
||||
|
||||
@@ -79,7 +79,7 @@ export class RegisterDynamicScheduleService {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
event: SCHEDULED_EVENT,
|
||||
event: [SCHEDULED_EVENT],
|
||||
source: "trigger.dev",
|
||||
payloadFilter: {},
|
||||
contextFilter: {},
|
||||
|
||||
@@ -76,7 +76,7 @@ export class RegisterTriggerSourceService {
|
||||
create: {
|
||||
dispatchableId: triggerSource.id,
|
||||
environmentId: environment.id,
|
||||
event: payload.rule.event,
|
||||
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
|
||||
source: payload.rule.source,
|
||||
payloadFilter: payload.rule.payload,
|
||||
contextFilter: payload.rule.context,
|
||||
@@ -86,7 +86,7 @@ export class RegisterTriggerSourceService {
|
||||
},
|
||||
},
|
||||
update: {
|
||||
event: payload.rule.event,
|
||||
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
|
||||
source: payload.rule.source,
|
||||
payloadFilter: payload.rule.payload,
|
||||
contextFilter: payload.rule.context,
|
||||
|
||||
@@ -12,9 +12,8 @@ import { InvokeDispatcherService } from "./events/invokeDispatcher.server";
|
||||
import { integrationAuthRepository } from "./externalApis/integrationAuthRepository.server";
|
||||
import { IntegrationConnectionCreatedService } from "./externalApis/integrationConnectionCreated.server";
|
||||
import { MissingConnectionCreatedService } from "./runs/missingConnectionCreated.server";
|
||||
import { PerformRunExecutionService } from "./runs/performRunExecution.server";
|
||||
import { RunFinishedService } from "./runs/runFinished.server";
|
||||
import { StartQueuedRunsService } from "./runs/startQueuedRuns.server";
|
||||
import { PerformRunExecutionV1Service } from "./runs/performRunExecutionV1.server";
|
||||
import { PerformRunExecutionV2Service } from "./runs/performRunExecutionV2.server";
|
||||
import { StartRunService } from "./runs/startRun.server";
|
||||
import { DeliverScheduledEventService } from "./schedules/deliverScheduledEvent.server";
|
||||
import { ActivateSourceService } from "./sources/activateSource.server";
|
||||
@@ -30,13 +29,9 @@ const workerCatalog = {
|
||||
}),
|
||||
scheduleEmail: DeliverEmailSchema,
|
||||
startRun: z.object({ id: z.string() }),
|
||||
performRunExecution: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
performTaskOperation: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
runFinished: z.object({ id: z.string() }),
|
||||
deliverHttpSourceRequest: z.object({ id: z.string() }),
|
||||
refreshOAuthToken: z.object({
|
||||
organizationId: z.string(),
|
||||
@@ -46,7 +41,7 @@ const workerCatalog = {
|
||||
id: z.string(),
|
||||
orphanedEvents: z.array(z.string()).optional(),
|
||||
}),
|
||||
startQueuedRuns: z.object({ id: z.string() }),
|
||||
|
||||
deliverEvent: z.object({ id: z.string() }),
|
||||
"events.invokeDispatcher": z.object({
|
||||
id: z.string(),
|
||||
@@ -64,10 +59,24 @@ const workerCatalog = {
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
performRunExecution: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
performRunExecutionV2: z.object({
|
||||
id: z.string(),
|
||||
reason: z.enum(["EXECUTE_JOB", "PREPROCESS"]),
|
||||
resumeTaskId: z.string().optional(),
|
||||
isRetry: z.boolean(),
|
||||
}),
|
||||
};
|
||||
|
||||
let workerQueue: ZodWorker<typeof workerCatalog>;
|
||||
let executionWorker: ZodWorker<typeof executionWorkerCatalog>;
|
||||
|
||||
declare global {
|
||||
var __worker__: ZodWorker<typeof workerCatalog>;
|
||||
var __executionWorker__: ZodWorker<typeof executionWorkerCatalog>;
|
||||
}
|
||||
|
||||
// this is needed because in development we don't want to restart
|
||||
@@ -76,25 +85,41 @@ declare global {
|
||||
// in production we'll have a single connection to the DB.
|
||||
if (env.NODE_ENV === "production") {
|
||||
workerQueue = getWorkerQueue();
|
||||
executionWorker = getExecutionWorkerQueue();
|
||||
} else {
|
||||
if (!global.__worker__) {
|
||||
global.__worker__ = getWorkerQueue();
|
||||
}
|
||||
workerQueue = global.__worker__;
|
||||
|
||||
if (!global.__executionWorker__) {
|
||||
global.__executionWorker__ = getExecutionWorkerQueue();
|
||||
}
|
||||
|
||||
executionWorker = global.__executionWorker__;
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
await workerQueue.initialize();
|
||||
if (env.WORKER_ENABLED === "true") {
|
||||
await workerQueue.initialize();
|
||||
}
|
||||
|
||||
if (env.EXECUTION_WORKER_ENABLED === "true") {
|
||||
await executionWorker.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "workerQueue",
|
||||
prisma,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: 5,
|
||||
pollInterval: 1000,
|
||||
concurrency: env.WORKER_CONCURRENCY,
|
||||
pollInterval: env.WORKER_POLL_INTERVAL,
|
||||
noPreparedStatements: env.DATABASE_URL !== env.DIRECT_URL,
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.WORKER_CONCURRENCY,
|
||||
},
|
||||
schema: workerCatalog,
|
||||
recurringTasks: {
|
||||
@@ -124,6 +149,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
tasks: {
|
||||
"events.invokeDispatcher": {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new InvokeDispatcherService();
|
||||
@@ -132,6 +158,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
"events.deliverScheduled": {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 5,
|
||||
handler: async ({ id, payload }, job) => {
|
||||
const service = new DeliverScheduledEventService();
|
||||
@@ -140,6 +167,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
connectionCreated: {
|
||||
priority: 10, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new IntegrationConnectionCreatedService();
|
||||
@@ -148,6 +176,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
missingConnectionCreated: {
|
||||
priority: 10, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new MissingConnectionCreatedService();
|
||||
@@ -155,24 +184,8 @@ function getWorkerQueue() {
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
runFinished: {
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new RunFinishedService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
startQueuedRuns: {
|
||||
maxAttempts: 3,
|
||||
queueName: (payload) => `queue:${payload.id}`,
|
||||
handler: async (payload, job) => {
|
||||
const service = new StartQueuedRunsService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
activateSource: {
|
||||
priority: 10, // smaller number = higher priority
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new ActivateSourceService();
|
||||
@@ -181,7 +194,8 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
deliverHttpSourceRequest: {
|
||||
maxAttempts: 25,
|
||||
priority: 1, // smaller number = higher priority
|
||||
maxAttempts: 14,
|
||||
handler: async (payload, job) => {
|
||||
const service = new DeliverHttpSourceRequestService();
|
||||
|
||||
@@ -189,23 +203,16 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
startRun: {
|
||||
maxAttempts: 8,
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 4,
|
||||
handler: async (payload, job) => {
|
||||
const service = new StartRunService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performRunExecution: {
|
||||
queueName: (payload) => `runs:${payload.id}`,
|
||||
maxAttempts: 1,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformRunExecutionService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performTaskOperation: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
queueName: (payload) => `tasks:${payload.id}`,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
@@ -223,6 +230,8 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
indexEndpoint: {
|
||||
priority: 1, // smaller number = higher priority
|
||||
maxAttempts: 7,
|
||||
handler: async (payload, job) => {
|
||||
const service = new IndexEndpointService();
|
||||
|
||||
@@ -230,6 +239,8 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
deliverEvent: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 5,
|
||||
handler: async (payload, job) => {
|
||||
const service = new DeliverEventService();
|
||||
|
||||
@@ -237,8 +248,9 @@ function getWorkerQueue() {
|
||||
},
|
||||
},
|
||||
refreshOAuthToken: {
|
||||
priority: 8, // smaller number = higher priority
|
||||
queueName: "internal-queue",
|
||||
maxAttempts: 10,
|
||||
maxAttempts: 7,
|
||||
handler: async (payload, job) => {
|
||||
await integrationAuthRepository.refreshConnection({
|
||||
connectionId: payload.connectionId,
|
||||
@@ -249,4 +261,42 @@ function getWorkerQueue() {
|
||||
});
|
||||
}
|
||||
|
||||
export { workerQueue };
|
||||
function getExecutionWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "executionWorker",
|
||||
prisma,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: env.EXECUTION_WORKER_CONCURRENCY,
|
||||
pollInterval: env.EXECUTION_WORKER_POLL_INTERVAL,
|
||||
noPreparedStatements: env.DATABASE_URL !== env.DIRECT_URL,
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.EXECUTION_WORKER_CONCURRENCY,
|
||||
},
|
||||
schema: executionWorkerCatalog,
|
||||
tasks: {
|
||||
performRunExecution: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 1,
|
||||
handler: async (payload, job) => {
|
||||
// This is a legacy task that we don't use anymore, but needs to be here for backwards compatibility
|
||||
// TODO: remove this once all performRunExecution tasks have been processed
|
||||
const service = new PerformRunExecutionV1Service();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performRunExecutionV2: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 12,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformRunExecutionV2Service();
|
||||
|
||||
await service.call(payload.id, payload.reason, payload.isRetry, payload.resumeTaskId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { executionWorker, workerQueue };
|
||||
|
||||
@@ -45,13 +45,10 @@ export function useMatchesData(
|
||||
const paths = Array.isArray(id) ? id : [id];
|
||||
|
||||
// Get the first matching route
|
||||
const route = paths.reduce(
|
||||
(acc, path) => {
|
||||
if (acc) return acc;
|
||||
return matchingRoutes.find((route) => route.id === path);
|
||||
},
|
||||
undefined as RouteMatch | undefined
|
||||
);
|
||||
const route = paths.reduce((acc, path) => {
|
||||
if (acc) return acc;
|
||||
return matchingRoutes.find((route) => route.id === path);
|
||||
}, undefined as RouteMatch | undefined);
|
||||
|
||||
return route;
|
||||
}
|
||||
@@ -76,7 +73,7 @@ export function hydrateDates(object: any): any {
|
||||
if (
|
||||
typeof object === "string" &&
|
||||
object.match(/\d{4}-\d{2}-\d{2}/) &&
|
||||
!isNaN(Date.parse(object))
|
||||
!Number.isNaN(Date.parse(object))
|
||||
) {
|
||||
return new Date(object);
|
||||
}
|
||||
|
||||
@@ -73,10 +73,6 @@ export function confirmBasicDetailsPath() {
|
||||
return `/confirm-basic-details`;
|
||||
}
|
||||
|
||||
export function invitationCodePath() {
|
||||
return `/invitation-code`;
|
||||
}
|
||||
|
||||
export function acceptInvitePath(token: string) {
|
||||
return `/invite-accept?token=${token}`;
|
||||
}
|
||||
@@ -119,6 +115,10 @@ export function projectPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`;
|
||||
}
|
||||
|
||||
export function projectJobsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return projectPath(organization, project);
|
||||
}
|
||||
|
||||
export function projectIntegrationsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${projectPath(organization, project)}/integrations`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eventStream } from "remix-utils";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type SseProps = {
|
||||
request: Request;
|
||||
@@ -30,12 +31,38 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }:
|
||||
};
|
||||
|
||||
return eventStream(request.signal, (send) => {
|
||||
const safeSend = (args: { event?: string; data: string }) => {
|
||||
try {
|
||||
send(args);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE, aborting", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
args,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug("Uknown error sending SSE, aborting", {
|
||||
error,
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
abort();
|
||||
}
|
||||
};
|
||||
|
||||
pinger = setInterval(() => {
|
||||
send({ event: "ping", data: new Date().toISOString() });
|
||||
safeSend({ event: "ping", data: new Date().toISOString() });
|
||||
}, pingInterval);
|
||||
|
||||
updater = setInterval(async () => {
|
||||
run(send, abort);
|
||||
run(safeSend, abort);
|
||||
}, updateInterval);
|
||||
|
||||
return abort;
|
||||
|
||||
+11
-7
@@ -54,14 +54,18 @@ app.all(
|
||||
|
||||
const port = process.env.REMIX_APP_PORT || 3000;
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
// require the built app so we're ready when the first request comes in
|
||||
require(BUILD_DIR);
|
||||
console.log(`✅ app ready: http://localhost:${port}`);
|
||||
});
|
||||
if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
const server = app.listen(port, () => {
|
||||
// require the built app so we're ready when the first request comes in
|
||||
require(BUILD_DIR);
|
||||
console.log(`✅ app ready: http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Handle shutdowns gracefully
|
||||
createTerminus(server, { signals: ["SIGINT", "SIGTERM"], timeout: 5000 });
|
||||
// Handle shutdowns gracefully
|
||||
createTerminus(server, { signals: ["SIGINT", "SIGTERM"], timeout: 5000 });
|
||||
} else {
|
||||
console.log(`✅ app ready (skipping http server)`);
|
||||
}
|
||||
|
||||
function purgeRequireCache() {
|
||||
// purge require cache on requests for "server side HMR" this won't let
|
||||
|
||||
@@ -144,6 +144,7 @@ module.exports = {
|
||||
},
|
||||
devEnv: colors.pink,
|
||||
liveEnv: colors.green,
|
||||
uiBorder: slate[800],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: radius,
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
RUN pnpm install --prod --no-frozen-lockfile
|
||||
COPY --from=pruner --chown=node:node /triggerdotdev/packages/database/prisma/schema.prisma /triggerdotdev/packages/database/prisma/schema.prisma
|
||||
RUN pnpx prisma@^4.16.0 generate --schema /triggerdotdev/packages/database/prisma/schema.prisma
|
||||
# RUN pnpm add @prisma/client@5.1.1 -w
|
||||
RUN pnpx prisma@4.16.0 generate --schema /triggerdotdev/packages/database/prisma/schema.prisma
|
||||
|
||||
## Builder (builds the webapp)
|
||||
FROM base AS builder
|
||||
|
||||
@@ -72,7 +72,7 @@ There are some things worth highlighting here:
|
||||
|
||||
### API Keys and Tokens
|
||||
|
||||
You the value in when creating your Integration client. They are never sent from your server by the Trigger.dev service, they are local to your servers. We recommend you use a secure method of storing these values and passing them to your code, like environment variables.
|
||||
You provide the API Key value when creating your Integration client. Keys aren't sent from your server by the Trigger.dev service, they are local to your servers. We recommend you use a secure method of storing these values and passing them to your code, like environment variables.
|
||||
|
||||
### OAuth
|
||||
|
||||
|
||||
@@ -23,12 +23,15 @@ client.defineJob({
|
||||
// 1. Run function with params
|
||||
run: async (payload, io, ctx) => {
|
||||
// 2. Regular code and Tasks
|
||||
// 3. Optionally return data from run execution
|
||||
return { status: 'success' }
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
1. The `run()` function is called with some useful parameters. More on that in a second.
|
||||
2. Inside the run function you can write regular code and use [Tasks](/documentation/concepts/tasks).
|
||||
3. You can return data, which will then be retrievable with [getRun](/sdk/triggerclient/instancemethods/getrun) or the [React hooks](/documentation/guides/react-hooks).
|
||||
|
||||
## Resumability
|
||||
|
||||
|
||||
@@ -60,15 +60,13 @@ yarn dlx @trigger.dev/cli@latest init
|
||||
|
||||
<Accordion title="Enter your development API key">
|
||||
To locate your development API key, login to the [Trigger.dev
|
||||
dashboard](https://cloud.trigger.dev) and select the Project you want to
|
||||
connect to. Then click on the Environments & API Keys tab in the left menu.
|
||||
You can copy your development API Key from the field at the top of this page.
|
||||
(Your development key will start with `tr_dev_`).
|
||||
dashboard](https://cloud.trigger.dev) and select the Project you want to connect to. Then click on
|
||||
the Environments & API Keys tab in the left menu. You can copy your development API Key from the
|
||||
field at the top of this page. (Your development key will start with `tr_dev_`).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Enter a unique ID for your endpoint">
|
||||
Enter a custom ID or use the default by hitting enter. You can learn more
|
||||
about endpoints
|
||||
Enter a custom ID or use the default by hitting enter. You can learn more about endpoints
|
||||
[here](/documentation/concepts/environments-endpoints#endpoints).
|
||||
</Accordion>
|
||||
|
||||
@@ -81,8 +79,8 @@ Once you're running your Next.js project locally, you can then execute the `dev`
|
||||

|
||||
|
||||
<Warning>
|
||||
Make sure your Next.js site is running locally before continuing. You must
|
||||
also leave this `dev` terminal command running while you develop.
|
||||
Make sure your Next.js site is running locally before continuing. You must also leave this `dev`
|
||||
terminal command running while you develop.
|
||||
</Warning>
|
||||
|
||||
In a **new terminal window or tab** run:
|
||||
@@ -107,3 +105,67 @@ yarn dlx @trigger.dev/cli@latest dev
|
||||
You can optionally pass the port if you're not running on 3000 by adding
|
||||
`--port 3001` to the end
|
||||
</Note>
|
||||
<Note>
|
||||
You can optionally pass the hostname if you're not running on localhost by adding
|
||||
`--hostname <host>`. Example, in case your Next.js is running on 0.0.0.0: `--hostname 0.0.0.0`.
|
||||
</Note>
|
||||
|
||||
## update Command
|
||||
|
||||
The `update` command will update all Trigger.dev packages to the latest version.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npx @trigger.dev/cli@latest update
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm dlx @trigger.dev/cli@latest update
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn dlx @trigger.dev/cli@latest update
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## whoami Command
|
||||
|
||||
The `whoami` command will print out information about your current Trigger.dev project and environment, based on the API key found in your `.env` or `.env.local` file
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npx @trigger.dev/cli@latest whoami
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm dlx @trigger.dev/cli@latest whoami
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn dlx @trigger.dev/cli@latest whoami
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## send-event Command
|
||||
|
||||
The `send-event` command will send an event to your Trigger.dev project. This is useful for testing your Trigger.dev project locally.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npx @trigger.dev/cli@latest send-event -n "event.name" -p "{ \"key\": \"value\" }"
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm dlx @trigger.dev/cli@latest send-event -n "event.name" -p "{ \"key\": \"value\" }"
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn dlx @trigger.dev/cli@latest send-event -n "event.name" -p "{ \"key\": \"value\" }"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: "Managing Jobs"
|
||||
description: "Managing Jobs in your codebase and the dashboard"
|
||||
---
|
||||
|
||||
## Disabling Jobs
|
||||
|
||||
To prevent a Job from processing new Runs, you can disable it by setting the `enabled` option:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "example-job",
|
||||
name: "Example Job",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({ name: "example.event" }),
|
||||
enabled: false,
|
||||
run: async (payload, io, ctx) => {
|
||||
// your Job code here
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you omit the `enabled` option, it will default to `true`.
|
||||
|
||||
The Job will only be disabled in environments that have seen the `enabled = false` value. So the Job will remain enabled in production until the code with the `enabled = false` is deployed to production.
|
||||
|
||||
<Note>
|
||||
Currently this is the only way to disable a Job. If you'd like to disable a Job in the Dashboard,
|
||||
please reach out to us on [Discord](https://discord.gg/kA47vcd8P6) and let us know 👋
|
||||
</Note>
|
||||
|
||||
Once a Job is disabled no **new** Runs will be created for that Job, and it will still be visible in the Dashboard as disabled:
|
||||
|
||||

|
||||
|
||||
### In-progress Runs
|
||||
|
||||
In-progress Runs will be allowed to finish, even Runs that are currently delayed from a call to `io.wait`. If you'd like to completely stop in-progress Runs, you have two options:
|
||||
|
||||
- Set the `enabled` option to false and then `throw` an error at the top of your Job `run` function.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "example-job",
|
||||
name: "Example Job",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({ name: "example.event" }),
|
||||
enabled: false,
|
||||
run: async (payload, io, ctx) => {
|
||||
throw new Error("Job disabled");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- Delete the Job from your codebase. This will disable the Job as well but also stop in progress Runs.
|
||||
|
||||
### Disabling in production with env vars
|
||||
|
||||
You can easily disable Jobs in production using env vars so you don't have to deploy new code to disable a Job.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "example-job",
|
||||
name: "Example Job",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({ name: "example.event" }),
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
|
||||
run: async (payload, io, ctx) => {
|
||||
// your Job code here
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Then you can disable the Job in production by setting the `TRIGGER_JOBS_DISABLED` env var to `"true"`. And removing the env var will re-enable the Job.
|
||||
|
||||
## Deleting Jobs
|
||||
|
||||
Once you have disabled a Job in all environments, you can delete it from the dashboard by navigating to the Job list page and clicking the "triple-dot" menu next to the Job you want to delete:
|
||||
|
||||

|
||||
|
||||
This will bring up a dialog confirming that you want to delete the Job and all of its history:
|
||||
|
||||

|
||||
@@ -258,5 +258,9 @@ yarn dlx @trigger.dev/cli@latest dev
|
||||
You can optionally pass the port if you're not running on 3000 by adding
|
||||
`--port 3001` to the end
|
||||
</Note>
|
||||
<Note>
|
||||
You can optionally pass the hostname if you're not running on localhost by adding
|
||||
`--hostname <host>`. Example, in case your Next.js is running on 0.0.0.0: `--hostname 0.0.0.0`.
|
||||
</Note>
|
||||
|
||||
<Tip>If your existing Next.js project utilizes middleware and you encounter any issues, such as potential conflicts with Trigger.dev, it's recommended to refer to the troubleshooting guide at [Middleware](/documentation/guides/platforms/nextjs#middleware) for assistance. This guide can help you address any concerns related to middleware conflicts and ensure the smooth functioning of your project with Trigger.dev.</Tip>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user