Compare commits

...

68 Commits

Author SHA1 Message Date
Eric Allam c9811beae2 Release 3.1.1
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-01 21:33:00 +00:00
github-actions[bot] 97533cba2e chore: Update version for release (#1442)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-01 21:32:04 +00:00
Dan e92459bf1e Added revalidation docs (#1444)
* Added revalidation docs

* Typo

* Copy tweak
2024-11-01 21:25:44 +00:00
Eric Allam cc74cae393 chore: remove unnecessary browser export condition (#1455) 2024-11-01 21:11:45 +00:00
nicktrn 58def055d0 Add new internal error for very outdated SDK versions (#1453)
* add outdated sdk error and crash outdated runs instead of staying queued forever

* add changeset
2024-11-01 15:41:31 +00:00
D-K-P 41f5261fe5 Added drizzle docs and updated prisma 2024-11-01 14:40:25 +00:00
Eric Allam ce1a54eb3b Renamed useBatch to useRealtimeBatch (#1447)
* Renamed useBatch to useRealtimeBatch

* Use the new hook name in the nextjs-realtime ref
2024-11-01 13:23:02 +00:00
Eric Allam 25285488f8 Add individual run ids to auto-generated public access token when calling batchTrigger (#1449) 2024-11-01 13:22:34 +00:00
Eric Allam befdcef1f0 Add known issue for realtime (#1452) 2024-11-01 13:21:01 +00:00
nicktrn fa18e6c012 Increase maximum retry count for deploy run controller (#1450)
* increase max retries for all deploy run controller operations

* add changeset
2024-11-01 11:23:11 +00:00
nicktrn 4194bce4a3 Forked worker processes now inherit PATH from parent (#1448)
* set PATH when forking

* add changeset
2024-11-01 11:23:00 +00:00
nicktrn da08e5015f Pass init output to all error handlers (#1441)
* pass init output to all error handlers

* add changeset
2024-10-31 11:23:47 +00:00
D-K-P 9b7472844d Updated deploy docs 2024-10-31 09:49:59 +00:00
Dan eace78fa04 Added fal AI example (#1439) 2024-10-30 17:30:23 +00:00
Dan e81b2a7839 Firecrawl example docs (#1438)
* Added firecrawl example docs

* Added Firecrawl and scrape hn to the intro page table
2024-10-30 16:41:04 +00:00
Eric Allam cdcfc81aca Add API_ORIGIN env var to be able to split dashboard & API servers 2024-10-29 16:43:22 +00:00
Eric Allam 816b0f9e3d Add some more react hooks & realtime docs 2024-10-29 16:41:57 +00:00
Eric Allam 6fb073672d Add realtime API docs 2024-10-29 13:57:02 +00:00
Eric Allam 9203bd8e67 Add realtime walkthrough video to docs 2024-10-29 13:23:57 +00:00
Eric Allam 3252d9ec31 docs: realtime, react hooks, and frontend usage (#1434)
* WIP realtime/frontend docs

* WIP

* Deleted old examples

* Improved styling

* Improved the generated example styles

* More style improvements

* More realtime docs

* Couple doc tweeks

* runs-and-attempts -> runs

* Remove the prerelease package version

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2024-10-29 12:31:04 +00:00
Eric Allam 45fdf35e20 Release 3.1.0
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-10-29 12:25:27 +00:00
github-actions[bot] 53841d7bf0 chore: Update version for release (#1423)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-29 12:24:21 +00:00
nicktrn d2c779eb0f Fixes for internal error reattempts (#1436)
* magic links on span event errors

* prevent task monitor from processing errors handled elsewhere

* exclusively use internal error code enum for completion data

* add complete attempt service opts

* reattempts need to go via the queue for task controllers that may have exited

* only infer retry config if completed via crash or system failure

* enhance error before deciding if retriable

* retry on SIGTERM

* enable retry config helper for latest sdk

* don't retry heartbeat timeouts for now

* enable task monitor to update fatal errors

* add missing service

* update retry config since package version

* don't alter completion time when updating existing error
2024-10-28 19:15:47 +00:00
Dan cbe51707a5 Added Sentry example (#1435)
* Added sentry docs

* Removed additionalPackages

* Updated description

* Copy updates
2024-10-28 15:57:41 +00:00
Eric Allam 3d7a6d8e9d Add missing indexes on Checkpoint table 2024-10-28 13:37:59 +00:00
Valentin Mocanu 72cdb5edc4 Supply correct namespace to TaskMonitor (#1419) 2024-10-27 12:53:43 +00:00
James Ritchie 1f11a8dde0 Added an AI prompt for converting v2 jobs to v3 tasks 2024-10-25 16:06:27 +01:00
Eric Allam af427aa32d Proxy no longer rewrites hostname, instead uses worker origin request forwarding 2024-10-24 17:02:57 +01:00
nicktrn 212f8539c3 Automatically reattempt after internal errors (#1424)
* refactor finalize run service

* refactor complete attempt service

* remove separate graceful exit handling

* refactor task status helpers

* clearly separate statuses in prisma schema

* all non-final statuses should be failable

* new import payload error code

* store default retry config if none set on task

* failed run service now respects retries

* fix merged task retry config indexing

* some errors should never be retried

* finalize run service takes care of acks now

* execution payload helper now with single object arg

* internal error code enum export

* unify failed and crashed run retries

* Prevent uncaught socket ack exceptions (#1415)

* catch all the remaining socket acks that could possibly throw

* wrap the remaining handlers in try catch

* New onboarding question (#1404)

* Updated “Twitter” to be “X (Twitter)”

* added Textarea to storybook

* Updated textarea styling to match input field

* WIP adding new text field to org creation page

* Added description to field

* Submit feedback to Plain when an org signs up

* Formatting improvement

* type improvement

* removed userId

* Moved submitting to Plain into its own file

* Change orgName with name

* use sendToPlain function for the help & feedback email form

* use name not orgName

* import cleanup

* Downgrading plan form uses sendToPlain

* Get the userId from requireUser only

* Added whitespace-pre-wrap to the message property on the run page

* use requireUserId

* Removed old Plain submit code

* Added a new Context page for the docs (#1416)

* Added a new context page with task context properties

* Removed code comments

* Added more crosslinks

* Fix updating many environment variables at once (#1413)

* Move code example to the side menu

* New docs example for creating a HN email summary

* doc: add instructions to create new reference project and run it locally (#1417)

* doc: add instructions to create new reference project and run it locally

* doc: Add instruction for running tunnel

* minor language improvement

* Fix several restore and resume bugs (#1418)

* try to correct resume messages with missing checkpoint

* prevent creating checkpoints for outdated task waits

* prevent creating checkpoints for outdated batch waits

* use heartbeats to check for and clean up any leftover containers

* lint

* improve exec logging

* improve resume attempt logs

* fix for resuming parents of canceled child runs

* separate SIGTERM from maybe OOM errors

* pretty errors can have magic dashboard links

* prevent uncancellable checkpoints

* simplify task run error code enum export

* grab the last, not the first child run

* Revert "prevent creating checkpoints for outdated batch waits"

This reverts commit f2b5c2ac42.

* Revert "grab the last, not the first child run"

This reverts commit 89ec5c8bfd.

* Revert "prevent creating checkpoints for outdated task waits"

This reverts commit 11066b4e74.

* more logs for resume message handling

* add magic error link comment

* add changeset

* chore: Update version for release (#1410)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Release 3.0.13

* capture ffmpeg oom errors

* respect maxAttempts=1 when failing before first attempt creation

* request worker exit on fatal errors

* fix error code merge

* add new error code to should retry

* pretty segfault errors

* pretty internal errors for attempt spans

* decrease oom false positives

* fix timeline event color for failed runs

* auto-retry packet import and export

* add sdk version check and complete event while completing attempt

* all internal errors become crashes by default

* use pretty error helpers exclusively

* error to debug log

* zodfetch fixes

* rename import payload to task input error

* fix true non-zero exit error display

* fix retry config parsing

* correctly mark crashes as crashed

* add changeset

* remove non-zero exit comment

* pretend we don't support default default retry configs yet

---------

Co-authored-by: James Ritchie <james@trigger.dev>
Co-authored-by: shubham yadav <126192924+yadavshubham01@users.noreply.github.com>
Co-authored-by: Tarun Pratap Singh <101409098+Wackyator@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-24 11:12:24 +01:00
shubham yadav 9a5e6e58be Fix: Handle circular references in flattenAttributes function (#1433)
* Fix: Handle circular references in flattenAttributes function

* Update flattenAttributes.test.ts

* Update packages/core/src/v3/utils/flattenAttributes.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update packages/core/src/v3/utils/flattenAttributes.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Create brown-laws-rest.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <eric@trigger.dev>
2024-10-24 10:44:04 +01:00
Eric Allam c31700ae5f Fix issue with lost built trigger.dev files after prisma generate (#1429)
* Fix issue with lost built trigger.dev files after prisma generate on deploy

* Add changeset
2024-10-24 10:43:26 +01:00
Eric Allam cfb96859b3 fix: removing schedule instance no longer crashes (#1430) 2024-10-24 10:41:49 +01:00
James Ritchie 9bc641d15e An improved 404 page design (#1427)
* WIP improving the 404 page

* Improved 404 page layout

* Added a shortcut key

* Removed old assets and code
2024-10-24 10:37:24 +01:00
Matt Aitken a79075908e Unflattening array fix for very large arrays (#1432)
* fix unflattening of array indices

* Get tests passing

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: Eric Allam <eallam@icloud.com>
2024-10-23 20:58:02 +01:00
Eric Allam 7f9091f205 fix metadata system not updating because of dual package hazard (#1428) 2024-10-23 14:02:13 +01:00
James Ritchie 9f6887b048 Embed the hacker news youtube video instead 2024-10-23 13:13:18 +01:00
Dan 982906cbad Vercel sync env vars extension (#1425)
* Added vercelSyncEnvVars extension

* Updated extension and added try catch

* VercelEnvironment fix

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Added changeset

* Moved vercelSynvEnvVars to code and updated core.ts

* Fixed import

* removed type

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-10-23 13:01:30 +01:00
James Ritchie 90514a73bb Added video to hacker news example docs 2024-10-23 12:21:14 +01:00
Eric Allam 2d63c5db50 Set the api rate limiter redis config 2024-10-22 22:22:13 +01:00
Eric Allam 768036a223 Revert the changes to the provider tsconfigs 2024-10-21 15:19:10 +01:00
Eric Allam 2d8a41b18b feat: realtime (#1402)
* Denormalize run tags, increase character limit to 128

* WIP realtime subscribing to runs

* extracted the stream stuff into core, made it more reusable

* WIP tags

* Remove tags for now because it’s not support in electric

* Support async iterables, readable stream, and callback style subscription styles

* Remove tags streaming endpoint

* Add realtime rate limits and scope them to the /realtime path

* WIP rate limt per org

* Introduce per org rate limits

* WIP JWT auth

* Move migrations into new internal db package

* Resolve pnpm lock file

* Authenticating to the realtime API with JWTs are working

* realtime in the client

* Created react-hooks package and starting to move stuff in there

* Improve types for hooks

* schema tasks

* Added useBatch hook

* build uploadthing/fal demo and change how run metadata is synced to the server

* tweaks

* WIL realtime concurrency tracking

* Implement test for realtime client using testcontainers

also updated electric to latest version

* Allow customizing the expiration time of the automatic JWT created after triggering a task

* Add support for subscribing to run tags

* Improve auth types and API

* finalize the realtime API

* Fixed some example stuff

* Allow up to 10 run tags

* Remove core from docker-provider tsconfig paths to prevent it from being typechecked

* do the same for the kubernetes provider

* Fixing some typecheck errors

* Fix webapp type errors

* Update @trigger.dev/platform to 1.0.13

* Fix attw error

* Remove from/to in subscribeToRuns query params

* Add tests for the rate limit middleware and add custom JWT rate limits

* turn off webapp test parallelism

* Finish renaming jwt -> publicAccessToken and automatically give the JWT read access to the tags when using trigger

* Add changeset

* Attempt to fix unit tests in CI

* Skip running the auth rate limit middleware tests for now

* Try a beefier machine

* Try and run webapp tests separately

* Setup env vars

* Make sliding window test more reliabile
2024-10-21 15:07:08 +01:00
nicktrn 67542a54e8 Release 3.0.13
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-10-18 17:26:03 +01:00
github-actions[bot] f1d89c413b chore: Update version for release (#1410)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-18 17:24:53 +01:00
nicktrn 90593adea5 Fix several restore and resume bugs (#1418)
* try to correct resume messages with missing checkpoint

* prevent creating checkpoints for outdated task waits

* prevent creating checkpoints for outdated batch waits

* use heartbeats to check for and clean up any leftover containers

* lint

* improve exec logging

* improve resume attempt logs

* fix for resuming parents of canceled child runs

* separate SIGTERM from maybe OOM errors

* pretty errors can have magic dashboard links

* prevent uncancellable checkpoints

* simplify task run error code enum export

* grab the last, not the first child run

* Revert "prevent creating checkpoints for outdated batch waits"

This reverts commit f2b5c2ac42.

* Revert "grab the last, not the first child run"

This reverts commit 89ec5c8bfd.

* Revert "prevent creating checkpoints for outdated task waits"

This reverts commit 11066b4e74.

* more logs for resume message handling

* add magic error link comment

* add changeset
2024-10-18 16:46:38 +01:00
Tarun Pratap Singh 235ab90c3d doc: add instructions to create new reference project and run it locally (#1417)
* doc: add instructions to create new reference project and run it locally

* doc: Add instruction for running tunnel

* minor language improvement
2024-10-18 16:03:18 +01:00
James Ritchie ef7f112227 New docs example for creating a HN email summary 2024-10-17 13:56:34 +01:00
James Ritchie db70faa4c7 Move code example to the side menu 2024-10-17 13:56:34 +01:00
shubham yadav 1d87b5ea43 Fix updating many environment variables at once (#1413) 2024-10-17 12:24:50 +01:00
James Ritchie d3f10941fd Added a new Context page for the docs (#1416)
* Added a new context page with task context properties

* Removed code comments

* Added more crosslinks
2024-10-17 11:17:17 +01:00
James Ritchie 6dc556b704 New onboarding question (#1404)
* Updated “Twitter” to be “X (Twitter)”

* added Textarea to storybook

* Updated textarea styling to match input field

* WIP adding new text field to org creation page

* Added description to field

* Submit feedback to Plain when an org signs up

* Formatting improvement

* type improvement

* removed userId

* Moved submitting to Plain into its own file

* Change orgName with name

* use sendToPlain function for the help & feedback email form

* use name not orgName

* import cleanup

* Downgrading plan form uses sendToPlain

* Get the userId from requireUser only

* Added whitespace-pre-wrap to the message property on the run page

* use requireUserId

* Removed old Plain submit code
2024-10-17 10:54:38 +01:00
nicktrn b6d1e0d868 Prevent uncaught socket ack exceptions (#1415)
* catch all the remaining socket acks that could possibly throw

* wrap the remaining handlers in try catch
2024-10-16 22:42:27 +01:00
D-K-P f2c5243905 Copy updates 2024-10-16 17:16:52 +01:00
D-K-P 803350ae6e Updated supabase docs 2024-10-16 15:23:48 +01:00
James Ritchie b225423ce0 docs typo 2024-10-16 15:09:18 +01:00
Niels e36e3d54f2 Make ephemeral storage defaults configurable via env vars (#1407)
* Added support for the env vars `POD_EPHEMERAL_STORAGE_SIZE_LIMIT` and `POD_EPHEMERAL_STORAGE_SIZE_REQUEST`

* Reverted pnpm lockfile

* Made sure there is no diff between the main lockfile & PR's lockfile

* Not touching the storage for the index containers

* Not touching the storage for the prePull container
2024-10-16 13:26:48 +01:00
James Ritchie be7790bbdf Added log size info to the limits page 2024-10-15 16:58:36 +01:00
nicktrn feb4fcdac6 Fix bun runtime by downgrading to 1.1.24 (#1409)
* pin bun to 1.1.24 and use slim image

* add changeset

* add warning about bun version to docs
2024-10-14 18:00:10 +01:00
Dan a6de3f0ff3 README updates (#1408)
* Main README edits

* SDK readme edits

* CLI readme edits

* Create cool-keys-sit.md

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2024-10-14 16:39:19 +01:00
Matt Aitken 1c80a74885 Added a note to scheduled task docs about the delay option. 2024-10-11 18:58:39 +01:00
Dan 4c6f27e978 Updated sharp docs (#1403)
* Added prerequisites to ffmpeg and puppeteer

* Improved the sharp docs

* Updated sharp task code
2024-10-11 17:11:07 +01:00
nicktrn 367d7bdb4c Add more metadata to checkpoint creation logs 2024-10-11 15:55:31 +01:00
James Ritchie d998a2064e New runs fundamentals page (#1401)
* WIP runs guide

* WIP updating the runs and attempts page

* Added more diagrams to Runs page

* Fixed broken anchors

* More updates and diagram improvements

* Improved diagrams and copy

* More diagram improvements

* copy tweak

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2024-10-11 14:28:01 +01:00
James Ritchie ba96397f53 Updated the limits page 2024-10-10 15:21:53 +01:00
James Ritchie 244988e212 New Help & Feedback menu and tab-selected styles (#1374)
* Added a new dropdown help and feedback menu to the side menu

* Added a shortcut to the popover menu

* Removed dev cli connected button for now

* Contact us form uses original Feedback component to prevent broken links

* Improved the messaging when selecting different options in the email form

* buttons style tweak

* SideMenuItem supports the trailingIconClassName

* Adding a consistent focus-visible states

* Removing tooltips for now

* Squashed commit of the following:

commit 7d11123c0a
Author: Eric Goldman <eric@sequin.io>
Date:   Mon Sep 30 17:54:06 2024 -0700

    Add sequin guide (#1368)

    Co-authored-by: James Ritchie <james@trigger.dev>

commit 8da495ac00
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Mon Sep 30 13:42:22 2024 +0100

    Improve checkpoint reliability and cleanup of temp files (#1367)

    * improve cleanup reliability

    * improve logging

    * bye-bye execa

    * fix for trailing newlines

    * prettier errors

    * trim args and log output by default

    * fix archive cleanup

    * prevent potential memleak

    * more cleanup debug logs

    * ignore abort during cleanup

    * rename checkpoint dir env var and move to helper

    * add global never throw override

    * add tmp cleaner

    * also clean up checkpoint dir by default

    * split by any whitespace, not just tabs

    * only create tmp cleaner if paths to clean

commit 69ec68ee31
Author: Eric Allam <eallam@icloud.com>
Date:   Sun Sep 29 19:18:39 2024 -0700

    Release 3.0.9

commit a6ea8444c9
Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Date:   Sun Sep 29 19:17:26 2024 -0700

    chore: Update version for release (#1366)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

commit 4c1ee3d6ea
Author: Eric Allam <eallam@icloud.com>
Date:   Sun Sep 29 19:09:38 2024 -0700

    fix: run metadata not working when using npx/pnpm dlx

* More support for custom-focus

* More custom focus styles added

* Support for focus-visible style for the Segmented control

* Fixed table triple dot menu z-index issue

* Improved help menu wording

* When you submit the help form, close the modal

* focus-visible style for radio buttons

* button prop is now optional in the SideMenu component

* focus styling for a text link

* Deleted unused sequin files
2024-10-10 14:28:18 +01:00
Eric Allam b1319fb88c Update limits.mdx 2024-10-10 14:19:40 +01:00
James Ritchie cff2ebba49 Change plan feedback modal (#1393)
* Checkbox component can have its label styles

* Improved the dialog footer

* Handle sending feedback to Slack using Plain

* WIP making the modal conditional

* Moved the Plain form action into the select plan file

* removed comment

* Show a confirmation diaglog if you’re downgrading from Pro to Hobby

* Downgrading to Hobby works

* Use redirectWithErrorMessage instead of throw error

* The cancel form now submits the data correctly

* Modals don’t trigger when you upgrade

* Copy improvements

* Added a tooltip to explain the link to the pricing page

* Squashed commit of the following:

commit 886429b0ed
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:36:46 2024 +0100

    Removed emails, @trigger.dev/database and @trigger.dev/otlp-importer from changesets config

commit f65157a14f
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:31:27 2024 +0100

    Lockfile with run-engine removed

commit 3d67bb81e9
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:24:31 2024 +0100

    Removed run-engine from the webapp package.json/tsconfig

commit d30e9713f4
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:06:04 2024 +0100

    Dockerfile fix because the database package has been moved

commit f2babbf637
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 09:41:22 2024 -0700

    Internal packages (testcontainers, redis-worker and zod-worker) (#1392)

    * Some notes on the new run engine

    * lockfile with setup for the run engine

    * Documenting where TaskRun is currently mutated, to try figure out the shape of the new system

    * Added notes about how triggering currently works

    * Details about when triggering happens

    * Lots of notes about waitpoints

    * Started scaffolding the RunEngine

    * Sketch of Prisma waitpoint schema while it’s fresh in my mind

    * Got Prisma working with testcontainers

    * Use beforeEach/afterEach

    * Simple Prisma and Redis test

    * Return Redis options instead of a client

    * Simplified things

    * A very simple FIFO pull-based queue to check the tests working properly

    * Use vitest extend

    * Separate redis, postgres and combined tests for faster testing

    * Some fixes and test improvements

    * Pass a logger into the queue

    * A queue processor that processes items from the given queue as fast as it can

    * Test for retrying an item that wasn’t processed

    * First draft of waitpoints in the Prisma schema

    * Remove the custom logger from the test

    * Added a completedAt to Waitpoint

    * Notes on the flow for an execution starting

    * Added redlock, moved some files around

    * Starting point for the TaskRunExecutionSnapshot table

    * Added relationships to TaskRunExecutionSnapshot

    * Change some tsconfig

    * Moved some things around

    * Added some packages

    * WIP on the RunQueue

    * Fix for some imports

    * Key producer with some tests

    * Removed the nv type from the keys… it’s not useful to do global queries

    * Passing unit tests for all the public key producer functions

    * Some basic tests passing for the RunQueue

    * Simple enqueue test working

    * Enqueue and dequeue for dev is working

    * Don’t log everything during the tests

    * Enqueuing/dequeuing from the shared queue is working

    * Tests for getting a shared queue

    * The key producer sharedQueue can now be named, to allow multiple separate queues

    * The key producer uses the name of the queue as the input

    * Extra info in the Prisma schema

    * Dequeuing a message gets the payload and sets the task concurrency all in one Lua script

    * Adding more keys so we can read the concurrency from the queue

    * Setting the concurrency with dequeue and enquque is working

    * Improved the tests and fixed some bugs

    * Acking is resetting the concurrencies

    * Check the key has been removed after acking

    * Nacking is working

    * Changed the package to CommonJS + Node10 so it works with Redlock

    * Moved the database, otel and emails packages to be in internal-packages

    * Moved some Prisma code to the database package

    * Started using the RunEngine for triggering

    * Progress on run engine triggering, first waitpoint code

    * Create a delay waitpoint

    * Moved ZodWorker to an internal package so it can be used in the run engine as well as the webapp

    * Web app now uses the zod worker package

    * Added parseNaturalLanguageDuration to core/apps

    * internal-packages/zod-worker in the lockfile

    * Pass in the master queue, remove old rebalance workers code

    * Add masterQueue to TaskRun

    * Fixed the tests

    * Moved waitpoint code into the run engine, also the zod worker

    * Completing waitpoints

    * An experiment to create a new test container with environment

    * More changes to triggering

    * Started testing triggering

    * Test for a run getting triggered and being enqueued

    * Removed dequeueMessageInEnv

    * Update dev queue tests to use the shared queue function

    * Schema changes for TaskRunExecutionSnapshot

    * First execution snapshot when the run is created. Dequeue run function added to the engine

    * Separate internal package for testcontainers so they can be used elsewhere

    * Remove the simple queue and testcontainers from the run-engine. They’re going to be separate

    * Fix for the wrong path to the Prisma schem,a

    * Added the testcontainers package to the run-engine

    * redis-worker package, just a copy of the simple queue for now

    * The queue now uses Lua to enqueue dequeue

    * The queue now has a catalog and an invisible period after dequeuing

    * Added a visibility timeout and acking, with tests

    * Added more Redis connection logging, deleted todos

    * Visibility timeouts are now defined on the catalog and can be overridden when enqueuing

    * Dequeue multiple items at once

    * Test for dequeuing multiple items

    * Export some types to be used elsewhere

    * Partial refactor of the processor

    * First stab at a worker with concurrency and NodeWorkers

    * Don’t have a default visibility timeout in the queue

    * Worker setup and processing items in a simple test

    * Process jobs in parallel with retrying

    * Get the attempt when dequeuing

    * Workers do exponential backoff

    * Moved todos

    * DLQ functionality

    * DLQ tests

    * Same cluster for all keys in the same queue

    * Added DLQ tests

    * Whitespace

    * Redis pubsub to redrive from the worker

    * Fixed database paths

    * Fix for path to zod-worker

    * Fixes for typecheck errors, mostly with TS versions and module resolution

    * Redlock required a patch

    * Moved the new DB migrations to the new database package folder

    * Remove the run-engine package

    * Remove the RunEngine prisma schema changes

    * Delete triggerTaskV2

    * Remove zodworker test script (no tests)

    * Update test-containers readme

    * Generate the client first

    * Use a specific version of the prisma package

    * Generate the prisma client before running the unit tests

commit fc609470c4
Author: Dan <8297864+D-K-P@users.noreply.github.com>
Date:   Tue Oct 8 14:36:03 2024 +0100

    Supabase database webhook example upgrade (#1386)

    * Added overview for guides and examples section and split them all out

    * New supabase guide wip

    * Updated images and improved docs

    * Trimmed the supabase prereqs

    * Supabase guide wip

    * more updates

    * Replaced old database webhook guide

    * Created one intro page and removed snippets

    * Updated guide sidebar titles

    * Code updates

    * More improvements

    * Updates and added images

    * Compressed image

    * Updated guides descriptions and edge function basic

    * Removed bold

    * Updated redirects

    * Fixed broken links

    * Updated intro

commit 07f82eae54
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 13:28:54 2024 +0100

    Release 3.0.11

commit 13ebfcc5b9
Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Date:   Tue Oct 8 13:24:38 2024 +0100

    chore: Update version for release (#1381)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

commit 2a04d17e1f
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 09:24:23 2024 +0100

    Simplify showLogs expression

commit 002ae4bc0d
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 09:22:29 2024 +0100

    Fix dotenv overrides for dev runs (#1388)

    * override dashboard dev env vars with local .env

    * add changeset

    * add simple task for testing env vars

commit 047cb00417
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 09:22:05 2024 +0100

    Disable schedules for deleted orgs on next tick (#1383)

    * disable schedules for deleted orgs

    * add debug logs

commit 2c014f725a
Author: James Ritchie <james@trigger.dev>
Date:   Sun Oct 6 13:02:00 2024 -0700

    Override log retention (#1385)

    * set full log retention as admin

    * If run.logsDeletedAt is set, don’t bother getting the trace

commit a69e04f03a
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Sat Oct 5 14:18:58 2024 +0100

    Include push output in logs for self-hosted deploys (#1382)

    * include push output in logs

    * changeset

commit c5488df8ca
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Sat Oct 5 13:12:47 2024 +0100

    Fix CLI downgrade check (#1380)

    * fix downgrade detection

    * remove unused semver package from webapp

    * add changeset

commit 1caec27f72
Author: Eric Allam <eric@trigger.dev>
Date:   Fri Oct 4 15:33:35 2024 -0700

    docs: Max duration (#1379)

    * maxDuration docs

    * Update the init command to set the maxDuration and include a commented out maxDuration in the config file

commit e14c954e12
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Oct 4 15:02:05 2024 -0700

    Release 3.0.10

commit 8e61f5d631
Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Date:   Fri Oct 4 14:59:07 2024 -0700

    chore: Update version for release (#1378)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

commit 08db565a72
Author: Eric Allam <eallam@icloud.com>
Date:   Thu Oct 3 12:38:25 2024 -0700

    improve the timed out description

commit 6d0884254e
Author: Eric Allam <eric@trigger.dev>
Date:   Thu Oct 3 12:43:26 2024 -0700

    feat: Add maxDuration to tasks (#1377)

    * WIP

    * Get max duration working on deployed runs

    * Actually set the timed out runs to status = TIMED_OUT

    * The client status for TIMED_OUT is now MAX_DURATION_EXCEEDED

    * New TimedOutIcon

    * Added new timedout icon

    * Add ability to opt-out of maxDuration with timeout.None

    * MAX_DURATION_EXCEEDED -> TIMED_OUT

    * changeset

    * Improved styling for the status tooltip content

    ---------

    Co-authored-by: James Ritchie <james@trigger.dev>

commit 665ccf88b7
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Thu Oct 3 12:33:18 2024 +0100

    Update github actions and self-hosting docs

commit 1ff7b861ca
Author: Eric Allam <eric@trigger.dev>
Date:   Wed Oct 2 18:26:36 2024 -0700

    Add max queue depth limits (#1376)

    * Add runs to an env queue, as well as the actual queue

    * Add queue size limit guard on triggering tasks

commit c531a9d986
Author: Eric Allam <eric@trigger.dev>
Date:   Wed Oct 2 15:30:39 2024 -0700

    fix: cleanup ttl expire run graphile jobs (#1373)

    * fix: remove ttl expire run graphile jobs when a run is started or completed

    * Update expireEnqueuedRun.server.ts

commit 0bf500fa95
Author: Matt Aitken <matt@mattaitken.com>
Date:   Wed Oct 2 15:30:16 2024 -0700

    Prioritize finishing waited runs (#1375)

    * If a tree node is missing, estimate the size as zero

    * Task to test prioritizing finishing existing runs after triggerAndWaits

    * When requeuing a run with a checkpoint, put it in the queue with the parent run time so it’s correctly prioritized

    * The same change but if there’s no checkpoint

* Squashed commit of the following:

commit 886429b0ed
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:36:46 2024 +0100

    Removed emails, @trigger.dev/database and @trigger.dev/otlp-importer from changesets config

commit f65157a14f
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:31:27 2024 +0100

    Lockfile with run-engine removed

commit 3d67bb81e9
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:24:31 2024 +0100

    Removed run-engine from the webapp package.json/tsconfig

commit d30e9713f4
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 18:06:04 2024 +0100

    Dockerfile fix because the database package has been moved

commit f2babbf637
Author: Matt Aitken <matt@mattaitken.com>
Date:   Tue Oct 8 09:41:22 2024 -0700

    Internal packages (testcontainers, redis-worker and zod-worker) (#1392)

    * Some notes on the new run engine

    * lockfile with setup for the run engine

    * Documenting where TaskRun is currently mutated, to try figure out the shape of the new system

    * Added notes about how triggering currently works

    * Details about when triggering happens

    * Lots of notes about waitpoints

    * Started scaffolding the RunEngine

    * Sketch of Prisma waitpoint schema while it’s fresh in my mind

    * Got Prisma working with testcontainers

    * Use beforeEach/afterEach

    * Simple Prisma and Redis test

    * Return Redis options instead of a client

    * Simplified things

    * A very simple FIFO pull-based queue to check the tests working properly

    * Use vitest extend

    * Separate redis, postgres and combined tests for faster testing

    * Some fixes and test improvements

    * Pass a logger into the queue

    * A queue processor that processes items from the given queue as fast as it can

    * Test for retrying an item that wasn’t processed

    * First draft of waitpoints in the Prisma schema

    * Remove the custom logger from the test

    * Added a completedAt to Waitpoint

    * Notes on the flow for an execution starting

    * Added redlock, moved some files around

    * Starting point for the TaskRunExecutionSnapshot table

    * Added relationships to TaskRunExecutionSnapshot

    * Change some tsconfig

    * Moved some things around

    * Added some packages

    * WIP on the RunQueue

    * Fix for some imports

    * Key producer with some tests

    * Removed the nv type from the keys… it’s not useful to do global queries

    * Passing unit tests for all the public key producer functions

    * Some basic tests passing for the RunQueue

    * Simple enqueue test working

    * Enqueue and dequeue for dev is working

    * Don’t log everything during the tests

    * Enqueuing/dequeuing from the shared queue is working

    * Tests for getting a shared queue

    * The key producer sharedQueue can now be named, to allow multiple separate queues

    * The key producer uses the name of the queue as the input

    * Extra info in the Prisma schema

    * Dequeuing a message gets the payload and sets the task concurrency all in one Lua script

    * Adding more keys so we can read the concurrency from the queue

    * Setting the concurrency with dequeue and enquque is working

    * Improved the tests and fixed some bugs

    * Acking is resetting the concurrencies

    * Check the key has been removed after acking

    * Nacking is working

    * Changed the package to CommonJS + Node10 so it works with Redlock

    * Moved the database, otel and emails packages to be in internal-packages

    * Moved some Prisma code to the database package

    * Started using the RunEngine for triggering

    * Progress on run engine triggering, first waitpoint code

    * Create a delay waitpoint

    * Moved ZodWorker to an internal package so it can be used in the run engine as well as the webapp

    * Web app now uses the zod worker package

    * Added parseNaturalLanguageDuration to core/apps

    * internal-packages/zod-worker in the lockfile

    * Pass in the master queue, remove old rebalance workers code

    * Add masterQueue to TaskRun

    * Fixed the tests

    * Moved waitpoint code into the run engine, also the zod worker

    * Completing waitpoints

    * An experiment to create a new test container with environment

    * More changes to triggering

    * Started testing triggering

    * Test for a run getting triggered and being enqueued

    * Removed dequeueMessageInEnv

    * Update dev queue tests to use the shared queue function

    * Schema changes for TaskRunExecutionSnapshot

    * First execution snapshot when the run is created. Dequeue run function added to the engine

    * Separate internal package for testcontainers so they can be used elsewhere

    * Remove the simple queue and testcontainers from the run-engine. They’re going to be separate

    * Fix for the wrong path to the Prisma schem,a

    * Added the testcontainers package to the run-engine

    * redis-worker package, just a copy of the simple queue for now

    * The queue now uses Lua to enqueue dequeue

    * The queue now has a catalog and an invisible period after dequeuing

    * Added a visibility timeout and acking, with tests

    * Added more Redis connection logging, deleted todos

    * Visibility timeouts are now defined on the catalog and can be overridden when enqueuing

    * Dequeue multiple items at once

    * Test for dequeuing multiple items

    * Export some types to be used elsewhere

    * Partial refactor of the processor

    * First stab at a worker with concurrency and NodeWorkers

    * Don’t have a default visibility timeout in the queue

    * Worker setup and processing items in a simple test

    * Process jobs in parallel with retrying

    * Get the attempt when dequeuing

    * Workers do exponential backoff

    * Moved todos

    * DLQ functionality

    * DLQ tests

    * Same cluster for all keys in the same queue

    * Added DLQ tests

    * Whitespace

    * Redis pubsub to redrive from the worker

    * Fixed database paths

    * Fix for path to zod-worker

    * Fixes for typecheck errors, mostly with TS versions and module resolution

    * Redlock required a patch

    * Moved the new DB migrations to the new database package folder

    * Remove the run-engine package

    * Remove the RunEngine prisma schema changes

    * Delete triggerTaskV2

    * Remove zodworker test script (no tests)

    * Update test-containers readme

    * Generate the client first

    * Use a specific version of the prisma package

    * Generate the prisma client before running the unit tests

commit fc609470c4
Author: Dan <8297864+D-K-P@users.noreply.github.com>
Date:   Tue Oct 8 14:36:03 2024 +0100

    Supabase database webhook example upgrade (#1386)

    * Added overview for guides and examples section and split them all out

    * New supabase guide wip

    * Updated images and improved docs

    * Trimmed the supabase prereqs

    * Supabase guide wip

    * more updates

    * Replaced old database webhook guide

    * Created one intro page and removed snippets

    * Updated guide sidebar titles

    * Code updates

    * More improvements

    * Updates and added images

    * Compressed image

    * Updated guides descriptions and edge function basic

    * Removed bold

    * Updated redirects

    * Fixed broken links

    * Updated intro

commit 07f82eae54
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 13:28:54 2024 +0100

    Release 3.0.11

commit 13ebfcc5b9
Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Date:   Tue Oct 8 13:24:38 2024 +0100

    chore: Update version for release (#1381)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

commit 2a04d17e1f
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 09:24:23 2024 +0100

    Simplify showLogs expression

commit 002ae4bc0d
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 09:22:29 2024 +0100

    Fix dotenv overrides for dev runs (#1388)

    * override dashboard dev env vars with local .env

    * add changeset

    * add simple task for testing env vars

commit 047cb00417
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Tue Oct 8 09:22:05 2024 +0100

    Disable schedules for deleted orgs on next tick (#1383)

    * disable schedules for deleted orgs

    * add debug logs

commit 2c014f725a
Author: James Ritchie <james@trigger.dev>
Date:   Sun Oct 6 13:02:00 2024 -0700

    Override log retention (#1385)

    * set full log retention as admin

    * If run.logsDeletedAt is set, don’t bother getting the trace

commit a69e04f03a
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Sat Oct 5 14:18:58 2024 +0100

    Include push output in logs for self-hosted deploys (#1382)

    * include push output in logs

    * changeset

commit c5488df8ca
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Sat Oct 5 13:12:47 2024 +0100

    Fix CLI downgrade check (#1380)

    * fix downgrade detection

    * remove unused semver package from webapp

    * add changeset

commit 1caec27f72
Author: Eric Allam <eric@trigger.dev>
Date:   Fri Oct 4 15:33:35 2024 -0700

    docs: Max duration (#1379)

    * maxDuration docs

    * Update the init command to set the maxDuration and include a commented out maxDuration in the config file

commit e14c954e12
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Oct 4 15:02:05 2024 -0700

    Release 3.0.10

commit 8e61f5d631
Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Date:   Fri Oct 4 14:59:07 2024 -0700

    chore: Update version for release (#1378)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

commit 08db565a72
Author: Eric Allam <eallam@icloud.com>
Date:   Thu Oct 3 12:38:25 2024 -0700

    improve the timed out description

commit 6d0884254e
Author: Eric Allam <eric@trigger.dev>
Date:   Thu Oct 3 12:43:26 2024 -0700

    feat: Add maxDuration to tasks (#1377)

    * WIP

    * Get max duration working on deployed runs

    * Actually set the timed out runs to status = TIMED_OUT

    * The client status for TIMED_OUT is now MAX_DURATION_EXCEEDED

    * New TimedOutIcon

    * Added new timedout icon

    * Add ability to opt-out of maxDuration with timeout.None

    * MAX_DURATION_EXCEEDED -> TIMED_OUT

    * changeset

    * Improved styling for the status tooltip content

    ---------

    Co-authored-by: James Ritchie <james@trigger.dev>

commit 665ccf88b7
Author: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date:   Thu Oct 3 12:33:18 2024 +0100

    Update github actions and self-hosting docs

commit 1ff7b861ca
Author: Eric Allam <eric@trigger.dev>
Date:   Wed Oct 2 18:26:36 2024 -0700

    Add max queue depth limits (#1376)

    * Add runs to an env queue, as well as the actual queue

    * Add queue size limit guard on triggering tasks

commit c531a9d986
Author: Eric Allam <eric@trigger.dev>
Date:   Wed Oct 2 15:30:39 2024 -0700

    fix: cleanup ttl expire run graphile jobs (#1373)

    * fix: remove ttl expire run graphile jobs when a run is started or completed

    * Update expireEnqueuedRun.server.ts

commit 0bf500fa95
Author: Matt Aitken <matt@mattaitken.com>
Date:   Wed Oct 2 15:30:16 2024 -0700

    Prioritize finishing waited runs (#1375)

    * If a tree node is missing, estimate the size as zero

    * Task to test prioritizing finishing existing runs after triggerAndWaits

    * When requeuing a run with a checkpoint, put it in the queue with the parent run time so it’s correctly prioritized

    * The same change but if there’s no checkpoint

* Revert "Squashed commit of the following:"

This reverts commit b837b5a558dc5187d89b09135276d16f6856f455.

* Removed console logs

* cleaned up conditionals

* Unlock free plan state

* Fixed subscribe button if you’re already github verified

* Simplified the downgrade reasons logic

* made periodEnd required

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-10-10 14:12:15 +01:00
Dan 25c7ff173f Updated guides intro and side menu (#1398)
* Updated intro and moved stripe webhooks into the webhooks section

* Added additional resources links to nextjs and remix

* Updated intro page

* Changed formatting to improve layout

* Removed Supabase card from frameworks
2024-10-10 12:55:03 +01:00
Dan a6dbf8433c Webhook guides (#1396)
* Added nextjs webhook guide

* Updated intro page

* Copy improvement

* Added repo links to both supabase guides

* Updated structure and added an overview page

* Updated guides and simplified example code

* Added webhook handler for pages and split the docs

* Updated links
2024-10-10 10:18:28 +01:00
306 changed files with 15785 additions and 3618 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
release:
name: 🦋 Changesets Release
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: ubuntu-latest
if: github.repository == 'triggerdotdev/trigger.dev'
outputs:
published: ${{ steps.changesets.outputs.published }}
+15 -3
View File
@@ -6,7 +6,7 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests"
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: buildjet-16vcpu-ubuntu-2204
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
@@ -30,5 +30,17 @@ jobs:
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🧪 Run Unit Tests
run: pnpm run test
- name: 🧪 Run Webapp Unit Tests
run: pnpm run test --filter webapp
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
SESSION_SECRET: "secret"
MAGIC_LINK_SECRET: "secret"
ENCRYPTION_KEY: "secret"
- name: 🧪 Run Package Unit Tests
run: pnpm run test --filter "@trigger.dev/*"
- name: 🧪 Run Internal Unit Tests
run: pnpm run test --filter "@internal/*"
+2 -1
View File
@@ -1,2 +1,3 @@
link-workspace-packages=false
public-hoist-pattern[]=*prisma*
public-hoist-pattern[]=*prisma*
prefer-workspace-packages=true
+2 -6
View File
@@ -1,8 +1,4 @@
{
"recommendations": [
"denoland.vscode-deno"
],
"unwantedRecommendations": [
]
"recommendations": ["bierner.comment-tagged-templates"],
"unwantedRecommendations": []
}
+21 -6
View File
@@ -15,13 +15,20 @@
## About Trigger.dev
Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs with no timeouts. Write normal async code, deploy, and never hit a timeout.
Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs. Write normal async code, deploy, and never hit a timeout.
#### Features:
### Key features:
- JavaScript and TypeScript SDK
- Write reliable code by default
- No timeouts
- Retries (with exponential backoff)
- Queues and concurrency controls
- Schedules and crons
- Full Observability; logs, live trace views, advanced filtering
- Custom alerts, get notified by email, Slack or webhooks
- No infrastructure to manage
- Elastic (scaling)
- Works with your existing tech stack
## In your codebase
@@ -45,7 +52,7 @@ export const helloWorld = task({
## Deployment
Use our SDK to write tasks in your codebase. There's no infrastructure to manage, your tasks automatically scale and connect to our cloud. Or you can always [self-host](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
Use our SDK to write tasks in your codebase. There's no infrastructure to manage, your tasks automatically scale and connect to our cloud. Or you can always self-host.
## Environments
@@ -59,11 +66,19 @@ View every task in every run so you can tell exactly what happened. We provide a
# Getting started
Visit our docs [here](https://trigger.dev/docs/v3/introduction) for a full guide on how to get started with Trigger.dev.
The quickest way to get started is to create an account and project in our [web app](https://cloud.trigger.dev), and follow the instructions in the onboarding. Build and deploy your first task in minutes.
## Self-host
### Useful links:
If you prefer to self-host, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
- [Quick start](https://trigger.dev/docs/quick-start) - get up and running in minutes
- [How it works](https://trigger.dev/docs/v3/how-it-works) - understand how Trigger.dev works under the hood
- [Guides and examples](https://trigger.dev/docs/guides/introduction) - walk-through guides and code examples for popular frameworks and use cases
## Self-hosting
If you prefer to self-host Trigger.dev, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
We also have a dedicated self-hosting channel in our [Discord server](https://trigger.dev/discord) for support.
## Development
+4 -1
View File
@@ -436,7 +436,10 @@ export class Checkpointer {
this.#logger.error("Error during cleanup", { ...metadata, error });
}
this.#abortControllers.delete(runId);
// Ensure only the current controller is removed
if (this.#abortControllers.get(runId) === controller) {
this.#abortControllers.delete(runId);
}
controller.signal.removeEventListener("abort", onAbort);
};
+12 -1
View File
@@ -64,7 +64,18 @@ export class Exec {
command,
argsRaw: args,
argsTrimmed,
...output,
globalOpts: {
trimArgs: this.trimArgs,
neverThrow: this.neverThrow,
hasAbortSignal: !!this.abortSignal,
},
localOpts: opts,
stdout: output.stdout,
stderr: output.stderr,
pid: result.pid,
exitCode: result.exitCode,
aborted: result.aborted,
killed: result.killed,
};
if (this.logOutput) {
+452 -336
View File
@@ -536,7 +536,11 @@ class TaskCoordinator {
socket.on("TEST", (message, callback) => {
logger.log("Handling TEST", { eventName: "TEST", ...getSocketMetadata(), ...message });
callback();
try {
callback();
} catch (error) {
logger.error("TEST error", { error });
}
});
// Deprecated: Only workers without support for lazy attempts use this
@@ -669,13 +673,25 @@ class TaskCoordinator {
log.log("Handling READY_FOR_RESUME");
updateAttemptFriendlyId(message.attemptFriendlyId);
try {
updateAttemptFriendlyId(message.attemptFriendlyId);
if (message.version === "v2") {
updateAttemptNumber(message.attemptNumber);
if (message.version === "v2") {
updateAttemptNumber(message.attemptNumber);
}
this.#platformSocket?.send("READY_FOR_RESUME", { ...message, version: "v1" });
} catch (error) {
log.error("READY_FOR_RESUME error", { error });
await crashRun({
name: "ReadyForResumeError",
message:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
return;
}
this.#platformSocket?.send("READY_FOR_RESUME", { ...message, version: "v1" });
});
// MARK: RUN COMPLETED
@@ -692,101 +708,113 @@ class TaskCoordinator {
log.log("Handling TASK_RUN_COMPLETED");
const { completion, execution } = message;
try {
const { completion, execution } = message;
// Cancel all in-progress checkpoints (if any)
this.#cancelCheckpoint(socket.data.runId);
// Cancel all in-progress checkpoints (if any)
this.#cancelCheckpoint(socket.data.runId);
await chaosMonkey.call({ throwErrors: false });
await chaosMonkey.call({ throwErrors: false });
const completeWithoutCheckpoint = (shouldExit: boolean) => {
const supportsRetryCheckpoints = message.version === "v1";
const completeWithoutCheckpoint = (shouldExit: boolean) => {
const supportsRetryCheckpoints = message.version === "v1";
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
version: supportsRetryCheckpoints ? "v1" : "v2",
execution,
completion,
});
callback({ willCheckpointAndRestore: false, shouldExit });
};
if (completion.ok) {
completeWithoutCheckpoint(true);
return;
}
if (
completion.error.type === "INTERNAL_ERROR" &&
completion.error.code === "TASK_RUN_CANCELLED"
) {
completeWithoutCheckpoint(true);
return;
}
if (completion.retry === undefined) {
completeWithoutCheckpoint(true);
return;
}
if (completion.retry.delay < this.#delayThresholdInMs) {
completeWithoutCheckpoint(false);
// Prevents runs that fail fast from never sending a heartbeat
this.#sendRunHeartbeat(socket.data.runId);
return;
}
if (message.version === "v2") {
completeWithoutCheckpoint(true);
return;
}
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
if (!willCheckpointAndRestore) {
completeWithoutCheckpoint(false);
return;
}
// The worker will then put itself in a checkpointable state
callback({ willCheckpointAndRestore: true, shouldExit: false });
const ready = await readyToCheckpoint("RETRY");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
return;
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
shouldHeartbeat: true,
});
if (!checkpoint) {
log.error("Failed to checkpoint");
completeWithoutCheckpoint(false);
return;
}
log.addFields({ checkpoint });
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
version: supportsRetryCheckpoints ? "v1" : "v2",
version: "v1",
execution,
completion,
checkpoint,
});
callback({ willCheckpointAndRestore: false, shouldExit });
};
if (completion.ok) {
completeWithoutCheckpoint(true);
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
} catch (error) {
log.error("TASK_RUN_COMPLETED error", { error });
if (
completion.error.type === "INTERNAL_ERROR" &&
completion.error.code === "TASK_RUN_CANCELLED"
) {
completeWithoutCheckpoint(true);
return;
}
if (completion.retry === undefined) {
completeWithoutCheckpoint(true);
return;
}
if (completion.retry.delay < this.#delayThresholdInMs) {
completeWithoutCheckpoint(false);
// Prevents runs that fail fast from never sending a heartbeat
this.#sendRunHeartbeat(socket.data.runId);
await crashRun({
name: "TaskRunCompletedError",
message:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
return;
}
if (message.version === "v2") {
completeWithoutCheckpoint(true);
return;
}
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
if (!willCheckpointAndRestore) {
completeWithoutCheckpoint(false);
return;
}
// The worker will then put itself in a checkpointable state
callback({ willCheckpointAndRestore: true, shouldExit: false });
const ready = await readyToCheckpoint("RETRY");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
return;
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
shouldHeartbeat: true,
});
if (!checkpoint) {
log.error("Failed to checkpoint");
completeWithoutCheckpoint(false);
return;
}
log.addFields({ checkpoint });
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
version: "v1",
execution,
completion,
checkpoint,
});
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
});
// MARK: TASK FAILED
@@ -802,15 +830,21 @@ class TaskCoordinator {
log.log("Handling TASK_RUN_FAILED_TO_RUN");
// Cancel all in-progress checkpoints (if any)
this.#cancelCheckpoint(socket.data.runId);
try {
// Cancel all in-progress checkpoints (if any)
this.#cancelCheckpoint(socket.data.runId);
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
version: "v1",
completion,
});
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
version: "v1",
completion,
});
exitRun();
exitRun();
} catch (error) {
log.error("TASK_RUN_FAILED_TO_RUN error", { error });
return;
}
});
// MARK: CHECKPOINT
@@ -823,14 +857,20 @@ class TaskCoordinator {
log.log("Handling READY_FOR_CHECKPOINT");
const checkpointable = this.#checkpointableTasks.get(socket.data.runId);
try {
const checkpointable = this.#checkpointableTasks.get(socket.data.runId);
if (!checkpointable) {
log.error("No checkpoint scheduled");
return;
}
checkpointable.resolve();
} catch (error) {
log.error("READY_FOR_CHECKPOINT error", { error });
if (!checkpointable) {
log.error("No checkpoint scheduled");
return;
}
checkpointable.resolve();
});
// MARK: CXX CHECKPOINT
@@ -843,15 +883,19 @@ class TaskCoordinator {
log.log("Handling CANCEL_CHECKPOINT");
if (message.version === "v1") {
this.#cancelCheckpoint(socket.data.runId);
// v1 has no callback
return;
try {
if (message.version === "v1") {
this.#cancelCheckpoint(socket.data.runId);
// v1 has no callback
return;
}
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId);
callback({ version: "v2", checkpointCanceled });
} catch (error) {
log.error("CANCEL_CHECKPOINT error", { error });
}
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId);
callback({ version: "v2", checkpointCanceled });
});
// MARK: DURATION WAIT
@@ -864,66 +908,79 @@ class TaskCoordinator {
log.log("Handling WAIT_FOR_DURATION");
await chaosMonkey.call({ throwErrors: false });
try {
await chaosMonkey.call({ throwErrors: false });
if (checkpointInProgress()) {
log.error("Checkpoint already in progress");
callback({ willCheckpointAndRestore: false });
return;
}
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
callback({ willCheckpointAndRestore });
if (!willCheckpointAndRestore) {
return;
}
const ready = await readyToCheckpoint("WAIT_FOR_DURATION");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
return;
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
if (!checkpoint) {
// The task container will keep running until the wait duration has elapsed
log.error("Failed to checkpoint");
return;
}
log.addFields({ checkpoint });
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
runId: socket.data.runId,
attemptFriendlyId: message.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_DURATION",
ms: message.ms,
now: message.now,
},
});
if (ack?.keepRunAlive) {
log.log("keeping run alive after duration checkpoint");
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
} catch (error) {
log.error("WAIT_FOR_DURATION error", { error });
await crashRun({
name: "WaitForDurationError",
message:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
if (checkpointInProgress()) {
log.error("Checkpoint already in progress");
callback({ willCheckpointAndRestore: false });
return;
}
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
callback({ willCheckpointAndRestore });
if (!willCheckpointAndRestore) {
return;
}
const ready = await readyToCheckpoint("WAIT_FOR_DURATION");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
return;
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
if (!checkpoint) {
// The task container will keep running until the wait duration has elapsed
log.error("Failed to checkpoint");
return;
}
log.addFields({ checkpoint });
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
attemptFriendlyId: message.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_DURATION",
ms: message.ms,
now: message.now,
},
});
if (ack?.keepRunAlive) {
log.log("keeping run alive after duration checkpoint");
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
});
// MARK: TASK WAIT
@@ -936,74 +993,87 @@ class TaskCoordinator {
log.log("Handling WAIT_FOR_TASK");
await chaosMonkey.call({ throwErrors: false });
try {
await chaosMonkey.call({ throwErrors: false });
if (checkpointInProgress()) {
log.error("Checkpoint already in progress");
callback({ willCheckpointAndRestore: false });
return;
}
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
callback({ willCheckpointAndRestore });
if (!willCheckpointAndRestore) {
return;
}
// Workers with v1 schemas don't signal when they're ready to checkpoint for dependency waits
if (message.version === "v2") {
const ready = await readyToCheckpoint("WAIT_FOR_TASK");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
if (checkpointInProgress()) {
log.error("Checkpoint already in progress");
callback({ willCheckpointAndRestore: false });
return;
}
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
callback({ willCheckpointAndRestore });
if (!willCheckpointAndRestore) {
return;
}
// Workers with v1 schemas don't signal when they're ready to checkpoint for dependency waits
if (message.version === "v2") {
const ready = await readyToCheckpoint("WAIT_FOR_TASK");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
return;
}
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
if (!checkpoint) {
log.error("Failed to checkpoint");
return;
}
log.addFields({ checkpoint });
log.log("WAIT_FOR_TASK checkpoint created");
//setting this means we can only resume from a checkpoint
socket.data.requiresCheckpointResumeWithMessage = `location:${checkpoint.location}-docker:${checkpoint.docker}`;
log.log("WAIT_FOR_TASK set requiresCheckpointResumeWithMessage");
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
runId: socket.data.runId,
attemptFriendlyId: message.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_TASK",
friendlyId: message.friendlyId,
},
});
if (ack?.keepRunAlive) {
socket.data.requiresCheckpointResumeWithMessage = undefined;
log.log("keeping run alive after task checkpoint");
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
} catch (error) {
log.error("WAIT_FOR_TASK error", { error });
await crashRun({
name: "WaitForTaskError",
message:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
if (!checkpoint) {
log.error("Failed to checkpoint");
return;
}
log.addFields({ checkpoint });
log.log("WAIT_FOR_TASK checkpoint created");
//setting this means we can only resume from a checkpoint
socket.data.requiresCheckpointResumeWithMessage = `location:${checkpoint.location}-docker:${checkpoint.docker}`;
log.log("WAIT_FOR_TASK set requiresCheckpointResumeWithMessage");
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
attemptFriendlyId: message.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_TASK",
friendlyId: message.friendlyId,
},
});
if (ack?.keepRunAlive) {
socket.data.requiresCheckpointResumeWithMessage = undefined;
log.log("keeping run alive after task checkpoint");
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
});
// MARK: BATCH WAIT
@@ -1016,75 +1086,88 @@ class TaskCoordinator {
log.log("Handling WAIT_FOR_BATCH", message);
await chaosMonkey.call({ throwErrors: false });
try {
await chaosMonkey.call({ throwErrors: false });
if (checkpointInProgress()) {
log.error("Checkpoint already in progress");
callback({ willCheckpointAndRestore: false });
return;
}
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
callback({ willCheckpointAndRestore });
if (!willCheckpointAndRestore) {
return;
}
// Workers with v1 schemas don't signal when they're ready to checkpoint for dependency waits
if (message.version === "v2") {
const ready = await readyToCheckpoint("WAIT_FOR_BATCH");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
if (checkpointInProgress()) {
log.error("Checkpoint already in progress");
callback({ willCheckpointAndRestore: false });
return;
}
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
const { canCheckpoint, willSimulate } = await this.#checkpointer.init();
const willCheckpointAndRestore = canCheckpoint || willSimulate;
callback({ willCheckpointAndRestore });
if (!willCheckpointAndRestore) {
return;
}
// Workers with v1 schemas don't signal when they're ready to checkpoint for dependency waits
if (message.version === "v2") {
const ready = await readyToCheckpoint("WAIT_FOR_BATCH");
if (!ready.success) {
log.error("Failed to become checkpointable", { reason: ready.reason });
return;
}
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
if (!checkpoint) {
log.error("Failed to checkpoint");
return;
}
log.addFields({ checkpoint });
log.log("WAIT_FOR_BATCH checkpoint created");
//setting this means we can only resume from a checkpoint
socket.data.requiresCheckpointResumeWithMessage = `location:${checkpoint.location}-docker:${checkpoint.docker}`;
log.log("WAIT_FOR_BATCH set checkpoint");
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
runId: socket.data.runId,
attemptFriendlyId: message.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_BATCH",
batchFriendlyId: message.batchFriendlyId,
runFriendlyIds: message.runFriendlyIds,
},
});
if (ack?.keepRunAlive) {
socket.data.requiresCheckpointResumeWithMessage = undefined;
log.log("keeping run alive after batch checkpoint");
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
} catch (error) {
log.error("WAIT_FOR_BATCH error", { error });
await crashRun({
name: "WaitForBatchError",
message:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
if (!checkpoint) {
log.error("Failed to checkpoint");
return;
}
log.addFields({ checkpoint });
log.log("WAIT_FOR_BATCH checkpoint created");
//setting this means we can only resume from a checkpoint
socket.data.requiresCheckpointResumeWithMessage = `location:${checkpoint.location}-docker:${checkpoint.docker}`;
log.log("WAIT_FOR_BATCH set checkpoint");
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
attemptFriendlyId: message.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_BATCH",
batchFriendlyId: message.batchFriendlyId,
runFriendlyIds: message.runFriendlyIds,
},
});
if (ack?.keepRunAlive) {
socket.data.requiresCheckpointResumeWithMessage = undefined;
log.log("keeping run alive after batch checkpoint");
return;
}
if (!checkpoint.docker || !willSimulate) {
exitRun();
}
});
// MARK: INDEX
@@ -1097,24 +1180,29 @@ class TaskCoordinator {
log.log("Handling INDEX_TASKS");
const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", {
version: "v2",
projectRef: socket.data.projectRef,
envId: socket.data.envId,
deploymentId: message.deploymentId,
metadata: {
contentHash: socket.data.contentHash,
packageVersion: message.packageVersion,
tasks: message.tasks,
},
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
});
try {
const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", {
version: "v2",
projectRef: socket.data.projectRef,
envId: socket.data.envId,
deploymentId: message.deploymentId,
metadata: {
contentHash: socket.data.contentHash,
packageVersion: message.packageVersion,
tasks: message.tasks,
},
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
});
if (!workerAck) {
log.debug("no worker ack while indexing");
if (!workerAck) {
log.debug("no worker ack while indexing");
}
callback({ success: !!workerAck?.success });
} catch (error) {
log.error("INDEX_TASKS error", { error });
callback({ success: false });
}
callback({ success: !!workerAck?.success });
});
// MARK: INDEX FAILED
@@ -1127,11 +1215,15 @@ class TaskCoordinator {
log.log("Handling INDEXING_FAILED");
this.#platformSocket?.send("INDEXING_FAILED", {
version: "v1",
deploymentId: message.deploymentId,
error: message.error,
});
try {
this.#platformSocket?.send("INDEXING_FAILED", {
version: "v1",
deploymentId: message.deploymentId,
error: message.error,
});
} catch (error) {
log.error("INDEXING_FAILED error", { error });
}
});
// MARK: CREATE ATTEMPT
@@ -1144,26 +1236,38 @@ class TaskCoordinator {
log.log("Handling CREATE_TASK_RUN_ATTEMPT");
await chaosMonkey.call({ throwErrors: false });
try {
await chaosMonkey.call({ throwErrors: false });
const createAttempt = await this.#platformSocket?.sendWithAck("CREATE_TASK_RUN_ATTEMPT", {
runId: message.runId,
envId: socket.data.envId,
});
const createAttempt = await this.#platformSocket?.sendWithAck(
"CREATE_TASK_RUN_ATTEMPT",
{
runId: message.runId,
envId: socket.data.envId,
}
);
if (!createAttempt?.success) {
log.debug("no ack while creating attempt", { reason: createAttempt?.reason });
callback({ success: false, reason: createAttempt?.reason });
return;
if (!createAttempt?.success) {
log.debug("no ack while creating attempt", { reason: createAttempt?.reason });
callback({ success: false, reason: createAttempt?.reason });
return;
}
updateAttemptFriendlyId(createAttempt.executionPayload.execution.attempt.id);
updateAttemptNumber(createAttempt.executionPayload.execution.attempt.number);
callback({
success: true,
executionPayload: createAttempt.executionPayload,
});
} catch (error) {
log.error("CREATE_TASK_RUN_ATTEMPT error", { error });
callback({
success: false,
reason:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
}
updateAttemptFriendlyId(createAttempt.executionPayload.execution.attempt.id);
updateAttemptNumber(createAttempt.executionPayload.execution.attempt.number);
callback({
success: true,
executionPayload: createAttempt.executionPayload,
});
});
socket.on("UNRECOVERABLE_ERROR", async (message) => {
@@ -1175,7 +1279,11 @@ class TaskCoordinator {
log.log("Handling UNRECOVERABLE_ERROR");
await crashRun(message.error);
try {
await crashRun(message.error);
} catch (error) {
log.error("UNRECOVERABLE_ERROR error", { error });
}
});
socket.on("SET_STATE", async (message) => {
@@ -1187,20 +1295,28 @@ class TaskCoordinator {
log.log("Handling SET_STATE");
if (message.attemptFriendlyId) {
updateAttemptFriendlyId(message.attemptFriendlyId);
}
try {
if (message.attemptFriendlyId) {
updateAttemptFriendlyId(message.attemptFriendlyId);
}
if (message.attemptNumber) {
updateAttemptNumber(message.attemptNumber);
if (message.attemptNumber) {
updateAttemptNumber(message.attemptNumber);
}
} catch (error) {
log.error("SET_STATE error", { error });
}
});
},
onDisconnect: async (socket, handler, sender, logger) => {
this.#platformSocket?.send("LOG", {
metadata: socket.data,
text: "disconnect",
});
try {
this.#platformSocket?.send("LOG", {
metadata: socket.data,
text: "disconnect",
});
} catch (error) {
logger.error("onDisconnect error", { error });
}
},
handlers: {
TASK_HEARTBEAT: async (message) => {
+6 -2
View File
@@ -34,6 +34,9 @@ const UPTIME_MAX_PENDING_RUNS = Number(process.env.UPTIME_MAX_PENDING_RUNS || "2
const UPTIME_MAX_PENDING_INDECES = Number(process.env.UPTIME_MAX_PENDING_INDECES || "10");
const UPTIME_MAX_PENDING_ERRORS = Number(process.env.UPTIME_MAX_PENDING_ERRORS || "10");
const POD_EPHEMERAL_STORAGE_SIZE_LIMIT = process.env.POD_EPHEMERAL_STORAGE_SIZE_LIMIT || "10Gi";
const POD_EPHEMERAL_STORAGE_SIZE_REQUEST = process.env.POD_EPHEMERAL_STORAGE_SIZE_REQUEST || "2Gi";
const logger = new SimpleLogger(`[${NODE_NAME}]`);
logger.log(`running in ${RUNTIME_ENV} mode`);
@@ -396,13 +399,13 @@ class KubernetesTaskOperations implements TaskOperations {
get #defaultResourceRequests(): ResourceQuantities {
return {
"ephemeral-storage": "2Gi",
"ephemeral-storage": POD_EPHEMERAL_STORAGE_SIZE_REQUEST,
};
}
get #defaultResourceLimits(): ResourceQuantities {
return {
"ephemeral-storage": "10Gi",
"ephemeral-storage": POD_EPHEMERAL_STORAGE_SIZE_LIMIT,
};
}
@@ -658,6 +661,7 @@ provider.listen();
const taskMonitor = new TaskMonitor({
runtimeEnv: RUNTIME_ENV,
namespace: KUBERNETES_NAMESPACE,
onIndexFailure: async (deploymentId, details) => {
logger.log("Indexing failed", { deploymentId, details });
+7 -6
View File
@@ -160,7 +160,10 @@ export class TaskMonitor {
let reason = rawReason || "Unknown error";
let logs = rawLogs || "";
let overrideCompletion = false;
/** This will only override existing task errors. It will not crash the run. */
let onlyOverrideExistingError = exitCode === EXIT_CODE_CHILD_NONZERO;
let errorCode: TaskRunInternalError["code"] = TaskRunErrorCodes.POD_UNKNOWN_ERROR;
switch (rawReason) {
@@ -185,10 +188,8 @@ export class TaskMonitor {
}
break;
case "OOMKilled":
overrideCompletion = true;
reason = `${
exitCode === EXIT_CODE_CHILD_NONZERO ? "Child process" : "Parent process"
} ran out of memory! Try choosing a machine preset with more memory for this task.`;
reason =
"[TaskMonitor] Your task ran out of memory. Try increasing the machine specs. If this doesn't fix it there might be a memory leak.";
errorCode = TaskRunErrorCodes.TASK_PROCESS_OOM_KILLED;
break;
default:
@@ -199,7 +200,7 @@ export class TaskMonitor {
exitCode,
reason,
logs,
overrideCompletion,
overrideCompletion: onlyOverrideExistingError,
errorCode,
} satisfies FailureDetails;
+2 -1
View File
@@ -4,7 +4,8 @@
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev"
"dev": "wrangler dev",
"dry-run:staging": "wrangler deploy --dry-run --outdir=dist --env staging"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
+2 -20
View File
@@ -17,12 +17,9 @@ export interface Env {
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!env.REWRITE_HOSTNAME) throw new Error("Missing REWRITE_HOSTNAME");
console.log("url", request.url);
if (!queueingIsEnabled(env)) {
console.log("Missing AWS credentials. Passing through to the origin.");
return redirectToOrigin(request, env);
return fetch(request);
}
const url = new URL(request.url);
@@ -42,25 +39,10 @@ export default {
}
//the same request but with the hostname (and port) changed
return redirectToOrigin(request, env);
return fetch(request);
},
};
function redirectToOrigin(request: Request, env: Env) {
const newUrl = new URL(request.url);
newUrl.hostname = env.REWRITE_HOSTNAME;
newUrl.port = env.REWRITE_PORT || newUrl.port;
const requestInit: RequestInit = {
method: request.method,
headers: request.headers,
body: request.body,
};
console.log("rewritten url", newUrl.toString());
return fetch(newUrl.toString(), requestInit);
}
function queueingIsEnabled(env: Env) {
return (
env.AWS_SQS_ACCESS_KEY_ID &&
+31 -8
View File
@@ -1,7 +1,10 @@
import { HomeIcon } from "@heroicons/react/20/solid";
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
import { LinkButton } from "./primitives/Buttons";
import { Header1, Header3 } from "./primitives/Headers";
import { motion } from "framer-motion";
import { friendlyErrorDisplay } from "~/utils/httpErrors";
import { LinkButton } from "./primitives/Buttons";
import { Header1 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
type ErrorDisplayOptions = {
button?: {
@@ -39,12 +42,32 @@ type DisplayOptionsProps = {
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
return (
<div className="p-4">
<Header1 className="mb-4 border-b border-charcoal-800 pb-4">{title}</Header1>
{message && <Header3>{message}</Header3>}
<LinkButton to={button ? button.to : "/"} variant="primary/medium" className="mt-8">
{button ? button.title : "Home"}
</LinkButton>
<div className="relative flex min-h-screen flex-col items-center justify-center bg-[#16181C]">
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
<Header1>{title}</Header1>
{message && <Paragraph>{message}</Paragraph>}
<LinkButton
to={button ? button.to : "/"}
shortcut={{ modifiers: ["meta"], key: "g" }}
variant="primary/medium"
LeadingIcon={HomeIcon}
>
{button ? button.title : "Go to homepage"}
</LinkButton>
</div>
<div className="pointer-events-none absolute bottom-4 right-4 z-10 h-[70px] w-[200px] bg-[rgb(24,26,30)]" />
<motion.div
className="pointer-events-none absolute inset-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
>
<iframe
src="https://my.spline.design/untitled-a6f70b5ebc46bdb2dcc0f21d5397e8ac/"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
style={{ border: "none" }}
/>
</motion.div>
</div>
);
}
+100 -158
View File
@@ -1,31 +1,23 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import {
CalendarDaysIcon,
ChevronRightIcon,
EnvelopeIcon,
LifebuoyIcon,
LightBulbIcon,
} from "@heroicons/react/24/solid";
import { InformationCircleIcon } from "@heroicons/react/20/solid";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { ActivityIcon } from "lucide-react";
import { type ReactNode, useState } from "react";
import { type ReactNode, useEffect, useState } from "react";
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { cn } from "~/utils/cn";
import { docsTroubleshootingPath } from "~/utils/pathBuilder";
import { Button, LinkButton } from "./primitives/Buttons";
import { Button } from "./primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
import { Fieldset } from "./primitives/Fieldset";
import { FormButtons } from "./primitives/FormButtons";
import { FormError } from "./primitives/FormError";
import { Header1 } from "./primitives/Headers";
import { Icon } from "./primitives/Icon";
import { InfoPanel } from "./primitives/InfoPanel";
import { InputGroup } from "./primitives/InputGroup";
import { Label } from "./primitives/Label";
import { Paragraph } from "./primitives/Paragraph";
import { Select, SelectItem } from "./primitives/Select";
import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet";
import { TextArea } from "./primitives/TextArea";
import { TextLink } from "./primitives/TextLink";
type FeedbackProps = {
button: ReactNode;
@@ -37,161 +29,111 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
const location = useLocation();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [type, setType] = useState<FeedbackType>(defaultValue);
const [form, { path, feedbackType, message }] = useForm({
id: "accept-invite",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onInput",
});
if (
open &&
navigation.formAction === "/resources/feedback" &&
form.error === undefined &&
form.errors.length === 0
) {
setOpen(false);
}
useEffect(() => {
if (
navigation.formAction === "/resources/feedback" &&
navigation.state === "loading" &&
form.error === undefined &&
form.errors.length === 0
) {
setOpen(false);
}
}, [navigation, form]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild={true}>{button}</SheetTrigger>
<SheetContent className="@container">
<SheetBody className="flex h-full flex-col justify-between">
<LinkBanner
title="Join our Discord community"
icon={<DiscordIcon className="size-9" />}
to="https://trigger.dev/discord"
className="hover:border-text-link"
>
<Paragraph>The quickest way to get answers from the Trigger.dev community.</Paragraph>
</LinkBanner>
<LinkBanner
title="Book a 15 min chat with the founders"
icon={<CalendarDaysIcon className="size-9 text-green-500" />}
to="https://cal.com/team/triggerdotdev/founders-call"
className="hover:border-green-500"
>
<Paragraph>Have a question or want to chat? Book a time to talk with us.</Paragraph>
</LinkBanner>
<LinkBanner
title="Suggest a feature"
icon={<LightBulbIcon className="size-9 text-sun-500" />}
to="https://feedback.trigger.dev/"
className="hover:border-sun-400"
>
<Paragraph>Have an idea for a new feature or improvement? Let us know!</Paragraph>
</LinkBanner>
<LinkBanner
title="Troubleshooting"
icon={<LifebuoyIcon className="size-9 text-rose-500" />}
>
<Paragraph>
If you're having trouble, check out our troubleshooting guide or the Trigger.dev
Status page.
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogContent>
<DialogHeader>Contact us</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={EnvelopeIcon} className="size-10 min-w-[2.5rem] text-blue-500" />
<Paragraph variant="base/bright">
How can we help? We read every message and will respond as quickly as we can.
</Paragraph>
<div className="flex flex-wrap gap-2">
<LinkButton
to={docsTroubleshootingPath("")}
variant="tertiary/medium"
LeadingIcon={BookOpenIcon}
>
Troubleshooting Docs
</LinkButton>
<LinkButton
to={"https://status.trigger.dev/"}
variant="tertiary/medium"
LeadingIcon={ActivityIcon}
>
Trigger.dev Status
</LinkButton>
</div>
</LinkBanner>
<LinkBanner
title="Send us an email"
icon={<EnvelopeIcon className="size-9 text-blue-500" />}
>
<Paragraph>We read every message and respond quickly.</Paragraph>
<Form method="post" action="/resources/feedback" {...form.props} className="w-full">
<Fieldset className="max-w-full gap-y-3">
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
<InputGroup className="max-w-full">
<Select
{...conform.select(feedbackType)}
variant="tertiary/medium"
defaultValue={defaultValue}
placeholder="Select type"
text={(value) => feedbackTypeLabel[value]}
dropdownIcon
</div>
<hr className="border-charcoal-800" />
<Form method="post" action="/resources/feedback" {...form.props} className="w-full">
<Fieldset className="max-w-full gap-y-3">
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
<InputGroup className="max-w-full">
{type === "feature" && (
<InfoPanel
icon={InformationCircleIcon}
iconClassName="text-blue-500"
panelClassName="w-full mb-2"
>
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
<SelectItem key={name} value={name}>
{title}
</SelectItem>
))}
</Select>
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label>Message</Label>
<TextArea {...conform.textarea(message)} />
<FormError id={message.errorId}>{message.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="flex w-full justify-end">
<FormButtons
className="m-0 w-max"
confirmButton={
<Button type="submit" variant="tertiary/medium">
Send message
</Button>
}
/>
</div>
</Fieldset>
</Form>
</LinkBanner>
</SheetBody>
</SheetContent>
</Sheet>
);
}
function LinkBanner({
className,
icon,
title,
children,
to,
}: {
className?: string;
icon?: ReactNode;
title?: string;
children?: ReactNode;
to?: string;
}) {
return (
<a
href={to}
target="_blank"
className={cn(
"group/banner mb-4 flex w-full items-center justify-between rounded-md border border-grid-bright bg-charcoal-750 p-4 transition",
className
)}
>
<div className="flex w-full items-start gap-4">
<span>{icon}</span>
<div className="flex w-full flex-col gap-2">
<Header1 className="text-2xl font-semibold text-text-bright">{title}</Header1>
{children}
<Paragraph variant="small">
All our feature requests are public and voted on by the community. The best
way to submit your feature request is to{" "}
<TextLink to="https://feedback.trigger.dev">
post it to our feedback forum
</TextLink>
.
</Paragraph>
</InfoPanel>
)}
{type === "help" && (
<InfoPanel
icon={InformationCircleIcon}
iconClassName="text-blue-500"
panelClassName="w-full mb-2"
>
<Paragraph variant="small">
The quickest way to get answers from the Trigger.dev team and community is to{" "}
<TextLink to="https://trigger.dev/discord">ask in our Discord</TextLink>.
</Paragraph>
</InfoPanel>
)}
<Select
{...conform.select(feedbackType)}
variant="tertiary/medium"
value={type}
defaultValue={type}
setValue={(v) => setType(v as FeedbackType)}
placeholder="Select type"
text={(value) => feedbackTypeLabel[value as FeedbackType]}
dropdownIcon
>
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
<SelectItem key={name} value={name}>
{title}
</SelectItem>
))}
</Select>
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label>Message</Label>
<TextArea {...conform.textarea(message)} />
<FormError id={message.errorId}>{message.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="flex w-full justify-end">
<FormButtons
className="m-0 w-max"
confirmButton={
<Button type="submit" variant="tertiary/medium">
Send message
</Button>
}
/>
</div>
</Fieldset>
</Form>
</div>
</div>
{to && (
<ChevronRightIcon className="size-5 text-charcoal-500 transition group-hover:translate-x-1 group-hover/banner:text-text-bright" />
)}
</a>
</DialogContent>
</Dialog>
);
}
@@ -27,7 +27,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
<Paragraph className="text-2sm text-text-bright">Free Plan</Paragraph>
</div>
<Link to={to} className="text-2sm text-text-link">
<Link to={to} className="text-2sm text-text-link focus-custom">
Upgrade
</Link>
</div>
@@ -241,7 +241,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"absolute right-3 z-50 transition-colors duration-100 hover:cursor-pointer",
"absolute right-3 z-50 transition-colors duration-100 focus-custom hover:cursor-pointer",
showChrome ? "top-10" : "top-2.5",
copied ? "text-emerald-500" : "text-charcoal-500 hover:text-charcoal-300"
)}
@@ -38,7 +38,7 @@ export function AccountSideMenu({ user }: { user: User }) {
<SideMenuItem
name="Your profile"
icon="account"
iconColor="text-indigo-500"
activeIconColor="text-indigo-500"
to={accountPath()}
data-action="account"
/>
@@ -49,7 +49,7 @@ export function AccountSideMenu({ user }: { user: User }) {
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldCheckIcon}
iconColor="text-emerald-500"
activeIconColor="text-emerald-500"
to={personalAccessTokensPath()}
data-action="tokens"
/>
+195 -114
View File
@@ -1,14 +1,19 @@
import {
AcademicCapIcon,
ArrowRightOnRectangleIcon,
ArrowUpRightIcon,
BeakerIcon,
BellAlertIcon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleLeftEllipsisIcon,
ClockIcon,
CreditCardIcon,
CursorArrowRaysIcon,
EnvelopeIcon,
IdentificationIcon,
KeyIcon,
LightBulbIcon,
RectangleStackIcon,
ServerStackIcon,
ShieldCheckIcon,
@@ -17,13 +22,14 @@ import {
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useEffect, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { type User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { FeedbackType } from "~/routes/resources.feedback";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -77,11 +83,12 @@ import {
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
PopoverSideMenuTrigger,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { TextLink } from "../primitives/TextLink";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<MatchedProject, "id" | "name" | "slug" | "version">;
@@ -91,6 +98,8 @@ type SideMenuProps = {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
button?: ReactNode;
defaultValue?: FeedbackType;
};
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
@@ -171,7 +180,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
name="Team"
icon={UserGroupIcon}
to={organizationTeamPath(organization)}
iconColor="text-amber-500"
activeIconColor="text-amber-500"
data-action="team"
/>
{organization.projects.some((proj) => proj.version === "V3") && isManagedCloud && (
@@ -180,14 +189,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
name="Usage"
icon={ChartBarIcon}
to={v3UsagePath(organization)}
iconColor="text-green-600"
activeIconColor="text-green-600"
data-action="usage"
/>
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
to={v3BillingPath(organization)}
iconColor="text-blue-600"
activeIconColor="text-blue-600"
data-action="billing"
badge={
currentPlan?.v3Subscription?.isPaying
@@ -202,14 +211,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
name="Usage (v2)"
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-green-600"
activeIconColor="text-green-600"
data-action="usage & billing"
/>
)}
<SideMenuItem
name="Organization settings"
icon="settings"
iconColor="text-teal-500"
activeIconColor="text-teal-500"
to={organizationSettingsPath(organization)}
data-action="organization-settings"
/>
@@ -239,96 +248,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join our slack"
fullWidth
textAlignLeft
>
Join our Slack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-charcoal-800" />
<div>
<StepNumber stepNumber="1" title="Email us" />
<StepContentContainer>
<Paragraph>
Send us an email to this address from your Trigger.dev account email
address:
<ClipboardField
variant="primary/medium"
value="priority-support@trigger.dev"
className="my-2"
/>
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
<StepContentContainer>
<Paragraph>
As soon as we can, we'll setup a Slack Connect channel and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
)}
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
<SideMenuItem
name="Status"
icon={SignalIcon}
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
leadingIconClassName="text-primary"
data-action="help & feedback"
fullWidth
textAlignLeft
>
<span className="text-primary">Help & Feedback</span>
</Button>
}
/>
<HelpAndFeedback />
{isV3Project && isFreeV3User && (
<FreePlanUsage
to={v3BillingPath(organization)}
@@ -341,6 +261,167 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
);
}
function HelpAndFeedback() {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
return (
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
<PopoverSideMenuTrigger isOpen={isHelpMenuOpen} shortcut={{ key: "h" }}>
<div className="flex items-center gap-1.5">
<ChatBubbleLeftEllipsisIcon className="size-4 text-success" />
Help & Feedback
</div>
</PopoverSideMenuTrigger>
<PopoverContent
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
>
<Fragment>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Documentation"
icon="docs"
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-green-500"
activeIconColor="text-green-500"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Status"
icon={SignalIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-green-500"
activeIconColor="text-green-500"
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<SideMenuItem
name="Suggest a feature"
icon={LightBulbIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://feedback.trigger.dev/"
data-action="suggest-a-feature"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
<div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join-our-slack"
fullWidth
textAlignLeft
>
<div className="flex w-full items-center justify-between">
<span className="text-text-bright">Join our Slack</span>
<MenuCount count="PRO" />
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-charcoal-800" />
<div>
<StepNumber stepNumber="1" title="Email us" />
<StepContentContainer>
<Paragraph>
Send us an email to this address from your Trigger.dev account email
address:
<ClipboardField
variant="secondary/medium"
value="priority-support@trigger.dev"
className="my-2"
/>
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
<StepContentContainer>
<Paragraph>
As soon as we can, we'll setup a Slack Connect channel and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
<SideMenuItem
name="Ask in our Discord"
icon={DiscordIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Book a 15 min call"
icon={CalendarDaysIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-rose-500"
activeIconColor="text-rose-500"
to="https://cal.com/team/triggerdotdev/founders-call"
data-action="book-a-call"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-500"
data-action="contact-us"
fullWidth
textAlignLeft
>
Contact us
</Button>
}
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
);
}
function ProjectSelector({
project,
organizations,
@@ -495,47 +576,47 @@ function V2ProjectSideMenu({
<SideMenuItem
name="Jobs"
icon="job"
iconColor="text-indigo-500"
activeIconColor="text-indigo-500"
to={projectPath(organization, project)}
data-action="jobs"
/>
<SideMenuItem
name="Runs"
icon="runs"
iconColor="text-teal-500"
activeIconColor="text-teal-500"
to={projectRunsPath(organization, project)}
/>
<SideMenuItem
name="Triggers"
icon="trigger"
iconColor="text-amber-500"
activeIconColor="text-amber-500"
to={projectTriggersPath(organization, project)}
data-action="triggers"
/>
<SideMenuItem
name="Events"
icon={CursorArrowRaysIcon}
iconColor="text-sky-500"
activeIconColor="text-sky-500"
to={projectEventsPath(organization, project)}
/>
<SideMenuItem
name="HTTP endpoints"
icon="http-endpoint"
iconColor="text-pink-500"
activeIconColor="text-pink-500"
to={projectHttpEndpointsPath(organization, project)}
data-action="httpendpoints"
/>
<SideMenuItem
name="Environments & API Keys"
icon="environment"
iconColor="text-rose-500"
activeIconColor="text-rose-500"
to={projectEnvironmentsPath(organization, project)}
data-action="environments & api keys"
/>
<SideMenuItem
name="Project settings"
icon="settings"
iconColor="text-teal-500"
activeIconColor="text-teal-500"
to={projectSettingsPath(organization, project)}
data-action="project-settings"
/>
@@ -558,41 +639,41 @@ function V3ProjectSideMenu({
<SideMenuItem
name="Tasks"
icon={TaskIcon}
iconColor="text-blue-500"
activeIconColor="text-blue-500"
to={v3ProjectPath(organization, project)}
data-action="tasks"
/>
<SideMenuItem
name="Runs"
icon="runs"
iconColor="text-teal-500"
activeIconColor="text-teal-500"
to={v3RunsPath(organization, project)}
/>
<SideMenuItem
name="Test"
icon={BeakerIcon}
iconColor="text-lime-500"
activeIconColor="text-lime-500"
to={v3TestPath(organization, project)}
data-action="test"
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
iconColor="text-sun-500"
activeIconColor="text-sun-500"
to={v3SchedulesPath(organization, project)}
data-action="schedules"
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
iconColor="text-amber-500"
activeIconColor="text-amber-500"
to={v3ApiKeysPath(organization, project)}
data-action="api keys"
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
iconColor="text-pink-500"
activeIconColor="text-pink-500"
to={v3EnvironmentVariablesPath(organization, project)}
data-action="environment variables"
/>
@@ -600,7 +681,7 @@ function V3ProjectSideMenu({
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
iconColor="text-blue-500"
activeIconColor="text-blue-500"
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
@@ -608,7 +689,7 @@ function V3ProjectSideMenu({
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
iconColor="text-red-500"
activeIconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
@@ -616,14 +697,14 @@ function V3ProjectSideMenu({
<SideMenuItem
name="Concurrency limits"
icon={RectangleStackIcon}
iconColor="text-indigo-500"
activeIconColor="text-indigo-500"
to={v3ConcurrencyPath(organization, project)}
data-action="concurrency"
/>
<SideMenuItem
name="Project settings"
icon="settings"
iconColor="text-teal-500"
activeIconColor="text-teal-500"
to={v3ProjectSettingsPath(organization, project)}
data-action="project-settings"
/>
@@ -3,25 +3,26 @@ import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { type IconNames } from "../primitives/NamedIcon";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { Icon } from "../primitives/Icon";
import { IconExclamationCircle } from "@tabler/icons-react";
export function SideMenuItem({
icon,
iconColor,
activeIconColor,
inactiveIconColor,
trailingIcon,
trailingIconClassName,
name,
to,
hasWarning,
badge,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
activeIconColor?: string;
inactiveIconColor?: string;
trailingIcon?: IconNames | React.ComponentType<any>;
trailingIconClassName?: string;
name: string;
to: string;
hasWarning?: string | boolean;
badge?: string;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
@@ -35,11 +36,13 @@ export function SideMenuItem({
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-text-dimmed"}
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
TrailingIcon={trailingIcon}
trailingIconClassName={trailingIconClassName}
to={to}
target={target}
className={cn(
"text-text-bright group-hover:bg-charcoal-750",
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
subItem ? "text-text-dimmed" : "",
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
)}
@@ -48,27 +51,13 @@ export function SideMenuItem({
{name}
<div className="flex items-center gap-1">
{badge !== undefined && <MenuCount count={badge} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-error" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-error bg-error/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-error" />
)}
</div>
</div>
</LinkButton>
);
}
function MenuCount({ count }: { count: number | string }) {
export function MenuCount({ count }: { count: number | string }) {
return (
<div className="rounded-full bg-charcoal-900 px-2 py-1 text-xxs uppercase tracking-wider text-text-dimmed">
{count}
@@ -45,7 +45,10 @@ export function AppliedFilter({
</div>
{removable && (
<button
className={cn("group flex size-6 items-center justify-center", variantClassName.clear)}
className={cn(
"group flex size-6 items-center justify-center focus-custom",
variantClassName.clear
)}
onClick={onRemove}
>
<XMarkIcon className="size-3.5" />
@@ -48,11 +48,11 @@ const theme = {
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
},
secondary: {
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
button:
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
"bg-secondary group-hover:bg-charcoal-600 group-hover:border-charcoal-650 border border-charcoal-600 group-disabled:bg-secondary group-disabled:opacity-60 group-disabled:pointer-events-none",
shortcut:
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
},
tertiary: {
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
@@ -114,8 +114,7 @@ const variant = {
"danger/extra-large": createVariant("extra-large", "danger"),
"menu-item": {
textColor: "text-text-bright px-1",
button:
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-800 transition",
button: "h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-750",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
@@ -124,7 +123,7 @@ const variant = {
"small-menu-item": {
textColor: "text-text-bright",
button:
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-850 transition",
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750",
icon: "h-4",
iconSpacing: "gap-x-1.5",
shortcutVariant: undefined,
@@ -133,7 +132,7 @@ const variant = {
"small-menu-sub-item": {
textColor: "text-text-dimmed",
button:
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-850 transition",
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750 focus-custom",
icon: undefined,
iconSpacing: undefined,
shortcutVariant: undefined,
@@ -142,7 +141,7 @@ const variant = {
};
const allVariants = {
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus:outline-none group-disabled:opacity-75 group-disabled:pointer-events-none",
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus:outline-none group-disabled:opacity-75 group-disabled:pointer-events-none focus-custom",
variant: variant,
};
@@ -268,7 +267,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
return (
<button
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group outline-none focus-custom", props.fullWidth ? "w-full" : "")}
type={type}
disabled={disabled}
onClick={onClick}
@@ -328,7 +327,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -343,7 +342,7 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -61,6 +61,7 @@ export type CheckboxProps = Omit<
description?: string;
badges?: string[];
className?: string;
labelClassName?: string;
onChange?: (isChecked: boolean) => void;
};
@@ -78,6 +79,7 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
badges,
disabled,
className,
labelClassName: externalLabelClassName,
...props
},
ref
@@ -148,7 +150,8 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
htmlFor={id}
className={cn(
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
labelClassName
labelClassName,
externalLabelClassName
)}
onClick={(e) => e.preventDefault()}
>
@@ -50,7 +50,7 @@ const DialogContent = React.forwardRef<
>
<hr className="absolute left-0 top-11 w-full" />
{children}
<DialogPrimitive.Close className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus-visible:ring-ring absolute right-3 top-3 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none">
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-3 top-2 rounded-sm py-1 pr-1 opacity-70 transition-opacity focus-custom hover:opacity-100 disabled:pointer-events-none">
<div className="flex gap-x-2">
<ShortcutKey
shortcut={{
@@ -82,7 +82,7 @@ DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn("flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2", className)}
{...props}
/>
);
@@ -1,7 +1,7 @@
import { LinkButton } from "./Buttons";
import { Header3 } from "./Headers";
import { Paragraph } from "./Paragraph";
import { cn } from "~/utils/cn";
import { LinkButton } from "./Buttons";
import { Header2 } from "./Headers";
import { Paragraph } from "./Paragraph";
const variants = {
info: {
@@ -57,7 +57,7 @@ export function InfoPanel({
)}
</div>
<div className="flex flex-col gap-1">
{title && <Header3 className="text-text-bright">{title}</Header3>}
{title && <Header2 className="text-text-bright">{title}</Header2>}
{typeof children === "string" ? (
<Paragraph variant={"small"} className="text-text-dimmed">
{children}
@@ -4,7 +4,7 @@ import { cn } from "~/utils/cn";
import { Icon, RenderIcon } from "./Icon";
const containerBase =
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-2 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-text-link has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-2 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
const inputBase =
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none";
@@ -49,7 +49,7 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
<div className="group -ml-1.5 flex items-center gap-0">
<Link
to={backButton.to}
className="rounded px-1.5 py-1 text-xs text-text-dimmed transition group-hover:bg-charcoal-700 group-hover:text-text-bright"
className="rounded px-1.5 py-1 text-xs text-text-dimmed transition focus-custom group-hover:bg-charcoal-700 group-hover:text-text-bright"
>
{backButton.text}
</Link>
@@ -6,6 +6,8 @@ import * as React from "react";
import { cn } from "~/utils/cn";
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
import { Paragraph, type ParagraphVariant } from "./Paragraph";
import { ShortcutKey } from "./ShortcutKey";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -91,7 +93,7 @@ function PopoverCustomTrigger({
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded text-text-dimmed transition hover:bg-charcoal-850 hover:text-text-bright",
"group flex items-center justify-end gap-1 rounded text-text-dimmed transition focus-custom hover:bg-charcoal-850 hover:text-text-bright",
className
)}
>
@@ -100,6 +102,45 @@ function PopoverCustomTrigger({
);
}
function PopoverSideMenuTrigger({
isOpen,
children,
className,
shortcut,
...props
}: { isOpen?: boolean; shortcut?: ShortcutDefinition } & React.ComponentPropsWithoutRef<
typeof PopoverTrigger
>) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
});
return (
<PopoverTrigger
{...props}
ref={ref}
className={cn(
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
shortcut ? "justify-between" : "",
className
)}
>
{children}
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</PopoverTrigger>
);
}
function PopoverArrowTrigger({
isOpen,
children,
@@ -116,7 +157,7 @@ function PopoverArrowTrigger({
<PopoverTrigger
{...props}
className={cn(
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright",
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
fullWidth && "w-full justify-between",
className
)}
@@ -149,7 +190,7 @@ function PopoverVerticalEllipseTrigger({
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded px-1.5 py-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright",
"group flex items-center justify-end gap-1 rounded px-1.5 py-1.5 text-text-dimmed transition focus-custom hover:bg-charcoal-750 hover:text-text-bright",
className
)}
>
@@ -165,6 +206,7 @@ export {
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
PopoverSideMenuTrigger,
PopoverTrigger,
PopoverVerticalEllipseTrigger,
};
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
return (
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
boxClassName
)}
>
@@ -81,7 +81,9 @@ export function RadioButtonCircle({
outerCircleClassName
)}
>
<Circle className={cn("h-1.5 w-1.5 fill-white text-white", innerCircleClassName)} />
<Circle
className={cn("size-1.5 fill-text-bright text-text-bright", innerCircleClassName)}
/>
</div>
)}
</div>
@@ -121,7 +123,7 @@ export const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"group flex cursor-pointer items-start gap-x-2 transition",
"group flex cursor-pointer items-start gap-x-2 transition focus-custom",
variation.button,
className
)}
@@ -129,7 +131,7 @@ export const RadioGroupItem = React.forwardRef<
>
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
variation.inputPosition
)}
>
@@ -26,7 +26,7 @@ const ResizableHandle = ({
}) => (
<PanelResizer
className={cn(
"focus-visible:ring-ring group relative flex w-0.75 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary/50 focus-visible:ring-offset-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
size="3px"
@@ -65,10 +65,7 @@ export default function SegmentedControl({
value={option.value}
className={({ active, checked }) =>
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
active
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
: "",
"relative flex h-full grow cursor-pointer text-center font-normal focus-custom",
checked
? variants[variant].active
: "text-text-dimmed transition hover:text-text-bright"
@@ -15,18 +15,18 @@ const sizes = {
button: "h-6 rounded text-xs px-2 ",
},
medium: {
button: "h-8 rounded text-xs px-3 text-sm",
button: "h-8 rounded px-3 text-sm",
},
};
const style = {
tertiary: {
button:
"bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600",
"bg-tertiary focus-custom border border-tertiary hover:text-text-bright hover:border-charcoal-600",
},
minimal: {
button:
"bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
"bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
},
};
@@ -322,7 +322,7 @@ export function SelectTrigger({
render={
<Ariakit.Select
className={cn(
"group flex items-center gap-1 outline-offset-0 focus-within:outline-none focus-within:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
"group flex items-center gap-1 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
variantClasses.button,
className
)}
@@ -426,7 +426,7 @@ export function SelectList(props: SelectListProps) {
<Component
{...props}
className={cn(
"overflow-y-auto overscroll-contain outline-none scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
"overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom",
props.className
)}
/>
@@ -440,11 +440,11 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1";
"group cursor-pointer px-1 pt-1 text-sm text-text-dimmed focus-custom last:pb-1";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
shortcut,
...props
}: SelectItemProps) {
@@ -477,7 +477,7 @@ export function SelectItem({
)}
ref={ref}
>
<div className="flex h-7 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
{icon}
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
@@ -6,14 +6,14 @@ import { cn } from "~/utils/cn";
const variations = {
large: {
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition",
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition focus-custom",
root: "h-6 w-11",
thumb: "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-charcoal-400 group-hover:text-charcoal-200 transition",
},
small: {
container:
"flex items-center gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition",
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition focus-custom",
root: "h-3 w-6",
thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
text: "text-xs text-charcoal-400 group-hover:text-charcoal-200 hover:cursor-pointer transition",
@@ -38,14 +38,14 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
) : null}
<div
className={cn(
"group-focus-visible:ring-ring group-focus-visible:ring-offset-background peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-focus-visible:ring-2 group-focus-visible:ring-offset-2 group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-secondary group-data-[state=unchecked]:bg-charcoal-700 focus-visible:outline-none",
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-text-link group-data-[state=unchecked]:bg-charcoal-700",
root
)}
>
<SwitchPrimitives.Thumb
className={cn(
thumb,
"pointer-events-none block rounded-full bg-charcoal-200 shadow-lg ring-0 transition group-data-[state=checked]:bg-charcoal-700"
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
)}
/>
</div>
@@ -151,7 +151,7 @@ const rowHoverStyles = {
};
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background-dimmed before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem]";
"sticky right-0 w-[2.8rem] min-w-[2.8rem] bg-background-dimmed before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem]";
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
(
@@ -202,11 +202,11 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
colSpan={colSpan}
>
{to ? (
<Link to={to} className={cn(flexClasses, actionClassName)}>
<Link to={to} className={cn("focus-custom", flexClasses, actionClassName)}>
{children}
</Link>
) : onClick ? (
<button onClick={onClick} className={cn(flexClasses, actionClassName)}>
<button onClick={onClick} className={cn("focus-custom", flexClasses, actionClassName)}>
{children}
</button>
) : (
@@ -46,7 +46,7 @@ export function TabLink({
layoutId: string;
}) {
return (
<NavLink to={to} className="group flex flex-col items-center pt-1" end>
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
{({ isActive, isPending }) => {
return (
<>
@@ -96,7 +96,7 @@ export function TabButton({
return (
<button
className={cn("group flex flex-col items-center pt-1", props.className)}
className={cn("group flex flex-col items-center pt-1 focus-custom", props.className)}
type="button"
ref={ref}
{...props}
@@ -8,7 +8,7 @@ export function TextArea({ className, rows, ...props }: TextAreaProps) {
{...props}
rows={rows ?? 6}
className={cn(
"ring-offset-background placeholder:text-muted-foreground focus:border-ring focus:ring-ring focus-visible:ring-ring w-full rounded-md border border-tertiary bg-tertiary px-3 text-sm text-text-bright transition file:border-0 file:bg-transparent file:text-base file:font-medium hover:border-charcoal-600 focus:outline-none focus:ring-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
"placeholder:text-muted-foreground w-full rounded border border-charcoal-800 bg-charcoal-750 px-3 text-sm text-text-bright transition focus-custom focus-custom file:border-0 file:bg-transparent file:text-base file:font-medium hover:border-charcoal-600 hover:bg-charcoal-650 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
/>
@@ -41,7 +41,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 focus-visible:outline-none",
variantClasses[variant],
className
)}
@@ -72,7 +72,9 @@ function SimpleTooltip({
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<TooltipTrigger className={cn("h-fit", buttonClassName)}>{button}</TooltipTrigger>
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)}>
{button}
</TooltipTrigger>
<TooltipContent
side={side}
hidden={hidden}
@@ -368,7 +368,7 @@ function AppliedStatusFilter() {
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
@@ -456,7 +456,7 @@ function AppliedEnvironmentFilter({
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
@@ -547,7 +547,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTas
{(search, setSearch) => (
<TasksDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Task"
value={appliedSummary(
@@ -645,7 +645,7 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkAc
{(search, setSearch) => (
<BulkActionsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Bulk action"
value={bulkId}
@@ -764,7 +764,7 @@ function AppliedTagsFilter() {
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Tags"
value={appliedSummary(values("tags"))}
@@ -902,7 +902,7 @@ function AppliedPeriodFilter() {
{(search, setSearch) => (
<CreatedDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Created"
value={
@@ -40,7 +40,7 @@ import {
} from "~/utils/pathBuilder";
import { TraceSpan } from "~/utils/taskEvent";
import { SpanLink } from "~/v3/eventRepository.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
import { RunTag } from "./RunTag";
import { TaskRunStatusCombo } from "./TaskRunStatus";
@@ -75,7 +75,7 @@ export function RunInspector({
if (!run) {
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon name={"task"} spanName="" className="h-4 min-h-4 w-4 min-w-4" />
<Header2 className={cn("overflow-x-hidden text-blue-500")}>
@@ -101,7 +101,7 @@ export function RunInspector({
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon
name={"task"}
@@ -479,6 +479,7 @@ function RunTimeline({ run }: { run: RawRun }) {
const updatedAt = new Date(run.updatedAt);
const isFinished = isFinalRunStatus(run.status);
const isError = isFailedRunStatus(run.status);
return (
<div className="min-w-fit max-w-80">
@@ -535,7 +536,7 @@ function RunTimeline({ run }: { run: RawRun }) {
<RunTimelineEvent
title="Finished"
subtitle={<DateTimeAccurate date={updatedAt} />}
state="complete"
state={isError ? "error" : "complete"}
/>
</>
) : (
@@ -1,3 +1,4 @@
import { EnvelopeIcon } from "@heroicons/react/20/solid";
import {
exceptionEventEnhancer,
isExceptionSpanEvent,
@@ -5,6 +6,8 @@ import {
type SpanEvent as OtelSpanEvent,
} from "@trigger.dev/core/v3";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Feedback } from "~/components/Feedback";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
@@ -75,11 +78,26 @@ export function SpanEventError({
titleClassName="text-rose-500"
/>
{enhancedException.message && <Callout variant="error">{enhancedException.message}</Callout>}
{enhancedException.link && (
<Callout variant="docs" to={enhancedException.link.href}>
{enhancedException.link.name}
</Callout>
)}
{enhancedException.link &&
(enhancedException.link.magic === "CONTACT_FORM" ? (
<Feedback
button={
<Button
variant="tertiary/medium"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-400"
fullWidth
textAlignLeft
>
{enhancedException.link.name}
</Button>
}
/>
) : (
<Callout variant="docs" to={enhancedException.link.href}>
{enhancedException.link.name}
</Callout>
))}
{enhancedException.stacktrace && (
<CodeBlock
showCopyButton={false}
@@ -47,7 +47,7 @@ export function SpanInspector({
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon
name={span.style?.icon}
+21 -1
View File
@@ -31,7 +31,8 @@ const EnvironmentSchema = z.object({
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
ELECTRIC_ORIGIN: z.string(),
API_ORIGIN: z.string().optional(),
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
APP_ENV: z.string().default(process.env.NODE_ENV),
SERVICE_NAME: z.string().default("trigger.dev webapp"),
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
@@ -103,6 +104,25 @@ const EnvironmentSchema = z.object({
API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds
API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
//Realtime rate limiting
/**
* @example "60s"
* @example "1m"
* @example "1h"
* @example "1d"
* @example "1000ms"
* @example "1000s"
*/
REALTIME_RATE_LIMIT_WINDOW: z.string().default("1m"),
REALTIME_RATE_LIMIT_TOKENS: z.coerce.number().int().default(100),
REALTIME_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
REALTIME_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
REALTIME_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
//Ingesting event rate limit
INGEST_EVENT_RATE_LIMIT_WINDOW: z.string().default("60s"),
+3 -3
View File
@@ -3,7 +3,7 @@ import type {
TaskRunFailedExecutionResult,
TaskRunSuccessfulExecutionResult,
} from "@trigger.dev/core/v3";
import { TaskRunError } from "@trigger.dev/core/v3";
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
import type {
TaskRun,
@@ -62,7 +62,7 @@ export function executionResultForTaskRun(
id: taskRun.friendlyId,
error: {
type: "INTERNAL_ERROR",
code: "TASK_RUN_CANCELLED",
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
},
} satisfies TaskRunFailedExecutionResult;
}
@@ -94,7 +94,7 @@ export function executionResultForTaskRun(
id: taskRun.friendlyId,
error: {
type: "INTERNAL_ERROR",
code: "CONFIGURED_INCORRECTLY",
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
},
} satisfies TaskRunFailedExecutionResult;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { prisma } from "~/db.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
export const MAX_TAGS_PER_RUN = 5;
export const MAX_TAGS_PER_RUN = 10;
export async function createTag({ tag, projectId }: { tag: string; projectId: string }) {
if (tag.trim().length === 0) return;
@@ -62,8 +62,7 @@ type CommonRelatedRun = Prisma.Result<
export class ApiRetrieveRunPresenter extends BasePresenter {
public async call(
friendlyId: string,
env: AuthenticatedEnvironment,
showSecretDetails: boolean
env: AuthenticatedEnvironment
): Promise<RetrieveRunResponse | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const taskRun = await this._replica.taskRun.findFirst({
@@ -72,11 +71,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
runtimeEnvironmentId: env.id,
},
include: {
attempts: {
orderBy: {
createdAt: "desc",
},
},
attempts: true,
lockedToVersion: true,
schedule: true,
tags: true,
@@ -111,50 +106,48 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
let $output: any;
let $outputPresignedUrl: string | undefined;
if (showSecretDetails) {
const payloadPacket = await conditionallyImportPacket({
data: taskRun.payload,
dataType: taskRun.payloadType,
});
const payloadPacket = await conditionallyImportPacket({
data: taskRun.payload,
dataType: taskRun.payloadType,
});
if (
payloadPacket.dataType === "application/store" &&
typeof payloadPacket.data === "string"
) {
$payloadPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
payloadPacket.data,
"GET"
);
} else {
$payload = await parsePacket(payloadPacket);
}
if (
payloadPacket.dataType === "application/store" &&
typeof payloadPacket.data === "string"
) {
$payloadPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
payloadPacket.data,
"GET"
);
} else {
$payload = await parsePacket(payloadPacket);
}
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
const completedAttempt = taskRun.attempts.find(
(a) => a.status === "COMPLETED" && typeof a.output !== null
);
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
const completedAttempt = taskRun.attempts.find(
(a) => a.status === "COMPLETED" && typeof a.output !== null
);
if (completedAttempt && completedAttempt.output) {
const outputPacket = await conditionallyImportPacket({
data: completedAttempt.output,
dataType: completedAttempt.outputType,
});
if (completedAttempt && completedAttempt.output) {
const outputPacket = await conditionallyImportPacket({
data: completedAttempt.output,
dataType: completedAttempt.outputType,
});
if (
outputPacket.dataType === "application/store" &&
typeof outputPacket.data === "string"
) {
$outputPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
outputPacket.data,
"GET"
);
} else {
$output = await parsePacket(outputPacket);
}
if (
outputPacket.dataType === "application/store" &&
typeof outputPacket.data === "string"
) {
$outputPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
outputPacket.data,
"GET"
);
} else {
$output = await parsePacket(outputPacket);
}
}
}
@@ -165,6 +158,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
payloadPresignedUrl: $payloadPresignedUrl,
output: $output,
outputPresignedUrl: $outputPresignedUrl,
error: ApiRetrieveRunPresenter.apiErrorFromError(taskRun.error),
schedule: taskRun.schedule
? {
id: taskRun.schedule.friendlyId,
@@ -179,17 +173,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
},
}
: undefined,
attempts: !showSecretDetails
? []
: taskRun.attempts.map((a) => ({
id: a.friendlyId,
status: ApiRetrieveRunPresenter.apiStatusFromAttemptStatus(a.status),
createdAt: a.createdAt ?? undefined,
updatedAt: a.updatedAt ?? undefined,
startedAt: a.startedAt ?? undefined,
completedAt: a.completedAt ?? undefined,
error: ApiRetrieveRunPresenter.apiErrorFromError(a.error),
})),
// We're removing attempts from the API
attemptCount: taskRun.attempts.length,
attempts: [],
relatedRuns: {
root: taskRun.rootTaskRun
? await createCommonRunStructure(taskRun.rootTaskRun)
@@ -29,7 +29,7 @@ const CoercedDate = z.preprocess((arg) => {
return arg;
}, z.date().optional());
const SearchParamsSchema = z.object({
export const ApiRunListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
@@ -121,45 +121,31 @@ const SearchParamsSchema = z.object({
"filter[createdAt][period]": z.string().optional(),
});
type SearchParamsSchema = z.infer<typeof SearchParamsSchema>;
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
export class ApiRunListPresenter extends BasePresenter {
public async call(
project: Project,
searchParams: URLSearchParams,
searchParams: ApiRunListSearchParams,
environment?: RuntimeEnvironment
): Promise<ListRunResponse> {
return this.trace("call", async (span) => {
const rawSearchParams = Object.fromEntries(searchParams.entries());
const $searchParams = SearchParamsSchema.safeParse(rawSearchParams);
if (!$searchParams.success) {
logger.error("Invalid search params", {
searchParams: rawSearchParams,
errors: $searchParams.error.errors,
});
throw fromZodError($searchParams.error);
}
logger.debug("Valid search params", { searchParams: $searchParams.data });
const options: RunListOptions = {
projectId: project.id,
};
// pagination
if ($searchParams.data["page[size]"]) {
options.pageSize = $searchParams.data["page[size]"];
if (searchParams["page[size]"]) {
options.pageSize = searchParams["page[size]"];
}
if ($searchParams.data["page[after]"]) {
options.cursor = $searchParams.data["page[after]"];
if (searchParams["page[after]"]) {
options.cursor = searchParams["page[after]"];
options.direction = "forward";
}
if ($searchParams.data["page[before]"]) {
options.cursor = $searchParams.data["page[before]"];
if (searchParams["page[before]"]) {
options.cursor = searchParams["page[before]"];
options.direction = "backward";
}
@@ -167,12 +153,12 @@ export class ApiRunListPresenter extends BasePresenter {
if (environment) {
options.environments = [environment.id];
} else {
if ($searchParams.data["filter[env]"]) {
if (searchParams["filter[env]"]) {
const environments = await this._prisma.runtimeEnvironment.findMany({
where: {
projectId: project.id,
slug: {
in: $searchParams.data["filter[env]"],
in: searchParams["filter[env]"],
},
},
});
@@ -181,46 +167,46 @@ export class ApiRunListPresenter extends BasePresenter {
}
}
if ($searchParams.data["filter[status]"]) {
options.statuses = $searchParams.data["filter[status]"].flatMap((status) =>
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"].flatMap((status) =>
ApiRunListPresenter.apiStatusToRunStatuses(status)
);
}
if ($searchParams.data["filter[taskIdentifier]"]) {
options.tasks = $searchParams.data["filter[taskIdentifier]"];
if (searchParams["filter[taskIdentifier]"]) {
options.tasks = searchParams["filter[taskIdentifier]"];
}
if ($searchParams.data["filter[version]"]) {
options.versions = $searchParams.data["filter[version]"];
if (searchParams["filter[version]"]) {
options.versions = searchParams["filter[version]"];
}
if ($searchParams.data["filter[tag]"]) {
options.tags = $searchParams.data["filter[tag]"];
if (searchParams["filter[tag]"]) {
options.tags = searchParams["filter[tag]"];
}
if ($searchParams.data["filter[bulkAction]"]) {
options.bulkId = $searchParams.data["filter[bulkAction]"];
if (searchParams["filter[bulkAction]"]) {
options.bulkId = searchParams["filter[bulkAction]"];
}
if ($searchParams.data["filter[schedule]"]) {
options.scheduleId = $searchParams.data["filter[schedule]"];
if (searchParams["filter[schedule]"]) {
options.scheduleId = searchParams["filter[schedule]"];
}
if ($searchParams.data["filter[createdAt][from]"]) {
options.from = $searchParams.data["filter[createdAt][from]"].getTime();
if (searchParams["filter[createdAt][from]"]) {
options.from = searchParams["filter[createdAt][from]"].getTime();
}
if ($searchParams.data["filter[createdAt][to]"]) {
options.to = $searchParams.data["filter[createdAt][to]"].getTime();
if (searchParams["filter[createdAt][to]"]) {
options.to = searchParams["filter[createdAt][to]"].getTime();
}
if ($searchParams.data["filter[createdAt][period]"]) {
options.period = $searchParams.data["filter[createdAt][period]"];
if (searchParams["filter[createdAt][period]"]) {
options.period = searchParams["filter[createdAt][period]"];
}
if (typeof $searchParams.data["filter[isTest]"] === "boolean") {
options.isTest = $searchParams.data["filter[isTest]"];
if (typeof searchParams["filter[isTest]"] === "boolean") {
options.isTest = searchParams["filter[isTest]"];
}
const presenter = new RunListPresenter();
@@ -7,7 +7,7 @@ import {
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { eventRepository } from "~/v3/eventRepository.server";
import { machinePresetFromName } from "~/v3/machinePresets.server";
import { FINAL_ATTEMPT_STATUSES, isFinalRunStatus } from "~/v3/taskStatus";
import { FINAL_ATTEMPT_STATUSES, isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { getMaxDuration } from "~/v3/utils/maxDuration";
@@ -294,6 +294,7 @@ export class SpanPresenter extends BasePresenter {
usageDurationMs: run.usageDurationMs,
isFinished,
isRunning: RUNNING_STATUSES.includes(run.status),
isError: isFailedRunStatus(run.status),
payload,
payloadType: run.payloadType,
output,
@@ -501,16 +501,14 @@ function TasksTreeView({
return (
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-2">
<SearchField onChange={setFilterText} />
<div className="flex items-center gap-2">
<Switch
variant="small"
label="Errors only"
checked={errorsOnly}
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
/>
</div>
<Switch
variant="small"
label="Errors only"
checked={errorsOnly}
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
/>
</div>
<ResizablePanelGroup autosaveId={resizableSettings.tree.autosaveId}>
{/* Tree list */}
@@ -1008,7 +1006,7 @@ function ShowParentLink({
{mouseOver ? (
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
) : (
<ShowParentIcon className="text-charcoal-650 h-4 w-4" />
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
)}
<Paragraph
variant="small"
@@ -528,16 +528,14 @@ function TasksTreeView({
return (
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3">
<SearchField onChange={setFilterText} />
<div className="flex items-center gap-2">
<Switch
variant="small"
label="Errors only"
checked={errorsOnly}
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
/>
</div>
<Switch
variant="small"
label="Errors only"
checked={errorsOnly}
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
/>
</div>
<ResizablePanelGroup autosaveId={resizableSettings.tree.autosaveId}>
{/* Tree list */}
@@ -1015,7 +1013,7 @@ function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) {
{mouseOver ? (
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
) : (
<ShowParentIcon className="text-charcoal-650 h-4 w-4" />
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
)}
<Paragraph
variant="small"
@@ -117,6 +117,7 @@ export default function ChoosePlanPage() {
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan={false}
periodEnd={periodEnd}
/>
</div>
</div>
@@ -5,13 +5,12 @@ import { z } from "zod";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { MainBody } from "~/components/layout/AppLayout";
import { SideMenu } from "~/components/navigation/SideMenu";
import { featuresForRequest } from "~/features.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { getCurrentPlan, getUsage } from "~/services/platform.v3.server";
import { getCachedUsage, getCurrentPlan, getUsage } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { organizationPath } from "~/utils/pathBuilder";
@@ -30,6 +29,27 @@ export function useCurrentPlan(matches?: UIMatch[]) {
return data?.currentPlan;
}
export const shouldRevalidate: ShouldRevalidateFunction = (params) => {
const { currentParams, nextParams } = params;
const current = ParamsSchema.safeParse(currentParams);
const next = ParamsSchema.safeParse(nextParams);
if (current.success && next.success) {
if (current.data.organizationSlug !== next.data.organizationSlug) {
return true;
}
if (current.data.projectParam !== next.data.projectParam) {
return true;
}
}
// This prevents revalidation when there are search params changes
// IMPORTANT: If the loader function depends on search params, this should be updated
return params.currentUrl.pathname !== params.nextUrl.pathname;
};
// IMPORTANT: Make sure to update shouldRevalidate if this loader depends on search params
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -51,11 +71,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const firstDayOfMonth = new Date();
firstDayOfMonth.setUTCDate(1);
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
const tomorrow = new Date();
tomorrow.setUTCDate(tomorrow.getDate() + 1);
// Using the 1st day of next month means we get the usage for the current month
// and the cache key for getCachedUsage is stable over the month
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
firstDayOfNextMonth.setUTCDate(1);
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
const [plan, usage] = await Promise.all([
getCurrentPlan(organization.id),
getUsage(organization.id, { from: firstDayOfMonth, to: tomorrow }),
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
]);
let hasExceededFreeTier = false;
@@ -101,26 +127,6 @@ export function ErrorBoundary() {
return org ? (
<RouteErrorDisplay button={{ title: org.title, to: organizationPath(org) }} />
) : (
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
<RouteErrorDisplay button={{ title: "Go to homepage", to: "/" }} />
);
}
export const shouldRevalidate: ShouldRevalidateFunction = ({
defaultShouldRevalidate,
currentParams,
nextParams,
}) => {
const current = ParamsSchema.safeParse(currentParams);
const next = ParamsSchema.safeParse(nextParams);
if (current.success && next.success) {
if (current.data.organizationSlug !== next.data.organizationSlug) {
return true;
}
if (current.data.projectParam !== next.data.projectParam) {
return true;
}
}
return defaultShouldRevalidate;
};
@@ -37,11 +37,15 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const currentPlan = await getCurrentPlan(organization.id);
return typedjson({ ...plans, ...currentPlan, organizationSlug });
const periodEnd = new Date();
periodEnd.setMonth(periodEnd.getMonth() + 1);
return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd });
}
export default function ChoosePlanPage() {
const { plans, v3Subscription, organizationSlug } = useTypedLoaderData<typeof loader>();
const { plans, v3Subscription, organizationSlug, periodEnd } =
useTypedLoaderData<typeof loader>();
return (
<MainCenteredContainer className="flex max-w-[80rem] flex-col items-center gap-8 p-3">
@@ -52,6 +56,7 @@ export default function ChoosePlanPage() {
organizationSlug={organizationSlug}
hasPromotedPlan
showGithubVerificationBadge
periodEnd={periodEnd}
/>
</MainCenteredContainer>
);
+82 -38
View File
@@ -4,6 +4,7 @@ import { RadioGroup } from "@radix-ui/react-radio-group";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { uiComponent } from "@team-plain/typescript-sdk";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
@@ -17,21 +18,25 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { TextArea } from "~/components/primitives/TextArea";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { organizationPath, rootPath } from "~/utils/pathBuilder";
import { sendToPlain } from "~/utils/plain.server";
const schema = z.object({
orgName: z.string().min(3).max(50),
companySize: z.string().optional(),
whyUseUs: z.string().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const presenter = new NewOrganizationPresenter();
const { hasOrganizations } = await presenter.call({ userId });
const { hasOrganizations } = await presenter.call({ userId: userId });
return typedjson({
hasOrganizations,
@@ -39,8 +44,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const formData = await request.formData();
const submission = parse(formData, { schema });
@@ -51,10 +55,41 @@ export const action: ActionFunction = async ({ request }) => {
try {
const organization = await createOrganization({
title: submission.value.orgName,
userId,
userId: user.id,
companySize: submission.value.companySize ?? null,
});
const whyUseUs = formData.get("whyUseUs");
if (whyUseUs) {
try {
await sendToPlain({
userId: user.id,
email: user.email,
name: user.name ?? user.displayName ?? user.email,
title: "New org feedback",
components: [
uiComponent.text({
text: `${submission.value.orgName} just created a new organization.`,
}),
uiComponent.divider({ spacingSize: "M" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "What problem are you trying to solve?",
}),
uiComponent.text({
size: "L",
color: "NORMAL",
text: whyUseUs.toString(),
}),
],
});
} catch (error) {
logger.error("Error sending data to Plain when creating an org:", { error });
}
}
return redirect(organizationPath(organization));
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
@@ -97,39 +132,48 @@ export default function NewOrganizationPage() {
<FormError id={orgName.errorId}>{orgName.error}</FormError>
</InputGroup>
{isManagedCloud && (
<InputGroup>
<Label htmlFor={"companySize"}>Number of employees</Label>
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
<RadioGroupItem
id="employees-1-5"
label="1-5"
value={"1-5"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-6-49"
label="6-49"
value={"6-49"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-50-99"
label="50-99"
value={"50-99"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-100+"
label="100+"
value={"100+"}
variant="button/small"
className="grow"
/>
</RadioGroup>
</InputGroup>
<>
<InputGroup>
<Label htmlFor={"companySize"}>Number of employees</Label>
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
<RadioGroupItem
id="employees-1-5"
label="1-5"
value={"1-5"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-6-49"
label="6-49"
value={"6-49"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-50-99"
label="50-99"
value={"50-99"}
variant="button/small"
className="grow"
/>
<RadioGroupItem
id="employees-100+"
label="100+"
value={"100+"}
variant="button/small"
className="grow"
/>
</RadioGroup>
</InputGroup>
<InputGroup>
<Label htmlFor={"whyUseUs"}>What problem are you trying to solve?</Label>
<TextArea name="whyUseUs" rows={4} spellCheck={false} />
<Hint>
Your answer will help us understand your use case and provide better support.
</Hint>
</InputGroup>
</>
)}
<FormButtons
@@ -0,0 +1,19 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequest } from "~/services/apiAuth.server";
export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const claims = {
sub: authenticationResult.environment.id,
pub: true,
};
return json(claims);
}
+46
View File
@@ -0,0 +1,46 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { z } from "zod";
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
const RequestBodySchema = z.object({
claims: z
.object({
scopes: z.array(z.string()).default([]),
})
.optional(),
expirationTime: z.union([z.number(), z.string()]).optional(),
});
export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const parsedBody = RequestBodySchema.safeParse(await request.json());
if (!parsedBody.success) {
return json(
{ error: "Invalid request body", issues: parsedBody.error.issues },
{ status: 400 }
);
}
const claims = {
sub: authenticationResult.environment.id,
pub: true,
...parsedBody.data.claims,
};
const jwt = await internal_generateJWT({
secretKey: authenticationResult.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
});
return json({ token: jwt });
}
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
id: parsedParams.data.connectionId,
integration: {
slug: parsedParams.data.integrationSlug,
organization: authenticatedEnv.organization,
organizationId: authenticatedEnv.organization.id,
},
},
include: {
@@ -1,62 +1,36 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ValidationError } from "zod-validation-error";
import { findProjectByRef } from "~/models/project.server";
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { apiCors } from "~/utils/apiCors";
import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderPATApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
searchParams: ApiRunListSearchParams,
corsStrategy: "all",
},
async ({ searchParams, params, authentication }) => {
const project = await findProjectByRef(params.projectRef, authentication.userId);
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const $params = ParamsSchema.safeParse(params);
if (!$params.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const project = await findProjectByRef($params.data.projectRef, authenticationResult.userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const url = new URL(request.url);
const presenter = new ApiRunListPresenter();
try {
const result = await presenter.call(project, url.searchParams);
const presenter = new ApiRunListPresenter();
const result = await presenter.call(project, searchParams);
if (!result) {
return apiCors(request, json({ data: [] }));
return json({ data: [] });
}
return apiCors(request, json(result));
} catch (error) {
if (error instanceof ValidationError) {
return apiCors(
request,
json({ error: "Query Error", details: error.details }, { status: 400 })
);
} else {
return apiCors(
request,
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
);
}
return json(result);
}
}
);
@@ -89,6 +89,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
tags: {
connect: tagIds.map((id) => ({ id })),
},
runTags: {
push: newTags,
},
},
});
@@ -29,7 +29,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
const service = new CreateTaskRunAttemptService();
try {
const { execution } = await service.call(runParam, authenticationResult.environment);
const { execution } = await service.call({
runId: runParam,
authenticatedEnv: authenticationResult.environment,
});
return json(execution, { status: 200 });
} catch (error) {
@@ -62,11 +62,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(
updatedRun.friendlyId,
authenticationResult.environment,
true
);
const result = await presenter.call(updatedRun.friendlyId, authenticationResult.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
+23 -46
View File
@@ -1,52 +1,29 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { ValidationError } from "zod-validation-error";
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: false,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const url = new URL(request.url);
const presenter = new ApiRunListPresenter();
try {
export const loader = createLoaderApiRoute(
{
searchParams: ApiRunListSearchParams,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ searchParams, authentication }) => {
const presenter = new ApiRunListPresenter();
const result = await presenter.call(
authenticatedEnv.project,
url.searchParams,
authenticatedEnv
authentication.environment.project,
searchParams,
authentication.environment
);
if (!result) {
return apiCors(request, json({ data: [] }));
}
return apiCors(request, json(result));
} catch (error) {
if (error instanceof ValidationError) {
return apiCors(
request,
json({ error: "Query Error", details: error.details }, { status: 400 })
);
} else {
return apiCors(
request,
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
);
}
return json(result);
}
}
);
@@ -104,10 +104,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Task not found" }, { status: 404 });
}
return json({
batchId: result.batch.friendlyId,
runs: result.runs,
});
return json(
{
batchId: result.batch.friendlyId,
runs: result.runs,
},
{
headers: {
"x-trigger-jwt-claims": JSON.stringify({
sub: authenticationResult.environment.id,
pub: true,
}),
},
}
);
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
@@ -30,6 +30,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
return { status: 405, body: "Method Not Allowed" };
}
logger.debug("TriggerTask action", { headers: Object.fromEntries(request.headers) });
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
@@ -105,9 +107,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Task not found" }, { status: 404 });
}
return json({
id: run.friendlyId,
});
return json(
{
id: run.friendlyId,
},
{
headers: {
"x-trigger-jwt-claims": JSON.stringify({
sub: authenticationResult.environment.id,
pub: true,
}),
},
}
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
+22 -35
View File
@@ -1,44 +1,31 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ runs: params.runId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication }) => {
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(params.runId, authentication.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
}
return json(result);
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: true,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return apiCors(request, json({ error: "Invalid or missing runId" }, { status: 400 }));
}
const { runId } = parsed.data;
const showSecretDetails = authenticationResult.type === "PRIVATE";
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(runId, authenticatedEnv, showSecretDetails);
if (!result) {
return apiCors(request, json({ error: "Run not found" }, { status: 404 }));
}
return apiCors(request, json(result));
}
);
@@ -222,7 +222,7 @@ export default function Page() {
<Label htmlFor={confirmEmail.id}>How did you hear about us?</Label>
<Input
{...conform.input(referralSource, { type: "text" })}
placeholder="Google, Twitter…?"
placeholder="Google, X (Twitter)…?"
icon="heart"
spellCheck={false}
/>
@@ -0,0 +1,36 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
});
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ batch: params.batchId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication, request }) => {
const batchRun = await $replica.batchTaskRun.findFirst({
where: {
friendlyId: params.batchId,
runtimeEnvironmentId: authentication.environment.id,
},
});
if (!batchRun) {
return json({ error: "Batch not found" }, { status: 404 });
}
return realtimeClient.streamBatch(request.url, authentication.environment, batchRun.id);
}
);
@@ -0,0 +1,36 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
});
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ runs: params.runId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication, request }) => {
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: authentication.environment.id,
},
});
if (!run) {
return json({ error: "Run not found" }, { status: 404 });
}
return realtimeClient.streamRun(request.url, authentication.environment, run.id);
}
);
@@ -0,0 +1,28 @@
import { z } from "zod";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const SearchParamsSchema = z.object({
tags: z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
});
export const loader = createLoaderApiRoute(
{
searchParams: SearchParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, searchParams) => searchParams,
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ searchParams, authentication, request }) => {
return realtimeClient.streamRuns(request.url, authentication.environment, searchParams);
}
);
+10 -73
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { env } from "~/env.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { requireUser } from "~/services/session.server";
import { sendToPlain } from "~/utils/plain.server";
let client: PlainClient | undefined;
@@ -14,7 +15,7 @@ export const feedbackTypeLabel = {
feature: "Feature request",
help: "Help me out",
enterprise: "Enterprise enquiry",
"developer preview": "Developer preview feedback",
feedback: "General feedback",
};
export type FeedbackType = keyof typeof feedbackTypeLabel;
@@ -32,7 +33,7 @@ const feedbackType = z.union(
export const schema = z.object({
path: z.string(),
feedbackType,
message: z.string().min(1, "Must be at least 1 character"),
message: z.string().min(10, "Must be at least 10 characters"),
});
export async function action({ request }: ActionFunctionArgs) {
@@ -45,60 +46,12 @@ export async function action({ request }: ActionFunctionArgs) {
return json(submission);
}
const title = feedbackTypeLabel[submission.value.feedbackType as FeedbackType];
try {
if (!env.PLAIN_API_KEY) {
console.error("PLAIN_API_KEY is not set");
submission.error.message = "PLAIN_API_KEY is not set";
return json(submission);
}
client = new PlainClient({
apiKey: env.PLAIN_API_KEY,
});
const upsertCustomerRes = await client.upsertCustomer({
identifier: {
emailAddress: user.email,
},
onCreate: {
externalId: user.id,
fullName: user.name ?? "",
// TODO - Optional: set 'first name' on user
// shortName: ''
email: {
email: user.email,
isVerified: true,
},
},
onUpdate: {
externalId: { value: user.id },
fullName: { value: user.name ?? "" },
// TODO - see above
// shortName: { value: "" },
email: {
email: user.email,
isVerified: true,
},
},
});
if (upsertCustomerRes.error) {
console.error(
inspect(upsertCustomerRes.error, {
showHidden: false,
depth: null,
colors: true,
})
);
submission.error.message = upsertCustomerRes.error.message;
return json(submission);
}
const title = feedbackTypeLabel[submission.value.feedbackType as FeedbackType];
const createThreadRes = await client.createThread({
customerIdentifier: {
customerId: upsertCustomerRes.data.customer.id,
},
await sendToPlain({
userId: user.id,
email: user.email,
name: user.name ?? user.displayName ?? user.email,
title,
components: [
uiComponent.text({
@@ -123,31 +76,15 @@ export async function action({ request }: ActionFunctionArgs) {
text: submission.value.message,
}),
],
// TODO: Optional: set labels on threads here on creation
// labelTypeIds: [],
// TODO: Optional: set the priority (0 is urgent, 3 is low)
// priority: 0,
});
if (createThreadRes.error) {
console.error(
inspect(createThreadRes.error, {
showHidden: false,
depth: null,
colors: true,
})
);
submission.error.message = createThreadRes.error.message;
return json(submission);
}
return redirectWithSuccessMessage(
submission.value.path,
request,
"Thanks for your feedback! We'll get back to you soon."
);
} catch (e) {
return json(e, { status: 400 });
submission.error.message = e instanceof Error ? e.message : "Unknown error";
return json(submission);
}
}
@@ -44,6 +44,7 @@ import { CronPattern, UpsertSchedule } from "~/v3/schedules";
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language";
import { TimezoneList } from "~/components/scheduled/timezones";
import { logger } from "~/services/logger.server";
const cronFormat = `* * * * *
┬ ┬ ┬ ┬ ┬
@@ -94,9 +95,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"
);
} catch (error: any) {
const errorMessage = `Failed: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`;
logger.error("Failed to create schedule", error);
const errorMessage = `Something went wrong. Please try again.`;
return redirectWithErrorMessage(
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
request,
@@ -1,4 +1,10 @@
import { CheckIcon, ClockIcon, CloudArrowDownIcon, QueueListIcon } from "@heroicons/react/20/solid";
import {
CheckIcon,
ClockIcon,
CloudArrowDownIcon,
EnvelopeIcon,
QueueListIcon,
} from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
@@ -13,6 +19,7 @@ import { typedjson, useTypedFetcher } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
@@ -172,7 +179,7 @@ function SpanBody({
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon
name={span.style?.icon}
@@ -314,7 +321,7 @@ function SpanBody({
<Property.Table>
<Property.Item>
<Property.Label>Message</Property.Label>
<Property.Value>{span.message}</Property.Value>
<Property.Value className="whitespace-pre-wrap">{span.message}</Property.Value>
</Property.Item>
{span.triggeredRuns.length > 0 && (
<Property.Item>
@@ -413,7 +420,7 @@ function RunBody({
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon
name={"task"}
@@ -850,7 +857,7 @@ function RunTimeline({ run }: { run: SpanRun }) {
<RunTimelineEvent
title="Finished"
subtitle={<DateTimeAccurate date={run.updatedAt} />}
state="complete"
state={run.isError ? "error" : "complete"}
/>
</>
) : (
@@ -963,11 +970,26 @@ function RunError({ error }: { error: TaskRunError }) {
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
<Header3 className="text-rose-500">{name}</Header3>
{enhancedError.message && <Callout variant="error">{enhancedError.message}</Callout>}
{enhancedError.link && (
<Callout variant="docs" to={enhancedError.link.href}>
{enhancedError.link.name}
</Callout>
)}
{enhancedError.link &&
(enhancedError.link.magic === "CONTACT_FORM" ? (
<Feedback
button={
<Button
variant="tertiary/medium"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-400"
fullWidth
textAlignLeft
>
{enhancedError.link.name}
</Button>
}
/>
) : (
<Callout variant="docs" to={enhancedError.link.href}>
{enhancedError.link.name}
</Callout>
))}
{enhancedError.stackTrace && (
<CodeBlock
showCopyButton={false}
@@ -1,11 +1,15 @@
import {
ArrowUpRightIcon,
CheckIcon,
ExclamationTriangleIcon,
ShieldCheckIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { ArrowDownCircleIcon } from "@heroicons/react/24/outline";
import { Form, useLocation, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { uiComponent } from "@team-plain/typescript-sdk";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import {
FreePlanDefinition,
Limits,
@@ -14,11 +18,13 @@ import {
SetPlanBody,
SubscriptionResult,
} from "@trigger.dev/platform/v3";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import { DefinitionTip } from "~/components/DefinitionTooltip";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
@@ -26,14 +32,18 @@ import {
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TextArea } from "~/components/primitives/TextArea";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { setPlan } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { sendToPlain } from "~/utils/plain.server";
const Params = z.object({
organizationSlug: z.string(),
@@ -43,6 +53,8 @@ const schema = z.object({
type: z.enum(["free", "paid"]),
planCode: z.string().optional(),
callerPath: z.string(),
reasons: z.union([z.string(), z.array(z.string())]).optional(),
message: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
@@ -51,11 +63,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
const { organizationSlug } = Params.parse(params);
const user = await requireUser(request);
const formData = await request.formData();
const reasons = formData.getAll("reasons");
const message = formData.get("message");
const userId = await requireUserId(request);
const formData = Object.fromEntries(await request.formData());
const form = schema.parse(formData);
const form = schema.parse({
...Object.fromEntries(formData),
reasons,
message: message || undefined,
});
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
@@ -69,9 +86,53 @@ export async function action({ request, params }: ActionFunctionArgs) {
switch (form.type) {
case "free": {
try {
if (reasons.length > 0 || (message && message.toString().trim() !== "")) {
await sendToPlain({
userId: user.id,
email: user.email,
name: user.name ?? "",
title: "Plan cancelation feedback",
components: [
uiComponent.text({
text: `${user.name} (${user.email}) just canceled their plan.`,
}),
uiComponent.divider({ spacingSize: "M" }),
...(reasons.length > 0
? [
uiComponent.spacer({ size: "L" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "Reasons:",
}),
uiComponent.text({
text: reasons.join(", "),
}),
]
: []),
...(message
? [
uiComponent.spacer({ size: "L" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "Comment:",
}),
uiComponent.text({
text: message.toString(),
}),
]
: []),
],
});
}
} catch (e) {
logger.error("Failed to submit to Plain the unsubscribe reason", { error: e });
}
payload = {
type: "free" as const,
userId,
userId: user.id,
};
break;
}
@@ -82,10 +143,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
payload = {
type: "paid" as const,
planCode: form.planCode,
userId,
userId: user.id,
};
break;
}
default: {
throw new Error("Invalid form type");
}
}
return setPlan(organization, request, form.callerPath, payload);
@@ -134,6 +198,7 @@ type PricingPlansProps = {
organizationSlug: string;
hasPromotedPlan: boolean;
showGithubVerificationBadge?: boolean;
periodEnd: Date;
};
export function PricingPlans({
@@ -142,6 +207,7 @@ export function PricingPlans({
organizationSlug,
hasPromotedPlan,
showGithubVerificationBadge,
periodEnd,
}: PricingPlansProps) {
return (
<div className="flex w-full flex-col">
@@ -151,6 +217,7 @@ export function PricingPlans({
subscription={subscription}
organizationSlug={organizationSlug}
showGithubVerificationBadge={showGithubVerificationBadge}
periodEnd={periodEnd}
/>
<TierHobby
plan={plans.hobby}
@@ -172,23 +239,33 @@ export function TierFree({
subscription,
organizationSlug,
showGithubVerificationBadge,
periodEnd,
}: {
plan: FreePlanDefinition;
subscription?: SubscriptionResult;
organizationSlug: string;
showGithubVerificationBadge?: boolean;
periodEnd: Date;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLackingFeaturesChecked, setIsLackingFeaturesChecked] = useState(false);
const status = subscription?.freeTierStatus ?? "requires_connect";
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
<TierContainer>
<div className="relative">
<PricingHeader title={plan.title} cost={0} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} free monthly usage
</TierLimit>
{showGithubVerificationBadge && status === "approved" && (
<SimpleTooltip
buttonClassName="absolute right-1 top-1"
@@ -242,16 +319,11 @@ export function TierFree({
</div>
</div>
) : (
<Form action={formAction} method="post" id="subscribe">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} free usage
</TierLimit>
<div className="py-6">
{status === "requires_connect" ? (
<Dialog>
<DialogTrigger asChild>
<>
{status === "requires_connect" ? (
<Dialog>
<DialogTrigger asChild>
<div className="my-6">
<Button
type="button"
variant="tertiary/large"
@@ -260,12 +332,16 @@ export function TierFree({
disabled={isLoading}
LeadingIcon={isLoading ? Spinner : undefined}
>
Unlock free plan
Unlock Free plan
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<Form action={formAction} method="post" id="subscribe-free">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<DialogHeader>Unlock the Free plan</DialogHeader>
<div className="mb-3 mt-4 flex flex-col items-center gap-4 px-6">
<div className="mb-5 mt-7 flex flex-col items-center gap-4 px-6">
<GitHubLightIcon className="size-16" />
<Paragraph variant="base/bright" className="text-center">
To unlock the Free plan, we need to verify that you have an active GitHub
@@ -282,16 +358,104 @@ export function TierFree({
fullWidth
disabled={isLoading}
LeadingIcon={isLoading ? Spinner : undefined}
form="subscribe"
form="subscribe-free"
>
Connect to GitHub
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
</Form>
</DialogContent>
</Dialog>
) : subscription?.plan !== undefined &&
subscription.plan.type !== "free" &&
subscription.canceledAt === undefined ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="cancel">
<DialogTrigger asChild>
<div className="my-6">
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
{`Downgrade to ${plan.title}`}
</Button>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<Form action={formAction} method="post" id="subscribe">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<DialogHeader>Downgrade plan?</DialogHeader>
<div className="flex items-start gap-3 pb-6 pr-2 pt-8">
<ArrowDownCircleIcon className="size-12 min-w-12 text-error" />
<Paragraph variant="base/bright" className="text-text-bright">
Are you sure you want to downgrade? You will lose access to your current
plan's features on{" "}
<DateTime
includeTime={false}
date={new Date(periodEnd.getTime() + 86400000)}
/>
.
</Paragraph>
</div>
<div>
<div className="mb-4">
<Header2 className="mb-1">Why are you thinking of downgrading?</Header2>
<ul className="space-y-1">
{[
"Subscription or usage costs too expensive",
"Bugs or technical issues",
"No longer need the service",
"Found a better alternative",
"Lacking features I need",
].map((label, index) => (
<li key={index}>
<CheckboxWithLabel
id={`reason-${index + 1}`}
name="reasons"
value={label}
variant="simple"
label={label}
labelClassName="text-text-dimmed"
onChange={(isChecked: boolean) => {
if (label === "Lacking features I need") {
setIsLackingFeaturesChecked(isChecked);
}
}}
/>
</li>
))}
</ul>
</div>
<div>
<Header2 className="mb-1">
{isLackingFeaturesChecked
? "What features do you need? Or how can we improve?"
: "What can we do to improve?"}
</Header2>
<TextArea id="improvement-suggestions" name="message" />
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
variant="danger/medium"
disabled={isLoading}
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
type="submit"
>
Downgrade plan
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
) : (
<Form action={formAction} method="post" id="subscribe-verified" className="my-6">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<Button
variant="tertiary/large"
type="submit"
form="subscribe-verified"
fullWidth
className="text-md font-medium"
disabled={
@@ -305,12 +469,14 @@ export function TierFree({
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
: subscription.plan.type === "free"
? "Current plan"
: `Downgrade to ${plan.title}`}
: subscription.canceledAt !== undefined
? "Current plan"
: "Select plan"}
</Button>
)}
</div>
</Form>
)}
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
@@ -329,7 +495,7 @@ export function TierFree({
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
</ul>
</Form>
</>
)}
</TierContainer>
);
@@ -350,6 +516,11 @@ export function TierHobby({
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
<TierContainer isHighlighted={isHighlighted}>
@@ -357,15 +528,52 @@ export function TierHobby({
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} usage included
</TierLimit>
<Form action={formAction} method="post" id="subscribe">
<div className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<input type="hidden" name="callerPath" value={location.pathname} />
<Form action={formAction} method="post" id="subscribe-hobby" className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<input type="hidden" name="callerPath" value={location.pathname} />
{subscription?.plan !== undefined &&
subscription.plan.type !== "free" &&
subscription.canceledAt === undefined &&
subscription.plan.code !== plan.code ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="downgrade">
<DialogTrigger asChild>
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
{`Downgrade to ${plan.title}`}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Downgrade plan?</DialogHeader>
<div className="mb-2 mt-4 flex items-start gap-3">
<span>
<ArrowDownCircleIcon className="size-12 text-blue-500" />
</span>
<Paragraph variant="base/bright" className="text-text-bright">
By downgrading you will lose access to your current plan's features and your
included credits will be reduced.
</Paragraph>
</div>
<DialogFooter>
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
variant="tertiary/medium"
disabled={isLoading}
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
form="subscribe-hobby"
>
{`Downgrade to ${plan.title}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Button
variant={isHighlighted ? "primary/large" : "tertiary/large"}
fullWidth
className="text-md font-medium"
form="subscribe-hobby"
disabled={
isLoading ||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
@@ -380,9 +588,9 @@ export function TierHobby({
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Downgrade to ${plan.title}`}
: `Upgrade to ${plan.title}`}
</Button>
</div>
)}
</Form>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
@@ -425,7 +633,7 @@ export function TierPro({
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} usage included
</TierLimit>
<Form action={formAction} method="post" id="subscribe">
<Form action={formAction} method="post" id="subscribe-pro">
<div className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
@@ -433,6 +641,7 @@ export function TierPro({
<Button
variant="tertiary/large"
fullWidth
form="subscribe-pro"
className="text-md font-medium"
disabled={
isLoading ||
@@ -589,21 +798,28 @@ function PricingHeader({
function TierLimit({ children, href }: { children: React.ReactNode; href?: string }) {
return (
<>
<hr className="my-6 border-grid-bright" />
{href ? (
<div>
<hr className="my-6 border-grid-bright" />
<a
href={href}
className="hover:decoration-bright font-sans text-lg font-normal text-text-bright underline decoration-charcoal-500 underline-offset-4 transition"
>
{children}
</a>
</div>
<SimpleTooltip
buttonClassName="text-left w-fit"
disableHoverableContent
button={
<a
href={href}
className="text-left font-sans text-lg font-normal text-text-bright underline decoration-charcoal-500 underline-offset-4 transition hover:decoration-text-bright"
>
{children}
</a>
}
content={
<div className="flex items-center gap-1">
<Paragraph variant="small">View compute pricing information</Paragraph>
<ArrowUpRightIcon className="size-4 text-text-dimmed" />
</div>
}
/>
) : (
<div>
<hr className="my-6 border-grid-bright" />
<div className="font-sans text-lg font-normal text-text-bright">{children}</div>
</div>
<div className="font-sans text-lg font-normal text-text-bright">{children}</div>
)}
</>
);
@@ -0,0 +1,17 @@
import { Input } from "~/components/primitives/Input";
import { TextArea } from "~/components/primitives/TextArea";
export default function Story() {
return (
<div className="flex gap-16">
<div>
<div className="m-8 flex w-64 flex-col gap-4">
<TextArea placeholder="6 rows (default)" autoFocus />
<Input placeholder="Input" />
<TextArea placeholder="3 rows" rows={3} />
<TextArea disabled placeholder="Disabled" />
</div>
</div>
</div>
);
}
+8 -4
View File
@@ -126,16 +126,20 @@ const stories: Story[] = [
slug: "date-fields",
},
{
name: "Simple form",
slug: "simple-form",
name: "Input fields",
slug: "input-fields",
},
{
name: "Search fields",
slug: "search-fields",
},
{
name: "Input fields",
slug: "input-fields",
name: "Simple form",
slug: "simple-form",
},
{
name: "Textarea",
slug: "textarea",
},
{
sectionTitle: "Menus",
+75 -28
View File
@@ -1,52 +1,56 @@
import { json } from "@remix-run/server-runtime";
import { Prettify } from "@trigger.dev/core";
import { SignJWT, errors, jwtVerify } from "jose";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import {
RuntimeEnvironment,
findEnvironmentByApiKey,
findEnvironmentByPublicApiKey,
} from "~/models/runtimeEnvironment.server";
import { logger } from "./logger.server";
import {
PersonalAccessTokenAuthenticationResult,
authenticateApiRequestWithPersonalAccessToken,
isPersonalAccessToken,
} from "./personalAccessToken.server";
import { prisma } from "~/db.server";
import { json } from "@remix-run/server-runtime";
import { findProjectByRef } from "~/models/project.server";
import { SignJWT, jwtVerify, errors } from "jose";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
});
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export type AuthenticatedEnvironment = Optional<
NonNullable<Awaited<ReturnType<typeof findEnvironmentByApiKey>>>,
"orgMember"
>;
type ApiAuthenticationResult = {
export type ApiAuthenticationResult = {
apiKey: string;
type: "PUBLIC" | "PRIVATE";
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
environment: AuthenticatedEnvironment;
scopes?: string[];
};
export async function authenticateApiRequest(
request: Request,
{ allowPublicKey = false }: { allowPublicKey?: boolean } = {}
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, { allowPublicKey });
return authenticateApiKey(apiKey, options);
}
export async function authenticateApiKey(
apiKey: string,
{ allowPublicKey = false }: { allowPublicKey?: boolean } = {}
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const result = getApiKeyResult(apiKey);
@@ -54,14 +58,12 @@ export async function authenticateApiKey(
return;
}
//if it's a public API key and we don't allow public keys, return
if (!allowPublicKey) {
const environment = await findEnvironmentByApiKey(result.apiKey);
if (!environment) return;
return {
...result,
environment,
};
if (!options.allowPublicKey && result.type === "PUBLIC") {
return;
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return;
}
switch (result.type) {
@@ -81,27 +83,72 @@ export async function authenticateApiKey(
environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults) {
return;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
...result,
environment: validationResults.environment,
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
};
}
}
}
export async function authenticateAuthorizationHeader(
authorization: string,
{
allowPublicKey = false,
allowJWT = false,
}: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromHeader(authorization);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
}
export function isPublicApiKey(key: string) {
return key.startsWith("pk_");
}
export function getApiKeyFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
export function isSecretApiKey(key: string) {
return key.startsWith("tr_");
}
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
export function getApiKeyFromRequest(request: Request) {
return getApiKeyFromHeader(request.headers.get("Authorization"));
}
export function getApiKeyFromHeader(authorization?: string | null) {
if (typeof authorization !== "string" || !authorization) {
return;
}
const apiKey = authorization.data.replace(/^Bearer /, "");
const apiKey = authorization.replace(/^Bearer /, "");
return apiKey;
}
export function getApiKeyResult(apiKey: string) {
const type = isPublicApiKey(apiKey) ? ("PUBLIC" as const) : ("PRIVATE" as const);
export function getApiKeyResult(apiKey: string): {
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
} {
const type = isPublicApiKey(apiKey)
? "PUBLIC"
: isSecretApiKey(apiKey)
? "PRIVATE"
: isPublicJWT(apiKey)
? "PUBLIC_JWT"
: "PRIVATE"; // Fallback to private key
return { apiKey, type };
}
+43 -143
View File
@@ -1,150 +1,48 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { RedisOptions } from "ioredis";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { Duration, Limiter, RateLimiter, createRedisRateLimitClient } from "./rateLimiter.server";
type Options = {
redis?: RedisOptions;
keyPrefix: string;
pathMatchers: (RegExp | string)[];
pathWhiteList?: (RegExp | string)[];
limiter: Limiter;
log?: {
requests?: boolean;
rejections?: boolean;
};
};
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
export function authorizationRateLimitMiddleware({
redis,
keyPrefix,
limiter,
pathMatchers,
pathWhiteList = [],
log = {
rejections: true,
requests: true,
},
}: Options) {
const rateLimiter = new RateLimiter({
redis,
keyPrefix,
limiter,
logSuccess: log.requests,
logFailure: log.rejections,
});
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
}
// allow OPTIONS requests
if (req.method.toUpperCase() === "OPTIONS") {
return next();
}
//first check if any of the pathMatchers match the request path
const path = req.path;
if (
!pathMatchers.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
}
return next();
}
// Check if the path matches any of the whitelisted paths
if (
pathWhiteList.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
}
return next();
}
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
}
const authorizationValue = req.headers.authorization;
if (!authorizationValue) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
}
res.setHeader("Content-Type", "application/problem+json");
return res.status(401).send(
JSON.stringify(
{
title: "Unauthorized",
status: 401,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
detail: "No authorization header provided",
error: "No authorization header provided",
},
null,
2
)
);
}
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedAuthorizationValue = hash.digest("hex");
const { success, pending, limit, reset, remaining } = await rateLimiter.limit(
hashedAuthorizationValue
);
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
res.set("x-ratelimit-limit", limit.toString());
res.set("x-ratelimit-remaining", $remaining.toString());
res.set("x-ratelimit-reset", reset.toString());
if (success) {
return next();
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
remaining,
secondsUntilReset,
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
)
);
};
}
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import { Duration } from "./rateLimiter.server";
export const apiRateLimiter = authorizationRateLimitMiddleware({
redis: {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
keyPrefix: "api",
limiter: Ratelimit.tokenBucket(
env.API_RATE_LIMIT_REFILL_RATE,
env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
env.API_RATE_LIMIT_MAX
),
defaultLimiter: {
type: "tokenBucket",
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.API_RATE_LIMIT_MAX,
},
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
},
limiterConfigOverride: async (authorizationValue) => {
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
allowPublicKey: true,
allowJWT: true,
});
if (!authenticatedEnv) {
return;
}
if (authenticatedEnv.type === "PUBLIC_JWT") {
return {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
};
} else {
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
}
},
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
@@ -159,11 +57,13 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
/^\/api\/v1\/endpoints\/[^\/]+\/[^\/]+\/index\/[^\/]+$/, // /api/v1/endpoints/$environmentId/$endpointSlug/index/$indexHookIdentifier
"/api/v1/timezones",
"/api/v1/usage/ingest",
"/api/v1/auth/jwt/claims",
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
},
});
@@ -0,0 +1,103 @@
export type AuthorizationAction = "read"; // Add more actions as needed
const ResourceTypes = ["tasks", "tags", "runs", "batch"] as const;
export type AuthorizationResources = {
[key in (typeof ResourceTypes)[number]]?: string | string[];
};
export type AuthorizationEntity = {
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
scopes?: string[];
};
/**
* Checks if the given entity is authorized to perform a specific action on a resource.
*
* @param entity - The entity requesting authorization.
* @param action - The action the entity wants to perform.
* @param resource - The resource on which the action is to be performed.
* @param superScopes - An array of super scopes that can bypass the normal authorization checks.
*
* @example
*
* ```typescript
* import { checkAuthorization } from "./authorization.server";
*
* const entity = {
* type: "PUBLIC",
* scope: ["read:runs:run_1234", "read:tasks"]
* };
*
* checkAuthorization(entity, "read", { runs: "run_1234" }); // Returns true
* checkAuthorization(entity, "read", { runs: "run_5678" }); // Returns false
* checkAuthorization(entity, "read", { tasks: "task_1234" }); // Returns true
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
* ```
*/
export function checkAuthorization(
entity: AuthorizationEntity,
action: AuthorizationAction,
resource: AuthorizationResources,
superScopes?: string[]
) {
// "PRIVATE" is a secret key and has access to everything
if (entity.type === "PRIVATE") {
return true;
}
// "PUBLIC" is a deprecated key and has no access
if (entity.type === "PUBLIC") {
return false;
}
// If the entity has no permissions, deny access
if (!entity.scopes || entity.scopes.length === 0) {
return false;
}
// If the resource object is empty, deny access
if (Object.keys(resource).length === 0) {
return false;
}
// Check for any of the super scopes
if (superScopes && superScopes.length > 0) {
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
return true;
}
}
const filteredResource = Object.keys(resource).reduce((acc, key) => {
if (ResourceTypes.includes(key)) {
acc[key as keyof AuthorizationResources] = resource[key as keyof AuthorizationResources];
}
return acc;
}, {} as AuthorizationResources);
// Check each resource type
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
let resourceAuthorized = false;
for (const value of resourceValues) {
// Check for specific resource permission
const specificPermission = `${action}:${resourceType}:${value}`;
// Check for general resource type permission
const generalPermission = `${action}:${resourceType}`;
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
resourceAuthorized = true;
break;
}
}
// If any resource is not authorized, return false
if (!resourceAuthorized) {
return false;
}
}
// All resources are authorized
return true;
}
@@ -0,0 +1,301 @@
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { RedisOptions } from "ioredis";
import { createHash } from "node:crypto";
import { z } from "zod";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { createRedisRateLimitClient, Duration, RateLimiter } from "./rateLimiter.server";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
const DurationSchema = z.custom<Duration>((value) => {
if (typeof value !== "string") {
throw new Error("Duration must be a string");
}
return value as Duration;
});
export const RateLimitFixedWindowConfig = z.object({
type: z.literal("fixedWindow"),
window: DurationSchema,
tokens: z.number(),
});
export type RateLimitFixedWindowConfig = z.infer<typeof RateLimitFixedWindowConfig>;
export const RateLimitSlidingWindowConfig = z.object({
type: z.literal("slidingWindow"),
window: DurationSchema,
tokens: z.number(),
});
export type RateLimitSlidingWindowConfig = z.infer<typeof RateLimitSlidingWindowConfig>;
export const RateLimitTokenBucketConfig = z.object({
type: z.literal("tokenBucket"),
refillRate: z.number(),
interval: DurationSchema,
maxTokens: z.number(),
});
export type RateLimitTokenBucketConfig = z.infer<typeof RateLimitTokenBucketConfig>;
export const RateLimiterConfig = z.discriminatedUnion("type", [
RateLimitFixedWindowConfig,
RateLimitSlidingWindowConfig,
RateLimitTokenBucketConfig,
]);
export type RateLimiterConfig = z.infer<typeof RateLimiterConfig>;
type LimitConfigOverrideFunction = (authorizationValue: string) => Promise<unknown>;
type Options = {
redis?: RedisOptions;
keyPrefix: string;
pathMatchers: (RegExp | string)[];
pathWhiteList?: (RegExp | string)[];
defaultLimiter: RateLimiterConfig;
limiterConfigOverride?: LimitConfigOverrideFunction;
limiterCache?: {
fresh: number;
stale: number;
};
log?: {
requests?: boolean;
rejections?: boolean;
limiter?: boolean;
};
};
async function resolveLimitConfig(
authorizationValue: string,
hashedAuthorizationValue: string,
defaultLimiter: RateLimiterConfig,
cache: UnkeyCache<{ limiter: RateLimiterConfig }>,
logsEnabled: boolean,
limiterConfigOverride?: LimitConfigOverrideFunction
): Promise<RateLimiterConfig> {
if (!limiterConfigOverride) {
return defaultLimiter;
}
if (logsEnabled) {
logger.info("RateLimiter: checking for override", {
authorizationValue: hashedAuthorizationValue,
defaultLimiter,
});
}
const cacheResult = await cache.limiter.swr(hashedAuthorizationValue, async (key) => {
const override = await limiterConfigOverride(authorizationValue);
if (!override) {
if (logsEnabled) {
logger.info("RateLimiter: no override found", {
authorizationValue,
defaultLimiter,
});
}
return defaultLimiter;
}
const parsedOverride = RateLimiterConfig.safeParse(override);
if (!parsedOverride.success) {
logger.error("Error parsing rate limiter override", {
override,
errors: parsedOverride.error.errors,
});
return defaultLimiter;
}
if (logsEnabled && parsedOverride.data) {
logger.info("RateLimiter: override found", {
authorizationValue,
defaultLimiter,
override: parsedOverride.data,
});
}
return parsedOverride.data;
});
return cacheResult.val ?? defaultLimiter;
}
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
export function authorizationRateLimitMiddleware({
redis,
keyPrefix,
defaultLimiter,
pathMatchers,
pathWhiteList = [],
log = {
rejections: true,
requests: true,
},
limiterCache,
limiterConfigOverride,
}: Options) {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
...redis,
},
});
// This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
const cache = createCache({
limiter: new Namespace<RateLimiterConfig>(ctx, {
stores: [memory, redisCacheStore],
fresh: limiterCache?.fresh ?? 30_000,
stale: limiterCache?.stale ?? 60_000,
}),
});
const redisClient = createRedisRateLimitClient(
redis ?? {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
}
);
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
}
// allow OPTIONS requests
if (req.method.toUpperCase() === "OPTIONS") {
return next();
}
//first check if any of the pathMatchers match the request path
const path = req.path;
if (
!pathMatchers.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
}
return next();
}
// Check if the path matches any of the whitelisted paths
if (
pathWhiteList.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
}
return next();
}
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
}
const authorizationValue = req.headers.authorization;
if (!authorizationValue) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
}
res.setHeader("Content-Type", "application/problem+json");
return res.status(401).send(
JSON.stringify(
{
title: "Unauthorized",
status: 401,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
detail: "No authorization header provided",
error: "No authorization header provided",
},
null,
2
)
);
}
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedAuthorizationValue = hash.digest("hex");
const limiterConfig = await resolveLimitConfig(
authorizationValue,
hashedAuthorizationValue,
defaultLimiter,
cache,
typeof log.limiter === "boolean" ? log.limiter : false,
limiterConfigOverride
);
const limiter =
limiterConfig.type === "fixedWindow"
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
: limiterConfig.type === "tokenBucket"
? Ratelimit.tokenBucket(
limiterConfig.refillRate,
limiterConfig.interval,
limiterConfig.maxTokens
)
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
const rateLimiter = new RateLimiter({
redisClient,
keyPrefix,
limiter,
logSuccess: log.requests,
logFailure: log.rejections,
});
const { success, limit, reset, remaining } = await rateLimiter.limit(hashedAuthorizationValue);
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
res.set("x-ratelimit-limit", limit.toString());
res.set("x-ratelimit-remaining", $remaining.toString());
res.set("x-ratelimit-reset", reset.toString());
if (success) {
return next();
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
remaining,
secondsUntilReset,
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
)
);
};
}
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
+94 -25
View File
@@ -1,5 +1,13 @@
import { BillingClient, Limits, SetPlanBody, UsageSeriesParams } from "@trigger.dev/platform/v3";
import { Organization, Project } from "@trigger.dev/database";
import {
BillingClient,
Limits,
SetPlanBody,
UsageSeriesParams,
UsageResult,
} from "@trigger.dev/platform/v3";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { redirect } from "remix-typedjson";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
@@ -7,10 +15,61 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { createEnvironment } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
import { singleton } from "~/utils/singleton";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
function initializeClient() {
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
const client = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`🤑 Billing client initialized: ${process.env.BILLING_API_URL}`);
return client;
} else {
console.log(`🤑 Billing client not initialized`);
}
}
const client = singleton("billingClient", initializeClient);
function initializePlatformCache() {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:platform:v3",
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
});
// This cache holds the limits fetched from the platform service
const cache = createCache({
limits: new Namespace<number>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 5, // 5 minutes
stale: 60_000 * 10, // 10 minutes
}),
usage: new Namespace<UsageResult>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 5, // 5 minutes
stale: 60_000 * 10, // 10 minutes
}),
});
return cache;
}
const platformCache = singleton("platformCache", initializePlatformCache);
export async function getCurrentPlan(orgId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
@@ -60,8 +119,8 @@ export async function getCurrentPlan(orgId: string) {
}
export async function getLimits(orgId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
if (!result.success) {
@@ -87,9 +146,15 @@ export async function getLimit(orgId: string, limit: keyof Limits, fallback: num
return fallback;
}
export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) {
return platformCache.limits.swr(`${orgId}:${limit}`, async () => {
return getLimit(orgId, limit, fallback);
});
}
export async function customerPortalUrl(orgId: string, orgSlug: string) {
const client = getClient();
if (!client) return undefined;
try {
return client.createPortalSession(orgId, {
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
@@ -101,8 +166,8 @@ export async function customerPortalUrl(orgId: string, orgSlug: string) {
}
export async function getPlans() {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.plans();
if (!result.success) {
@@ -122,7 +187,6 @@ export async function setPlan(
callerPath: string,
plan: SetPlanBody
) {
const client = getClient();
if (!client) {
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
}
@@ -178,8 +242,8 @@ export async function setPlan(
}
export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.usage(organizationId, { from, to });
if (!result.success) {
@@ -193,9 +257,27 @@ export async function getUsage(organizationId: string, { from, to }: { from: Dat
}
}
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
const client = getClient();
export async function getCachedUsage(
organizationId: string,
{ from, to }: { from: Date; to: Date }
) {
if (!client) return undefined;
const result = await platformCache.usage.swr(
`${organizationId}:${from.toISOString()}:${to.toISOString()}`,
async () => {
const usageResponse = await getUsage(organizationId, { from, to });
return usageResponse;
}
);
return result.val;
}
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
if (!client) return undefined;
try {
const result = await client.usageSeries(organizationId, params);
if (!result.success) {
@@ -214,8 +296,8 @@ export async function reportInvocationUsage(
costInCents: number,
additionalData?: Record<string, any>
) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.reportInvocationUsage({
organizationId,
@@ -234,8 +316,8 @@ export async function reportInvocationUsage(
}
export async function reportComputeUsage(request: Request) {
const client = getClient();
if (!client) return undefined;
return fetch(`${process.env.BILLING_API_URL}/api/v1/usage/ingest/compute`, {
method: "POST",
headers: request.headers,
@@ -244,8 +326,8 @@ export async function reportComputeUsage(request: Request) {
}
export async function getEntitlement(organizationId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.getEntitlement(organizationId);
if (!result.success) {
@@ -275,19 +357,6 @@ export async function projectCreated(organization: Organization, project: Projec
}
}
function getClient() {
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
const client = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`Billing client initialized: ${process.env.BILLING_API_URL}`);
return client;
} else {
console.log(`Billing client not initialized`);
}
}
function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
+16 -19
View File
@@ -5,6 +5,7 @@ import { logger } from "./logger.server";
type Options = {
redis?: RedisOptions;
redisClient?: RateLimiterRedisClient;
keyPrefix: string;
limiter: Limiter;
logSuccess?: boolean;
@@ -14,34 +15,32 @@ type Options = {
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
export type RateLimiterRedisClient = ConstructorParameters<typeof Ratelimit>[0]["redis"];
export class RateLimiter {
#ratelimit: Ratelimit;
constructor(private readonly options: Options) {
const { redis, keyPrefix, limiter } = options;
const { redis, redisClient, keyPrefix, limiter } = options;
const prefix = `ratelimit:${keyPrefix}`;
this.#ratelimit = new Ratelimit({
redis: createRedisRateLimitClient(
redis ?? {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
}
),
redis:
redisClient ??
createRedisRateLimitClient(
redis ?? {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
}
),
limiter,
ephemeralCache: new Map(),
analytics: false,
prefix,
});
logger.info(`RateLimiter (${keyPrefix}): initialized`, {
keyPrefix,
redisKeyspace: prefix,
});
}
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
@@ -71,9 +70,7 @@ export class RateLimiter {
}
}
export function createRedisRateLimitClient(
redisOptions: RedisOptions
): ConstructorParameters<typeof Ratelimit>[0]["redis"] {
export function createRedisRateLimitClient(redisOptions: RedisOptions): RateLimiterRedisClient {
const redis = new Redis(redisOptions);
return {
@@ -0,0 +1,85 @@
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
export async function validatePublicJwtKey(token: string) {
// Get the sub claim from the token
// Use the sub claim to find the environment
// Validate the token against the environment.apiKey
// Once that's done, return the environment and the claims
const sub = extractJWTSub(token);
if (!sub) {
return;
}
const environment = await findEnvironmentById(sub);
if (!environment) {
return;
}
const claims = await validateJWT(token, environment.apiKey);
if (!claims) {
return;
}
return {
environment,
claims,
};
}
export function isPublicJWT(token: string): boolean {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return false;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return false;
// Check for the pub: true claim
return "pub" in payload && payload.pub === true;
} catch (error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return false;
}
}
function extractJWTSub(token: string): string | undefined {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return;
// Check for the pub: true claim
return "sub" in payload && typeof payload.sub === "string" ? payload.sub : undefined;
} catch (error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return;
}
}
function decodeBase64Url(str: string): string {
// Replace URL-safe characters and add padding
str = str.replace(/-/g, "+").replace(/_/g, "/");
switch (str.length % 4) {
case 2:
str += "==";
break;
case 3:
str += "=";
break;
}
// Decode using Node.js Buffer
return Buffer.from(str, "base64").toString("utf8");
}
@@ -0,0 +1,253 @@
import { json } from "@remix-run/server-runtime";
import Redis, { Callback, Result, type RedisOptions } from "ioredis";
import { randomUUID } from "node:crypto";
import { longPollingFetch } from "~/utils/longPollingFetch";
import { logger } from "./logger.server";
export interface CachedLimitProvider {
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
}
export type RealtimeClientOptions = {
electricOrigin: string;
redis: RedisOptions;
cachedLimitProvider: CachedLimitProvider;
keyPrefix: string;
expiryTimeInSeconds?: number;
};
export type RealtimeEnvironment = {
id: string;
organizationId: string;
};
export type RealtimeRunsParams = {
tags?: string[];
};
export class RealtimeClient {
private redis: Redis;
private expiryTimeInSeconds: number;
private cachedLimitProvider: CachedLimitProvider;
constructor(private options: RealtimeClientOptions) {
this.redis = new Redis(options.redis);
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5; // default to 5 minutes
this.cachedLimitProvider = options.cachedLimitProvider;
this.#registerCommands();
}
async streamRun(url: URL | string, environment: RealtimeEnvironment, runId: string) {
return this.#streamRunsWhere(url, environment, `id='${runId}'`);
}
async streamBatch(url: URL | string, environment: RealtimeEnvironment, batchId: string) {
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`);
}
async streamRuns(
url: URL | string,
environment: RealtimeEnvironment,
params: RealtimeRunsParams
) {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
if (params.tags) {
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
}
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(url, environment, whereClause);
}
async #streamRunsWhere(url: URL | string, environment: RealtimeEnvironment, whereClause: string) {
const electricUrl = this.#constructElectricUrl(url, whereClause);
return this.#performElectricRequest(electricUrl, environment);
}
#constructElectricUrl(url: URL | string, whereClause: string): URL {
const $url = new URL(url.toString());
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape/public."TaskRun"`);
// Copy over all the url search params to the electric url
$url.searchParams.forEach((value, key) => {
electricUrl.searchParams.set(key, value);
});
// const electricParams = ["shape_id", "live", "offset", "columns", "cursor"];
// electricParams.forEach((param) => {
// if ($url.searchParams.has(param) && $url.searchParams.get(param)) {
// electricUrl.searchParams.set(param, $url.searchParams.get(param)!);
// }
// });
electricUrl.searchParams.set("where", whereClause);
return electricUrl;
}
async #performElectricRequest(url: URL, environment: RealtimeEnvironment) {
const shapeId = extractShapeId(url);
logger.debug("[realtimeClient] request", {
url: url.toString(),
});
if (!shapeId) {
// If the shapeId is not present, we're just getting the initial value
return longPollingFetch(url.toString());
}
const isLive = isLiveRequestUrl(url);
if (!isLive) {
return longPollingFetch(url.toString());
}
const requestId = randomUUID();
// We now need to wrap the longPollingFetch in a concurrency tracker
const concurrencyLimit = await this.cachedLimitProvider.getCachedLimit(
environment.organizationId,
100_000
);
if (!concurrencyLimit) {
logger.error("Failed to get concurrency limit", {
organizationId: environment.organizationId,
});
return json({ error: "Failed to get concurrency limit" }, { status: 500 });
}
logger.debug("[realtimeClient] increment and check", {
concurrencyLimit,
shapeId,
requestId,
environment: {
id: environment.id,
organizationId: environment.organizationId,
},
});
const canProceed = await this.#incrementAndCheck(environment.id, requestId, concurrencyLimit);
if (!canProceed) {
logger.debug("[realtimeClient] too many concurrent requests", {
requestId,
environmentId: environment.id,
});
return json({ error: "Too many concurrent requests" }, { status: 429 });
}
try {
// ... (rest of your existing code for the long polling request)
const response = await longPollingFetch(url.toString());
// Decrement the counter after the long polling request is complete
await this.#decrementConcurrency(environment.id, requestId);
return response;
} catch (error) {
// Decrement the counter if the request fails
await this.#decrementConcurrency(environment.id, requestId);
throw error;
}
}
async #incrementAndCheck(environmentId: string, requestId: string, limit: number) {
const key = this.#getKey(environmentId);
const now = Date.now();
const result = await this.redis.incrementAndCheckConcurrency(
key,
now.toString(),
requestId,
this.expiryTimeInSeconds.toString(), // expiry time
(now - this.expiryTimeInSeconds * 1000).toString(), // cutoff time
limit.toString()
);
return result === 1;
}
async #decrementConcurrency(environmentId: string, requestId: string) {
logger.debug("[realtimeClient] decrement", {
requestId,
environmentId,
});
const key = this.#getKey(environmentId);
await this.redis.zrem(key, requestId);
}
#getKey(environmentId: string): string {
return `${this.options.keyPrefix}:${environmentId}`;
}
#registerCommands() {
this.redis.defineCommand("incrementAndCheckConcurrency", {
numberOfKeys: 1,
lua: /* lua */ `
local concurrencyKey = KEYS[1]
local timestamp = tonumber(ARGV[1])
local requestId = ARGV[2]
local expiryTime = tonumber(ARGV[3])
local cutoffTime = tonumber(ARGV[4])
local limit = tonumber(ARGV[5])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
-- Add the new request to the sorted set
redis.call('ZADD', concurrencyKey, timestamp, requestId)
-- Set the expiry time on the key
redis.call('EXPIRE', concurrencyKey, expiryTime)
-- Get the total number of concurrent requests
local totalRequests = redis.call('ZCARD', concurrencyKey)
-- Check if the limit has been exceeded
if totalRequests > limit then
-- Remove the request we just added
redis.call('ZREM', concurrencyKey, requestId)
return 0
end
-- Return 1 to indicate success
return 1
`,
});
}
}
function extractShapeId(url: URL) {
return url.searchParams.get("shape_id");
}
function isLiveRequestUrl(url: URL) {
return url.searchParams.has("live") && url.searchParams.get("live") === "true";
}
declare module "ioredis" {
interface RedisCommander<Context> {
incrementAndCheckConcurrency(
key: string,
timestamp: string,
requestId: string,
expiryTime: string,
cutoffTime: string,
limit: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
@@ -0,0 +1,32 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { RealtimeClient } from "./realtimeClient.server";
import { getCachedLimit } from "./platform.v3.server";
function initializeRealtimeClient() {
return new RealtimeClient({
electricOrigin: env.ELECTRIC_ORIGIN,
keyPrefix: "tr:realtime:concurrency",
redis: {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
cachedLimitProvider: {
async getCachedLimit(organizationId, defaultValue) {
const result = await getCachedLimit(
organizationId,
"realtimeConcurrentConnections",
defaultValue
);
return result.val;
},
},
});
}
export const realtimeClient = singleton("realtimeClient", initializeRealtimeClient);
@@ -0,0 +1,260 @@
import { z } from "zod";
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import {
AuthorizationAction,
AuthorizationResources,
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.server";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
} from "../personalAccessToken.server";
type ApiKeyRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined
) => AuthorizationResources;
superScopes?: string[];
};
};
type ApiKeyHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
authentication: ApiAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
>(
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
if (!checkAuthorization(authenticationResult, action, $resource, superScopes)) {
return wrapResponse(
request,
json({ error: "Unauthorized" }, { status: 403 }),
corsStrategy !== "none"
);
}
}
try {
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
type PATRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
corsStrategy?: "all" | "none";
};
type PATHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderPATApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
>(
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
corsStrategy = "none",
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
try {
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
function wrapResponse(request: Request, response: Response, useCors: boolean) {
return useCors ? apiCors(request, response) : response;
}
@@ -0,0 +1,97 @@
import { Err, Ok, type Result } from "@unkey/error";
import type { Entry, Store } from "@unkey/cache/stores";
import type { RedisOptions } from "ioredis";
import { Redis } from "ioredis";
import { CacheError } from "@unkey/cache";
export type RedisCacheStoreConfig = {
connection: RedisOptions;
};
export class RedisCacheStore<TNamespace extends string, TValue = any>
implements Store<TNamespace, TValue>
{
public readonly name = "redis";
private readonly redis: Redis;
constructor(config: RedisCacheStoreConfig) {
this.redis = new Redis(config.connection);
}
private buildCacheKey(namespace: TNamespace, key: string): string {
return [namespace, key].join("::");
}
public async get(
namespace: TNamespace,
key: string
): Promise<Result<Entry<TValue> | undefined, CacheError>> {
let raw: string | null;
try {
raw = await this.redis.get(this.buildCacheKey(namespace, key));
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
if (!raw) {
return Promise.resolve(Ok(undefined));
}
try {
const superjson = await import("superjson");
const entry = superjson.parse(raw) as Entry<TValue>;
return Ok(entry);
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
}
public async set(
namespace: TNamespace,
key: string,
entry: Entry<TValue>
): Promise<Result<void, CacheError>> {
const cacheKey = this.buildCacheKey(namespace, key);
try {
const superjson = await import("superjson");
await this.redis.set(cacheKey, superjson.stringify(entry), "PXAT", entry.staleUntil);
return Ok();
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
}
public async remove(namespace: TNamespace, key: string): Promise<Result<void, CacheError>> {
try {
const cacheKey = this.buildCacheKey(namespace, key);
await this.redis.del(cacheKey);
return Promise.resolve(Ok());
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
}
}
+16 -1
View File
@@ -10,10 +10,25 @@ type CorsOptions = {
credentials?: boolean;
};
export function apiCors(
export async function apiCors(
request: Request,
response: Response,
options: CorsOptions = { maxAge: 5 * 60 }
): Promise<Response> {
if (hasCorsHeaders(response)) {
return response;
}
return cors(request, response, options);
}
export function makeApiCors(
request: Request,
options: CorsOptions = { maxAge: 5 * 60 }
): (response: Response) => Promise<Response> {
return (response: Response) => apiCors(request, response, options);
}
function hasCorsHeaders(response: Response) {
return response.headers.has("access-control-allow-origin");
}
+4 -11
View File
@@ -10,23 +10,16 @@ export async function longPollingFetch(url: string, options?: RequestInit) {
try {
let response = await fetch(url, options);
// Check if the response is ok (status in the range 200-299)
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP error! status: ${response.status}. ${body}`);
}
if (response.headers.get(`content-encoding`)) {
if (response.headers.get("content-encoding")) {
const headers = new Headers(response.headers);
headers.delete(`content-encoding`);
headers.delete(`content-length`);
headers.delete("content-encoding");
headers.delete("content-length");
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
headers,
});
}
return response;
} catch (error) {
if (error instanceof TypeError) {
+59
View File
@@ -0,0 +1,59 @@
import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
import { env } from "~/env.server";
type Input = {
userId: string;
email: string;
name: string;
title: string;
components: ReturnType<typeof uiComponent.text>[];
};
export async function sendToPlain({ userId, email, name, title, components }: Input) {
if (!env.PLAIN_API_KEY) {
return;
}
const client = new PlainClient({
apiKey: env.PLAIN_API_KEY,
});
const upsertCustomerRes = await client.upsertCustomer({
identifier: {
emailAddress: email,
},
onCreate: {
externalId: userId,
fullName: name,
email: {
email: email,
isVerified: true,
},
},
onUpdate: {
externalId: { value: userId },
fullName: { value: name },
email: {
email: email,
isVerified: true,
},
},
});
if (upsertCustomerRes.error) {
console.error("Failed to upsert customer in Plain", upsertCustomerRes.error);
return;
}
const createThreadRes = await client.createThread({
customerIdentifier: {
customerId: upsertCustomerRes.data.customer.id,
},
title: title,
components: components,
});
if (createThreadRes.error) {
console.error("Failed to create thread in Plain", createThreadRes.error);
}
}
@@ -135,8 +135,8 @@ export class EnvironmentVariablesRepository implements Repository {
}
try {
const result = await $transaction(this.prismaClient, async (tx) => {
for (const variable of values) {
for (const variable of values) {
const result = await $transaction(this.prismaClient, async (tx) => {
const environmentVariable = await tx.environmentVariable.upsert({
where: {
projectId_key: {
@@ -195,8 +195,8 @@ export class EnvironmentVariablesRepository implements Repository {
secret: variable.value,
});
}
}
});
});
}
return {
success: true as const,
@@ -732,7 +732,7 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen
},
{
key: "TRIGGER_API_URL",
value: env.APP_ORIGIN,
value: env.API_ORIGIN ?? env.APP_ORIGIN,
},
{
key: "TRIGGER_RUNTIME_WAIT_THRESHOLD_IN_MS",
+241 -31
View File
@@ -1,15 +1,41 @@
import { sanitizeError, TaskRunFailedExecutionResult } from "@trigger.dev/core/v3";
import {
calculateNextRetryDelay,
RetryOptions,
TaskRunExecution,
TaskRunExecutionRetry,
TaskRunFailedExecutionResult,
} from "@trigger.dev/core/v3";
import { logger } from "~/services/logger.server";
import { createExceptionPropertiesFromError, eventRepository } from "./eventRepository.server";
import { BaseService } from "./services/baseService.server";
import { FinalizeTaskRunService } from "./services/finalizeTaskRun.server";
import { FAILABLE_RUN_STATUSES } from "./taskStatus";
import { isFailableRunStatus, isFinalAttemptStatus } from "./taskStatus";
import type { Prisma, TaskRun } from "@trigger.dev/database";
import { CompleteAttemptService } from "./services/completeAttempt.server";
import { CreateTaskRunAttemptService } from "./services/createTaskRunAttempt.server";
import { sharedQueueTasks } from "./marqs/sharedQueueConsumer.server";
import * as semver from "semver";
const includeAttempts = {
attempts: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
lockedBy: true, // task
lockedToVersion: true, // worker
} satisfies Prisma.TaskRunInclude;
type TaskRunWithAttempts = Prisma.TaskRunGetPayload<{
include: typeof includeAttempts;
}>;
export class FailedTaskRunService extends BaseService {
public async call(anyRunId: string, completion: TaskRunFailedExecutionResult) {
logger.debug("[FailedTaskRunService] Handling failed task run", { anyRunId, completion });
const isFriendlyId = anyRunId.startsWith("run_");
const taskRun = await this._prisma.taskRun.findUnique({
const taskRun = await this._prisma.taskRun.findFirst({
where: {
friendlyId: isFriendlyId ? anyRunId : undefined,
id: !isFriendlyId ? anyRunId : undefined,
@@ -25,7 +51,7 @@ export class FailedTaskRunService extends BaseService {
return;
}
if (!FAILABLE_RUN_STATUSES.includes(taskRun.status)) {
if (!isFailableRunStatus(taskRun.status)) {
logger.error("[FailedTaskRunService] Task run is not in a failable state", {
taskRun,
completion,
@@ -34,33 +60,217 @@ export class FailedTaskRunService extends BaseService {
return;
}
// No more retries, we need to fail the task run
logger.debug("[FailedTaskRunService] Failing task run", { taskRun, completion });
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRun.id,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
attemptStatus: "FAILED",
error: sanitizeError(completion.error),
const retryHelper = new FailedTaskRunRetryHelper(this._prisma);
const retryResult = await retryHelper.call({
runId: taskRun.id,
completion,
});
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: new Date(),
properties: {
exception: createExceptionPropertiesFromError(completion.error),
},
},
],
logger.debug("[FailedTaskRunService] Completion result", {
runId: taskRun.id,
result: retryResult,
});
}
}
interface TaskRunWithWorker extends TaskRun {
lockedBy: { retryConfig: Prisma.JsonValue } | null;
lockedToVersion: { sdkVersion: string } | null;
}
export class FailedTaskRunRetryHelper extends BaseService {
async call({
runId,
completion,
isCrash,
}: {
runId: string;
completion: TaskRunFailedExecutionResult;
isCrash?: boolean;
}) {
const taskRun = await this._prisma.taskRun.findFirst({
where: {
id: runId,
},
include: includeAttempts,
});
if (!taskRun) {
logger.error("[FailedTaskRunRetryHelper] Task run not found", {
runId,
completion,
});
return "NO_TASK_RUN";
}
const retriableExecution = await this.#getRetriableAttemptExecution(taskRun, completion);
if (!retriableExecution) {
return "NO_EXECUTION";
}
logger.debug("[FailedTaskRunRetryHelper] Completing attempt", { taskRun, completion });
const completeAttempt = new CompleteAttemptService({
prisma: this._prisma,
isSystemFailure: !isCrash,
isCrash,
});
const completeResult = await completeAttempt.call({
completion,
execution: retriableExecution,
});
return completeResult;
}
async #getRetriableAttemptExecution(
run: TaskRunWithAttempts,
completion: TaskRunFailedExecutionResult
): Promise<TaskRunExecution | undefined> {
let attempt = run.attempts[0];
// We need to create an attempt if:
// - None exists yet
// - The last attempt has a final status, e.g. we failed between attempts
if (!attempt || isFinalAttemptStatus(attempt.status)) {
logger.debug("[FailedTaskRunRetryHelper] No attempts found", {
run,
completion,
});
const createAttempt = new CreateTaskRunAttemptService(this._prisma);
try {
const { execution } = await createAttempt.call({
runId: run.id,
// This ensures we correctly respect `maxAttempts = 1` when failing before the first attempt was created
startAtZero: true,
});
return execution;
} catch (error) {
logger.error("[FailedTaskRunRetryHelper] Failed to create attempt", {
run,
completion,
error,
});
return;
}
}
// We already have an attempt with non-final status, let's use it
try {
const executionPayload = await sharedQueueTasks.getExecutionPayloadFromAttempt({
id: attempt.id,
skipStatusChecks: true,
});
return executionPayload?.execution;
} catch (error) {
logger.error("[FailedTaskRunRetryHelper] Failed to get execution payload", {
run,
completion,
error,
});
return;
}
}
static async getExecutionRetry({
run,
execution,
}: {
run: TaskRunWithWorker;
execution: TaskRunExecution;
}): Promise<TaskRunExecutionRetry | undefined> {
try {
const retryConfig = run.lockedBy?.retryConfig;
if (!retryConfig) {
if (!run.lockedToVersion) {
logger.error("[FailedTaskRunRetryHelper] Run not locked to version", {
run,
execution,
});
return;
}
const sdkVersion = run.lockedToVersion.sdkVersion ?? "0.0.0";
const isValid = semver.valid(sdkVersion);
if (!isValid) {
logger.error("[FailedTaskRunRetryHelper] Invalid SDK version", {
run,
execution,
});
return;
}
// With older SDK versions, tasks only have a retry config stored in the DB if it's explicitly defined on the task itself
// It won't get populated with retry.default in trigger.config.ts
if (semver.lt(sdkVersion, FailedTaskRunRetryHelper.DEFAULT_RETRY_CONFIG_SINCE_VERSION)) {
logger.warn(
"[FailedTaskRunRetryHelper] SDK version not recent enough to determine retry config",
{
run,
execution,
}
);
return;
}
}
const parsedRetryConfig = RetryOptions.nullable().safeParse(retryConfig);
if (!parsedRetryConfig.success) {
logger.error("[FailedTaskRunRetryHelper] Invalid retry config", {
run,
execution,
});
return;
}
if (!parsedRetryConfig.data) {
logger.debug("[FailedTaskRunRetryHelper] No retry config", {
run,
execution,
});
return;
}
const delay = calculateNextRetryDelay(parsedRetryConfig.data, execution.attempt.number);
if (!delay) {
logger.debug("[FailedTaskRunRetryHelper] No more retries", {
run,
execution,
});
return;
}
return {
timestamp: Date.now() + delay,
delay,
};
} catch (error) {
logger.error("[FailedTaskRunRetryHelper] Failed to get execution retry", {
run,
execution,
error,
});
return;
}
}
static DEFAULT_RETRY_CONFIG_SINCE_VERSION = "3.1.0";
}
+20 -9
View File
@@ -24,6 +24,7 @@ import { Redis } from "ioredis";
import { createAdapter } from "@socket.io/redis-adapter";
import { CrashTaskRunService } from "./services/crashTaskRun.server";
import { CreateTaskRunAttemptService } from "./services/createTaskRunAttempt.server";
import { UpdateFatalRunErrorService } from "./services/updateFatalRunError.server";
export const socketIo = singleton("socketIo", initalizeIoServer);
@@ -123,12 +124,13 @@ function createCoordinatorNamespace(io: Server) {
await resumeAttempt.call(message);
},
TASK_RUN_COMPLETED: async (message) => {
const completeAttempt = new CompleteAttemptService();
const completeAttempt = new CompleteAttemptService({
supportsRetryCheckpoints: message.version === "v1",
});
await completeAttempt.call({
completion: message.completion,
execution: message.execution,
checkpoint: message.checkpoint,
supportsRetryCheckpoints: message.version === "v1",
});
},
TASK_RUN_FAILED_TO_RUN: async (message) => {
@@ -193,9 +195,16 @@ function createCoordinatorNamespace(io: Server) {
}
const service = new CreateTaskRunAttemptService();
const { attempt } = await service.call(message.runId, environment, false);
const { attempt } = await service.call({
runId: message.runId,
authenticatedEnv: environment,
setToExecuting: false,
});
const payload = await sharedQueueTasks.getExecutionPayloadFromAttempt(attempt.id, true);
const payload = await sharedQueueTasks.getExecutionPayloadFromAttempt({
id: attempt.id,
setToExecuting: true,
});
if (!payload) {
logger.error("Failed to retrieve payload after attempt creation", message);
@@ -294,11 +303,13 @@ function createProviderNamespace(io: Server) {
handlers: {
WORKER_CRASHED: async (message) => {
try {
const service = new CrashTaskRunService();
await service.call(message.runId, {
...message,
});
if (message.overrideCompletion) {
const updateErrorService = new UpdateFatalRunErrorService();
await updateErrorService.call(message.runId, { ...message });
} else {
const crashRunService = new CrashTaskRunService();
await crashRunService.call(message.runId, { ...message });
}
} catch (error) {
logger.error("Error while handling crashed worker", { error });
}
@@ -4,6 +4,7 @@ import {
ProdTaskRunExecution,
ProdTaskRunExecutionPayload,
TaskRunError,
TaskRunErrorCodes,
TaskRunExecution,
TaskRunExecutionLazyAttemptPayload,
TaskRunExecutionResult,
@@ -21,7 +22,7 @@ import {
TaskRunStatus,
} from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { $replica, prisma } from "~/db.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
@@ -43,7 +44,12 @@ import { generateJWTTokenForEnvironment } from "~/services/apiAuth.server";
import { EnvironmentVariable } from "../environmentVariables/repository";
import { machinePresetFromConfig } from "../machinePresets.server";
import { env } from "~/env.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import {
FINAL_ATTEMPT_STATUSES,
FINAL_RUN_STATUSES,
isFinalAttemptStatus,
isFinalRunStatus,
} from "../taskStatus";
import { getMaxDuration } from "../utils/maxDuration";
const WithTraceContext = z.object({
@@ -504,13 +510,21 @@ export class SharedQueueConsumer {
if (!deployment.worker.supportsLazyAttempts) {
try {
const service = new CreateTaskRunAttemptService();
await service.call(lockedTaskRun.friendlyId, undefined, false);
await service.call({
runId: lockedTaskRun.id,
setToExecuting: false,
});
} catch (error) {
logger.error("Failed to create task run attempt for outdate worker", {
error,
taskRun: lockedTaskRun.id,
});
const service = new CrashTaskRunService();
await service.call(lockedTaskRun.id, {
errorCode: TaskRunErrorCodes.OUTDATED_SDK_VERSION,
});
await this.#ackAndDoMoreWork(message.messageId);
return;
}
@@ -620,6 +634,9 @@ export class SharedQueueConsumer {
const resumableRun = await prisma.taskRun.findUnique({
where: {
id: message.messageId,
status: {
notIn: FINAL_RUN_STATUSES,
},
},
});
@@ -633,6 +650,14 @@ export class SharedQueueConsumer {
return;
}
if (resumableRun.status !== "EXECUTING") {
logger.warn("Run is not executing, will try to resume anyway", {
queueMessage: message.data,
messageId: message.messageId,
runStatus: resumableRun.status,
});
}
const resumableAttempt = await prisma.taskRunAttempt.findUnique({
where: {
id: messageBody.data.resumableAttemptId,
@@ -718,9 +743,9 @@ export class SharedQueueConsumer {
completions.push(completion);
const executionPayload = await this._tasks.getExecutionPayloadFromAttempt(
completedAttempt.id
);
const executionPayload = await this._tasks.getExecutionPayloadFromAttempt({
id: completedAttempt.id,
});
if (!executionPayload) {
await this.#ackAndDoMoreWork(message.messageId);
@@ -740,7 +765,11 @@ export class SharedQueueConsumer {
executions,
};
logger.debug("Broadcasting RESUME_AFTER_DEPENDENCY_WITH_ACK", { resumeMessage, message });
logger.debug("Broadcasting RESUME_AFTER_DEPENDENCY_WITH_ACK", {
resumeMessage,
message,
resumableRun,
});
// The attempt should still be running so we can broadcast to all coordinators to resume immediately
const responses = await socketIo.coordinatorNamespace
@@ -763,15 +792,91 @@ export class SharedQueueConsumer {
}
const hasSuccess = responses.some((response) => response.success);
if (!hasSuccess) {
logger.warn("RESUME_AFTER_DEPENDENCY_WITH_ACK failed", {
resumeMessage,
responses,
message,
});
await this.#nackAndDoMoreWork(message.messageId, this._options.nextTickInterval, 5_000);
if (hasSuccess) {
this.#doMoreWork();
return;
}
// No coordinator was able to resume the run
logger.warn("RESUME_AFTER_DEPENDENCY_WITH_ACK failed", {
resumeMessage,
responses,
message,
});
// Let's check if the run is frozen
if (resumableRun.status === "WAITING_TO_RESUME") {
logger.debug("RESUME_AFTER_DEPENDENCY_WITH_ACK run is waiting to be restored", {
queueMessage: message.data,
messageId: message.messageId,
});
try {
const restoreService = new RestoreCheckpointService();
const checkpointEvent = await restoreService.getLastCheckpointEventIfUnrestored(
resumableRun.id
);
if (checkpointEvent) {
// The last checkpoint hasn't been restored yet, so restore it
const checkpoint = await restoreService.call({
eventId: checkpointEvent.id,
});
if (!checkpoint) {
logger.debug("RESUME_AFTER_DEPENDENCY_WITH_ACK failed to restore checkpoint", {
queueMessage: message.data,
messageId: message.messageId,
});
await this.#ackAndDoMoreWork(message.messageId);
return;
}
logger.debug("RESUME_AFTER_DEPENDENCY_WITH_ACK restored checkpoint", {
queueMessage: message.data,
messageId: message.messageId,
checkpoint,
});
this.#doMoreWork();
return;
} else {
logger.debug(
"RESUME_AFTER_DEPENDENCY_WITH_ACK run is frozen without last checkpoint event",
{
queueMessage: message.data,
messageId: message.messageId,
}
);
}
} catch (e) {
if (e instanceof Error) {
this._currentSpan?.recordException(e);
} else {
this._currentSpan?.recordException(new Error(String(e)));
}
this._endSpanInNextIteration = true;
await this.#nackAndDoMoreWork(
message.messageId,
this._options.nextTickInterval,
5_000
);
return;
}
}
logger.debug("RESUME_AFTER_DEPENDENCY_WITH_ACK retrying", {
queueMessage: message.data,
messageId: message.messageId,
});
await this.#nackAndDoMoreWork(message.messageId, this._options.nextTickInterval, 5_000);
return;
} catch (e) {
if (e instanceof Error) {
this._currentSpan?.recordException(e);
@@ -896,7 +1001,7 @@ class SharedQueueTasks {
where: {
id,
status: {
in: ["COMPLETED", "FAILED"],
in: FINAL_ATTEMPT_STATUSES,
},
},
include: {
@@ -942,11 +1047,17 @@ class SharedQueueTasks {
}
}
async getExecutionPayloadFromAttempt(
id: string,
setToExecuting?: boolean,
isRetrying?: boolean
): Promise<ProdTaskRunExecutionPayload | undefined> {
async getExecutionPayloadFromAttempt({
id,
setToExecuting,
isRetrying,
skipStatusChecks,
}: {
id: string;
setToExecuting?: boolean;
isRetrying?: boolean;
skipStatusChecks?: boolean;
}): Promise<ProdTaskRunExecutionPayload | undefined> {
const attempt = await prisma.taskRunAttempt.findUnique({
where: {
id,
@@ -979,27 +1090,29 @@ class SharedQueueTasks {
return;
}
switch (attempt.status) {
case "CANCELED":
case "EXECUTING": {
logger.error("Invalid attempt status for execution payload retrieval", {
attemptId: id,
status: attempt.status,
});
return;
if (!skipStatusChecks) {
switch (attempt.status) {
case "CANCELED":
case "EXECUTING": {
logger.error("Invalid attempt status for execution payload retrieval", {
attemptId: id,
status: attempt.status,
});
return;
}
}
}
switch (attempt.taskRun.status) {
case "CANCELED":
case "EXECUTING":
case "INTERRUPTED": {
logger.error("Invalid run status for execution payload retrieval", {
attemptId: id,
runId: attempt.taskRunId,
status: attempt.taskRun.status,
});
return;
switch (attempt.taskRun.status) {
case "CANCELED":
case "EXECUTING":
case "INTERRUPTED": {
logger.error("Invalid run status for execution payload retrieval", {
attemptId: id,
runId: attempt.taskRunId,
status: attempt.taskRun.status,
});
return;
}
}
}
@@ -1150,7 +1263,11 @@ class SharedQueueTasks {
return;
}
return this.getExecutionPayloadFromAttempt(latestAttempt.id, setToExecuting, isRetrying);
return this.getExecutionPayloadFromAttempt({
id: latestAttempt.id,
setToExecuting,
isRetrying,
});
}
async getLazyAttemptPayload(
@@ -1225,13 +1342,13 @@ class SharedQueueTasks {
return;
}
await marqs?.heartbeatMessage(taskRunAttempt.taskRunId);
await this.#heartbeat(taskRunAttempt.taskRunId);
}
async taskRunHeartbeat(runId: string) {
logger.debug("[SharedQueueConsumer] taskRunHeartbeat()", { runId });
await marqs?.heartbeatMessage(runId);
await this.#heartbeat(runId);
}
public async taskRunFailed(completion: TaskRunFailedExecutionResult) {
@@ -1242,6 +1359,66 @@ class SharedQueueTasks {
await service.call(completion.id, completion);
}
async #heartbeat(runId: string) {
await marqs?.heartbeatMessage(runId);
try {
// There can be a lot of calls per minute and the data doesn't have to be accurate, so use the read replica
const taskRun = await $replica.taskRun.findFirst({
where: {
id: runId,
},
select: {
id: true,
status: true,
runtimeEnvironment: {
select: {
type: true,
},
},
lockedToVersion: {
select: {
supportsLazyAttempts: true,
},
},
},
});
if (!taskRun) {
logger.error("SharedQueueTasks.#heartbeat: Task run not found", {
runId,
});
return;
}
if (taskRun.runtimeEnvironment.type === "DEVELOPMENT") {
return;
}
if (isFinalRunStatus(taskRun.status)) {
logger.debug("SharedQueueTasks.#heartbeat: Task run is in final status", {
runId,
status: taskRun.status,
});
// Signal to exit any leftover containers
socketIo.coordinatorNamespace.emit("REQUEST_RUN_CANCELLATION", {
version: "v1",
runId: taskRun.id,
// Give the run a few seconds to exit to complete any flushing etc
delayInMs: taskRun.lockedToVersion?.supportsLazyAttempts ? 5_000 : undefined,
});
return;
}
} catch (error) {
logger.error("SharedQueueTasks.#heartbeat: Error signaling run cancellation", {
runId,
error: error instanceof Error ? error.message : error,
});
}
}
async #buildEnvironmentVariables(
environment: RuntimeEnvironment,
runId: string,
+40 -2
View File
@@ -6,11 +6,30 @@ import { FailedTaskRunService } from "./failedTaskRun.server";
import { BaseService } from "./services/baseService.server";
import { PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { socketIo } from "./handleSocketIo.server";
import { TaskRunErrorCodes } from "@trigger.dev/core/v3";
export class RequeueTaskRunService extends BaseService {
public async call(runId: string) {
const taskRun = await this._prisma.taskRun.findUnique({
where: { id: runId },
where: {
id: runId,
},
select: {
id: true,
friendlyId: true,
status: true,
runtimeEnvironment: {
select: {
type: true,
},
},
lockedToVersion: {
select: {
supportsLazyAttempts: true,
},
},
},
});
if (!taskRun) {
@@ -41,7 +60,7 @@ export class RequeueTaskRunService extends BaseService {
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: "TASK_RUN_HEARTBEAT_TIMEOUT",
code: TaskRunErrorCodes.TASK_RUN_HEARTBEAT_TIMEOUT,
message: "Did not receive a heartbeat from the worker in time",
},
});
@@ -76,6 +95,25 @@ export class RequeueTaskRunService extends BaseService {
await marqs?.acknowledgeMessage(taskRun.id);
try {
if (taskRun.runtimeEnvironment.type === "DEVELOPMENT") {
return;
}
// Signal to exit any leftover containers
socketIo.coordinatorNamespace.emit("REQUEST_RUN_CANCELLATION", {
version: "v1",
runId: taskRun.id,
// Give the run a few seconds to exit to complete any flushing etc
delayInMs: taskRun.lockedToVersion?.supportsLazyAttempts ? 5_000 : undefined,
});
} catch (error) {
logger.error("[RequeueTaskRunService] Error signaling run cancellation", {
runId: taskRun.id,
error: error instanceof Error ? error.message : error,
});
}
break;
}
default: {
@@ -1,6 +1,7 @@
import { Attributes } from "@opentelemetry/api";
import {
TaskRunContext,
TaskRunErrorCodes,
TaskRunExecution,
TaskRunExecutionResult,
TaskRunExecutionRetry,
@@ -8,6 +9,8 @@ import {
TaskRunSuccessfulExecutionResult,
flattenAttributes,
sanitizeError,
shouldRetryError,
taskRunErrorEnhancer,
} from "@trigger.dev/core/v3";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
@@ -21,9 +24,10 @@ import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
import { CreateCheckpointService } from "./createCheckpoint.server";
import { TaskRun } from "@trigger.dev/database";
import { RetryAttemptService } from "./retryAttempt.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { FAILED_RUN_STATUSES, isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { env } from "~/env.server";
import { FailedTaskRunRetryHelper } from "../failedTaskRun.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
@@ -32,19 +36,28 @@ type CheckpointData = {
location: string;
};
type CompleteAttemptServiceOptions = {
prisma?: PrismaClientOrTransaction;
supportsRetryCheckpoints?: boolean;
isSystemFailure?: boolean;
isCrash?: boolean;
};
export class CompleteAttemptService extends BaseService {
constructor(private opts: CompleteAttemptServiceOptions = {}) {
super(opts.prisma);
}
public async call({
completion,
execution,
env,
checkpoint,
supportsRetryCheckpoints,
}: {
completion: TaskRunExecutionResult;
execution: TaskRunExecution;
env?: AuthenticatedEnvironment;
checkpoint?: CheckpointData;
supportsRetryCheckpoints?: boolean;
}): Promise<"COMPLETED" | "RETRIED"> {
const taskRunAttempt = await findAttempt(this._prisma, execution.attempt.id);
@@ -78,7 +91,7 @@ export class CompleteAttemptService extends BaseService {
attemptStatus: "FAILED",
error: {
type: "INTERNAL_ERROR",
code: "TASK_EXECUTION_FAILED",
code: TaskRunErrorCodes.TASK_EXECUTION_FAILED,
message: "Tried to complete attempt but it doesn't exist",
},
});
@@ -109,7 +122,6 @@ export class CompleteAttemptService extends BaseService {
taskRunAttempt,
env,
checkpoint,
supportsRetryCheckpoints,
});
}
}
@@ -169,14 +181,12 @@ export class CompleteAttemptService extends BaseService {
taskRunAttempt,
env,
checkpoint,
supportsRetryCheckpoints,
}: {
completion: TaskRunFailedExecutionResult;
execution: TaskRunExecution;
taskRunAttempt: NonNullable<FoundAttempt>;
env?: AuthenticatedEnvironment;
checkpoint?: CheckpointData;
supportsRetryCheckpoints?: boolean;
}): Promise<"COMPLETED" | "RETRIED"> {
if (
completion.error.type === "INTERNAL_ERROR" &&
@@ -194,18 +204,17 @@ export class CompleteAttemptService extends BaseService {
env
);
// The cancel service handles ACK
return "COMPLETED";
}
const failedAt = new Date();
const sanitizedError = sanitizeError(completion.error);
await this._prisma.taskRunAttempt.update({
where: { id: taskRunAttempt.id },
data: {
status: "FAILED",
completedAt: new Date(),
completedAt: failedAt,
error: sanitizedError,
usageDurationMs: completion.usage?.durationMs,
},
@@ -213,226 +222,230 @@ export class CompleteAttemptService extends BaseService {
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
const retryAt = new Date(completion.retry.timestamp);
// This means that tasks won't know they are being retried
let executionRetryInferred = false;
let executionRetry = completion.retry;
// Retry the task run
await eventRepository.recordEvent(`Retry #${execution.attempt.number} delay`, {
taskSlug: taskRunAttempt.taskRun.taskIdentifier,
const shouldInfer = this.opts.isCrash || this.opts.isSystemFailure;
if (!executionRetry && shouldInfer) {
executionRetryInferred = true;
executionRetry = await FailedTaskRunRetryHelper.getExecutionRetry({
run: {
...taskRunAttempt.taskRun,
lockedBy: taskRunAttempt.backgroundWorkerTask,
lockedToVersion: taskRunAttempt.backgroundWorker,
},
execution,
});
}
const retriableError = shouldRetryError(taskRunErrorEnhancer(completion.error));
if (
retriableError &&
executionRetry !== undefined &&
taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS
) {
return await this.#retryAttempt({
execution,
executionRetry,
executionRetryInferred,
taskRunAttempt,
environment,
attributes: {
metadata: this.#generateMetadataAttributesForNextAttempt(execution),
checkpoint,
});
}
// The attempt has failed and we won't retry
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: failedAt,
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: failedAt,
properties: {
retryAt: retryAt.toISOString(),
exception: createExceptionPropertiesFromError(sanitizedError),
},
runId: taskRunAttempt.taskRun.friendlyId,
style: {
icon: "schedule-attempt",
},
queueId: taskRunAttempt.queueId,
queueName: taskRunAttempt.taskRun.queue,
},
context: taskRunAttempt.taskRun.traceContext as Record<string, string | undefined>,
spanIdSeed: `retry-${taskRunAttempt.number + 1}`,
endTime: retryAt,
});
],
});
logger.debug("Retrying", {
taskRun: taskRunAttempt.taskRun.friendlyId,
retry: completion.retry,
});
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
error: sanitizedError,
},
});
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
status: "RETRYING_AFTER_FAILURE",
},
});
let status: FAILED_RUN_STATUSES;
if (environment.type === "DEVELOPMENT") {
// This is already an EXECUTE message so we can just NACK
await marqs?.nackMessage(taskRunAttempt.taskRunId, completion.retry.timestamp);
return "RETRIED";
}
if (!checkpoint) {
await this.#retryAttempt({
run: taskRunAttempt.taskRun,
retry: completion.retry,
supportsLazyAttempts: taskRunAttempt.backgroundWorker.supportsLazyAttempts,
supportsRetryCheckpoints,
});
return "RETRIED";
}
const createCheckpoint = new CreateCheckpointService(this._prisma);
const checkpointCreateResult = await createCheckpoint.call({
attemptFriendlyId: execution.attempt.id,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "RETRYING_AFTER_FAILURE",
attemptNumber: execution.attempt.number,
},
});
if (!checkpointCreateResult.success) {
logger.error("Failed to create checkpoint", { checkpoint, execution: execution.run.id });
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
});
return "COMPLETED";
}
await this.#retryAttempt({
run: taskRunAttempt.taskRun,
retry: completion.retry,
checkpointEventId: checkpointCreateResult.event.id,
supportsLazyAttempts: taskRunAttempt.backgroundWorker.supportsLazyAttempts,
supportsRetryCheckpoints,
});
return "RETRIED";
// Set the correct task run status
if (this.opts.isSystemFailure) {
status = "SYSTEM_FAILURE";
} else if (this.opts.isCrash) {
status = "CRASHED";
} else if (
sanitizedError.type === "INTERNAL_ERROR" &&
sanitizedError.code === "MAX_DURATION_EXCEEDED"
) {
status = "TIMED_OUT";
} else if (sanitizedError.type === "INTERNAL_ERROR") {
status = "CRASHED";
} else {
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: new Date(),
properties: {
exception: createExceptionPropertiesFromError(sanitizedError),
},
},
],
});
status = "COMPLETED_WITH_ERRORS";
}
if (
sanitizedError.type === "INTERNAL_ERROR" &&
sanitizedError.code === "GRACEFUL_EXIT_TIMEOUT"
) {
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
});
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status,
completedAt: failedAt,
});
// We need to fail all incomplete spans
const inProgressEvents = await eventRepository.queryIncompleteEvents({
attemptId: execution.attempt.id,
});
if (status !== "CRASHED" && status !== "SYSTEM_FAILURE") {
return "COMPLETED";
}
logger.debug("Failing in-progress events", {
const inProgressEvents = await eventRepository.queryIncompleteEvents({
runId: taskRunAttempt.taskRun.friendlyId,
});
// Handle in-progress events
switch (status) {
case "CRASHED": {
logger.debug("[CompleteAttemptService] Crashing in-progress events", {
inProgressEvents: inProgressEvents.map((event) => event.id),
});
const exception = {
type: "Graceful exit timeout",
message: sanitizedError.message,
};
await Promise.all(
inProgressEvents.map((event) => {
return eventRepository.crashEvent({
event: event,
crashedAt: new Date(),
exception,
event,
crashedAt: failedAt,
exception: createExceptionPropertiesFromError(sanitizedError),
});
})
);
} else {
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
error: sanitizedError,
},
});
const status =
sanitizedError.type === "INTERNAL_ERROR" &&
sanitizedError.code === "MAX_DURATION_EXCEEDED"
? "TIMED_OUT"
: "COMPLETED_WITH_ERRORS";
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status,
completedAt: new Date(),
});
break;
}
case "SYSTEM_FAILURE": {
logger.debug("[CompleteAttemptService] Failing in-progress events", {
inProgressEvents: inProgressEvents.map((event) => event.id),
});
return "COMPLETED";
await Promise.all(
inProgressEvents.map((event) => {
return eventRepository.completeEvent(event.spanId, {
endTime: failedAt,
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: failedAt,
properties: {
exception: createExceptionPropertiesFromError(sanitizedError),
},
},
],
});
})
);
}
}
return "COMPLETED";
}
async #retryAttempt({
async #enqueueReattempt({
run,
retry,
executionRetry,
executionRetryInferred,
checkpointEventId,
supportsLazyAttempts,
supportsRetryCheckpoints,
}: {
run: TaskRun;
retry: TaskRunExecutionRetry;
executionRetry: TaskRunExecutionRetry;
executionRetryInferred: boolean;
checkpointEventId?: string;
supportsLazyAttempts: boolean;
supportsRetryCheckpoints?: boolean;
}) {
const retryViaQueue = () => {
logger.debug("[CompleteAttemptService] Enqueuing retry attempt", { runId: run.id });
// We have to replace a potential RESUME with EXECUTE to correctly retry the attempt
return marqs?.replaceMessage(
run.id,
{
type: "EXECUTE",
taskIdentifier: run.taskIdentifier,
checkpointEventId: supportsRetryCheckpoints ? checkpointEventId : undefined,
retryCheckpointsDisabled: !supportsRetryCheckpoints,
checkpointEventId: this.opts.supportsRetryCheckpoints ? checkpointEventId : undefined,
retryCheckpointsDisabled: !this.opts.supportsRetryCheckpoints,
},
retry.timestamp
executionRetry.timestamp
);
};
const retryDirectly = () => {
return RetryAttemptService.enqueue(run.id, this._prisma, new Date(retry.timestamp));
logger.debug("[CompleteAttemptService] Retrying attempt directly", { runId: run.id });
return RetryAttemptService.enqueue(run.id, this._prisma, new Date(executionRetry.timestamp));
};
// There's a checkpoint, so we need to go through the queue
if (checkpointEventId) {
if (!supportsRetryCheckpoints) {
logger.error("Worker does not support retry checkpoints, but a checkpoint was created", {
runId: run.id,
checkpointEventId,
});
if (!this.opts.supportsRetryCheckpoints) {
logger.error(
"[CompleteAttemptService] Worker does not support retry checkpoints, but a checkpoint was created",
{
runId: run.id,
checkpointEventId,
}
);
}
logger.debug("[CompleteAttemptService] Enqueuing retry attempt with checkpoint", {
runId: run.id,
});
await retryViaQueue();
return;
}
// Workers without lazy attempt support always need to go through the queue, which is where the attempt is created
if (!supportsLazyAttempts) {
logger.debug("[CompleteAttemptService] Worker does not support lazy attempts", {
runId: run.id,
});
await retryViaQueue();
return;
}
// Workers that never checkpoint between attempts will exit after completing their current attempt if the retry delay exceeds the threshold
if (!supportsRetryCheckpoints && retry.delay >= env.CHECKPOINT_THRESHOLD_IN_MS) {
if (
!this.opts.supportsRetryCheckpoints &&
executionRetry.delay >= env.CHECKPOINT_THRESHOLD_IN_MS
) {
logger.debug(
"[CompleteAttemptService] Worker does not support retry checkpoints and the delay exceeds the threshold",
{ runId: run.id }
);
await retryViaQueue();
return;
}
if (executionRetryInferred) {
logger.debug("[CompleteAttemptService] Execution retry inferred, forcing retry via queue", {
runId: run.id,
});
await retryViaQueue();
return;
}
@@ -441,6 +454,141 @@ export class CompleteAttemptService extends BaseService {
await retryDirectly();
}
async #retryAttempt({
execution,
executionRetry,
executionRetryInferred,
taskRunAttempt,
environment,
checkpoint,
}: {
execution: TaskRunExecution;
executionRetry: TaskRunExecutionRetry;
executionRetryInferred: boolean;
taskRunAttempt: NonNullable<FoundAttempt>;
environment: AuthenticatedEnvironment;
checkpoint?: CheckpointData;
}) {
const retryAt = new Date(executionRetry.timestamp);
// Retry the task run
await eventRepository.recordEvent(`Retry #${execution.attempt.number} delay`, {
taskSlug: taskRunAttempt.taskRun.taskIdentifier,
environment,
attributes: {
metadata: this.#generateMetadataAttributesForNextAttempt(execution),
properties: {
retryAt: retryAt.toISOString(),
},
runId: taskRunAttempt.taskRun.friendlyId,
style: {
icon: "schedule-attempt",
},
queueId: taskRunAttempt.queueId,
queueName: taskRunAttempt.taskRun.queue,
},
context: taskRunAttempt.taskRun.traceContext as Record<string, string | undefined>,
spanIdSeed: `retry-${taskRunAttempt.number + 1}`,
endTime: retryAt,
});
logger.debug("[CompleteAttemptService] Retrying", {
taskRun: taskRunAttempt.taskRun.friendlyId,
retry: executionRetry,
});
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
status: "RETRYING_AFTER_FAILURE",
},
});
if (environment.type === "DEVELOPMENT") {
// This is already an EXECUTE message so we can just NACK
await marqs?.nackMessage(taskRunAttempt.taskRunId, executionRetry.timestamp);
return "RETRIED";
}
if (checkpoint) {
// This is only here for backwards compat - we don't checkpoint between attempts anymore
return await this.#retryAttemptWithCheckpoint({
execution,
taskRunAttempt,
executionRetry,
executionRetryInferred,
checkpoint,
});
}
await this.#enqueueReattempt({
run: taskRunAttempt.taskRun,
executionRetry,
supportsLazyAttempts: taskRunAttempt.backgroundWorker.supportsLazyAttempts,
executionRetryInferred,
});
return "RETRIED";
}
async #retryAttemptWithCheckpoint({
execution,
taskRunAttempt,
executionRetry,
executionRetryInferred,
checkpoint,
}: {
execution: TaskRunExecution;
taskRunAttempt: NonNullable<FoundAttempt>;
executionRetry: TaskRunExecutionRetry;
executionRetryInferred: boolean;
checkpoint: CheckpointData;
}) {
const createCheckpoint = new CreateCheckpointService(this._prisma);
const checkpointCreateResult = await createCheckpoint.call({
attemptFriendlyId: execution.attempt.id,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "RETRYING_AFTER_FAILURE",
attemptNumber: execution.attempt.number,
},
});
if (!checkpointCreateResult.success) {
logger.error("[CompleteAttemptService] Failed to create reattempt checkpoint", {
checkpoint,
runId: execution.run.id,
attemptId: execution.attempt.id,
});
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
error: {
type: "STRING_ERROR",
raw: "Failed to create reattempt checkpoint",
},
});
return "COMPLETED" as const;
}
await this.#enqueueReattempt({
run: taskRunAttempt.taskRun,
executionRetry,
checkpointEventId: checkpointCreateResult.event.id,
supportsLazyAttempts: taskRunAttempt.backgroundWorker.supportsLazyAttempts,
executionRetryInferred,
});
return "RETRIED" as const;
}
#generateMetadataAttributesForNextAttempt(execution: TaskRunExecution) {
const context = TaskRunContext.parse(execution);
@@ -475,6 +623,7 @@ async function findAttempt(prismaClient: PrismaClientOrTransaction, friendlyId:
select: {
id: true,
supportsLazyAttempts: true,
sdkVersion: true,
},
},
},
@@ -1,12 +1,12 @@
import { TaskRun, TaskRunAttempt } from "@trigger.dev/database";
import { eventRepository } from "../eventRepository.server";
import { marqs } from "~/v3/marqs/index.server";
import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { CRASHABLE_ATTEMPT_STATUSES, isCrashableRunStatus } from "../taskStatus";
import { sanitizeError, TaskRunInternalError } from "@trigger.dev/core/v3";
import { sanitizeError, TaskRunErrorCodes, TaskRunInternalError } from "@trigger.dev/core/v3";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { FailedTaskRunRetryHelper } from "../failedTaskRun.server";
export type CrashTaskRunServiceOptions = {
reason?: string;
@@ -29,6 +29,11 @@ export class CrashTaskRunService extends BaseService {
logger.debug("CrashTaskRunService.call", { runId, opts });
if (options?.overrideCompletion) {
logger.error("CrashTaskRunService.call: overrideCompletion is deprecated", { runId });
return;
}
const taskRun = await this._prisma.taskRun.findFirst({
where: {
id: runId,
@@ -36,16 +41,50 @@ export class CrashTaskRunService extends BaseService {
});
if (!taskRun) {
logger.error("Task run not found", { runId });
logger.error("[CrashTaskRunService] Task run not found", { runId });
return;
}
// Make sure the task run is in a crashable state
if (!opts.overrideCompletion && !isCrashableRunStatus(taskRun.status)) {
logger.error("Task run is not in a crashable state", { runId, status: taskRun.status });
logger.error("[CrashTaskRunService] Task run is not in a crashable state", {
runId,
status: taskRun.status,
});
return;
}
logger.debug("[CrashTaskRunService] Completing attempt", { runId, options });
const retryHelper = new FailedTaskRunRetryHelper(this._prisma);
const retryResult = await retryHelper.call({
runId,
completion: {
ok: false,
id: runId,
error: {
type: "INTERNAL_ERROR",
code: opts.errorCode ?? TaskRunErrorCodes.TASK_RUN_CRASHED,
message: opts.reason,
stackTrace: opts.logs,
},
},
isCrash: true,
});
logger.debug("[CrashTaskRunService] Completion result", { runId, retryResult });
if (retryResult === "RETRIED") {
logger.debug("[CrashTaskRunService] Retried task run", { runId });
return;
}
if (!opts.overrideCompletion) {
return;
}
logger.debug("[CrashTaskRunService] Overriding completion", { runId, options });
const finalizeService = new FinalizeTaskRunService();
const crashedTaskRun = await finalizeService.call({
id: taskRun.id,
@@ -74,7 +113,7 @@ export class CrashTaskRunService extends BaseService {
attemptStatus: "FAILED",
error: {
type: "INTERNAL_ERROR",
code: opts.errorCode ?? "TASK_RUN_CRASHED",
code: opts.errorCode ?? TaskRunErrorCodes.TASK_RUN_CRASHED,
message: opts.reason,
stackTrace: opts.logs,
},
@@ -87,7 +126,7 @@ export class CrashTaskRunService extends BaseService {
options?.overrideCompletion
);
logger.debug("Crashing in-progress events", {
logger.debug("[CrashTaskRunService] Crashing in-progress events", {
inProgressEvents: inProgressEvents.map((event) => event.id),
});
@@ -97,7 +136,7 @@ export class CrashTaskRunService extends BaseService {
event: event,
crashedAt: opts.crashedAt,
exception: {
type: opts.errorCode ?? "TASK_RUN_CRASHED",
type: opts.errorCode ?? TaskRunErrorCodes.TASK_RUN_CRASHED,
message: opts.reason,
stacktrace: opts.logs,
},
@@ -136,27 +175,29 @@ export class CrashTaskRunService extends BaseService {
code?: TaskRunInternalError["code"];
}
) {
return await this.traceWithEnv("failAttempt()", environment, async (span) => {
span.setAttribute("taskRunId", run.id);
span.setAttribute("attemptId", attempt.id);
return await this.traceWithEnv(
"[CrashTaskRunService] failAttempt()",
environment,
async (span) => {
span.setAttribute("taskRunId", run.id);
span.setAttribute("attemptId", attempt.id);
await marqs?.acknowledgeMessage(run.id);
await this._prisma.taskRunAttempt.update({
where: {
id: attempt.id,
},
data: {
status: "FAILED",
completedAt: failedAt,
error: sanitizeError({
type: "INTERNAL_ERROR",
code: error.code ?? "TASK_RUN_CRASHED",
message: error.reason,
stackTrace: error.logs,
}),
},
});
});
await this._prisma.taskRunAttempt.update({
where: {
id: attempt.id,
},
data: {
status: "FAILED",
completedAt: failedAt,
error: sanitizeError({
type: "INTERNAL_ERROR",
code: error.code ?? TaskRunErrorCodes.TASK_RUN_CRASHED,
message: error.reason,
stackTrace: error.logs,
}),
},
});
}
);
}
}
@@ -50,7 +50,7 @@ export class CreateCheckpointService extends BaseService {
});
if (!attempt) {
logger.error("Attempt not found", { attemptFriendlyId: params.attemptFriendlyId });
logger.error("Attempt not found", params);
return {
success: false,
@@ -70,6 +70,7 @@ export class CreateCheckpointService extends BaseService {
id: attempt.taskRunId,
status: attempt.taskRun.status,
},
params,
});
return {
@@ -84,6 +85,7 @@ export class CreateCheckpointService extends BaseService {
logger.error("Missing deployment or image ref", {
attemptId: attempt.id,
workerId: attempt.backgroundWorker.id,
params,
});
return {
@@ -170,6 +172,7 @@ export class CreateCheckpointService extends BaseService {
taskRunId: attempt.taskRunId,
type: "WAIT_FOR_TASK",
reason,
params,
});
await marqs?.cancelHeartbeat(attempt.taskRunId);
@@ -182,6 +185,7 @@ export class CreateCheckpointService extends BaseService {
if (!childRun) {
logger.error("CreateCheckpointService: WAIT_FOR_TASK child run not found", {
friendlyId: reason.friendlyId,
params,
});
return {
@@ -201,6 +205,7 @@ export class CreateCheckpointService extends BaseService {
childRun,
attempt,
checkpointEvent,
params,
});
} else {
logger.error("CreateCheckpointService: Failed to resume dependent parents", {
@@ -208,6 +213,7 @@ export class CreateCheckpointService extends BaseService {
childRun,
attempt,
checkpointEvent,
params,
});
}
@@ -233,6 +239,7 @@ export class CreateCheckpointService extends BaseService {
attemptId: attempt.id,
taskRunId: attempt.taskRunId,
type: "WAIT_FOR_BATCH",
params,
});
await marqs?.cancelHeartbeat(attempt.taskRunId);
@@ -248,6 +255,7 @@ export class CreateCheckpointService extends BaseService {
if (!batchRun) {
logger.error("CreateCheckpointService: Batch not found", {
friendlyId: reason.batchFriendlyId,
params,
});
return {
@@ -297,6 +305,7 @@ export class CreateCheckpointService extends BaseService {
logger.error("No checkpoint event", {
attemptId: attempt.id,
checkpointId: checkpoint.id,
params,
});
await marqs?.acknowledgeMessage(attempt.taskRunId);
@@ -12,11 +12,17 @@ import { CrashTaskRunService } from "./crashTaskRun.server";
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
export class CreateTaskRunAttemptService extends BaseService {
public async call(
runId: string,
authenticatedEnv?: AuthenticatedEnvironment,
setToExecuting = true
): Promise<{
public async call({
runId,
authenticatedEnv,
setToExecuting = true,
startAtZero = false,
}: {
runId: string;
authenticatedEnv?: AuthenticatedEnvironment;
setToExecuting?: boolean;
startAtZero?: boolean;
}): Promise<{
execution: TaskRunExecution;
run: TaskRun;
attempt: TaskRunAttempt;
@@ -102,7 +108,11 @@ export class CreateTaskRunAttemptService extends BaseService {
throw new ServiceValidationError("Queue not found", 404);
}
const nextAttemptNumber = taskRun.attempts[0] ? taskRun.attempts[0].number + 1 : 1;
const nextAttemptNumber = taskRun.attempts[0]
? taskRun.attempts[0].number + 1
: startAtZero
? 0
: 1;
if (nextAttemptNumber > MAX_TASK_RUN_ATTEMPTS) {
const service = new CrashTaskRunService(this._prisma);
@@ -3,11 +3,17 @@ import { type Prisma, type TaskRun } from "@trigger.dev/database";
import { logger } from "~/services/logger.server";
import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { FINAL_ATTEMPT_STATUSES, isFailedRunStatus, type FINAL_RUN_STATUSES } from "../taskStatus";
import {
FINAL_ATTEMPT_STATUSES,
isFailedRunStatus,
isFatalRunStatus,
type FINAL_RUN_STATUSES,
} from "../taskStatus";
import { PerformTaskRunAlertsService } from "./alerts/performTaskRunAlerts.server";
import { BaseService } from "./baseService.server";
import { ResumeDependentParentsService } from "./resumeDependentParents.server";
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
import { socketIo } from "../handleSocketIo.server";
type BaseInput = {
id: string;
@@ -90,6 +96,42 @@ export class FinalizeTaskRunService extends BaseService {
await PerformTaskRunAlertsService.enqueue(run.id, this._prisma);
}
if (isFatalRunStatus(run.status)) {
logger.error("FinalizeTaskRunService: Fatal status", { runId: run.id, status: run.status });
const extendedRun = await this._prisma.taskRun.findFirst({
where: { id: run.id },
select: {
id: true,
lockedToVersion: {
select: {
supportsLazyAttempts: true,
},
},
runtimeEnvironment: {
select: {
type: true,
},
},
},
});
if (extendedRun && extendedRun.runtimeEnvironment.type !== "DEVELOPMENT") {
logger.error("FinalizeTaskRunService: Fatal status, requesting worker exit", {
runId: run.id,
status: run.status,
});
// Signal to exit any leftover containers
socketIo.coordinatorNamespace.emit("REQUEST_RUN_CANCELLATION", {
version: "v1",
runId: run.id,
// Give the run a few seconds to exit to complete any flushing etc
delayInMs: extendedRun.lockedToVersion?.supportsLazyAttempts ? 5_000 : undefined,
});
}
}
return run as Output<T>;
}
@@ -111,83 +153,90 @@ export class FinalizeTaskRunService extends BaseService {
error?: TaskRunError;
run: TaskRun;
}) {
if (attemptStatus || error) {
const latestAttempt = await this._prisma.taskRunAttempt.findFirst({
where: { taskRunId: run.id },
orderBy: { id: "desc" },
take: 1,
if (!attemptStatus && !error) {
logger.error("FinalizeTaskRunService: No attemptStatus or error provided", { runId: run.id });
return;
}
const latestAttempt = await this._prisma.taskRunAttempt.findFirst({
where: { taskRunId: run.id },
orderBy: { id: "desc" },
take: 1,
});
if (latestAttempt) {
logger.debug("Finalizing run attempt", {
id: latestAttempt.id,
status: attemptStatus,
error,
});
if (latestAttempt) {
logger.debug("Finalizing run attempt", {
id: latestAttempt.id,
status: attemptStatus,
error,
});
await this._prisma.taskRunAttempt.update({
where: { id: latestAttempt.id },
data: { status: attemptStatus, error: error ? sanitizeError(error) : undefined },
});
await this._prisma.taskRunAttempt.update({
where: { id: latestAttempt.id },
data: { status: attemptStatus, error: error ? sanitizeError(error) : undefined },
});
} else {
logger.debug("Finalizing run no attempt found", {
runId: run.id,
attemptStatus,
error,
});
if (!run.lockedById) {
logger.error(
"FinalizeTaskRunService: No lockedById, so can't get the BackgroundWorkerTask. Not creating an attempt.",
{ runId: run.id }
);
return;
}
const workerTask = await this._prisma.backgroundWorkerTask.findFirst({
select: {
id: true,
workerId: true,
runtimeEnvironmentId: true,
},
where: {
id: run.lockedById,
},
});
if (!workerTask) {
logger.error("FinalizeTaskRunService: No worker task found", { runId: run.id });
return;
}
const queue = await this._prisma.taskQueue.findUnique({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: workerTask.runtimeEnvironmentId,
name: sanitizeQueueName(run.queue),
},
},
});
if (!queue) {
logger.error("FinalizeTaskRunService: No queue found", { runId: run.id });
return;
}
await this._prisma.taskRunAttempt.create({
data: {
number: 1,
friendlyId: generateFriendlyId("attempt"),
taskRunId: run.id,
backgroundWorkerId: workerTask?.workerId,
backgroundWorkerTaskId: workerTask?.id,
queueId: queue.id,
runtimeEnvironmentId: workerTask.runtimeEnvironmentId,
status: attemptStatus,
error: error ? sanitizeError(error) : undefined,
},
});
}
return;
}
// There's no attempt, so create one
logger.debug("Finalizing run no attempt found", {
runId: run.id,
attemptStatus,
error,
});
if (!run.lockedById) {
logger.error(
"FinalizeTaskRunService: No lockedById, so can't get the BackgroundWorkerTask. Not creating an attempt.",
{ runId: run.id }
);
return;
}
const workerTask = await this._prisma.backgroundWorkerTask.findFirst({
select: {
id: true,
workerId: true,
runtimeEnvironmentId: true,
},
where: {
id: run.lockedById,
},
});
if (!workerTask) {
logger.error("FinalizeTaskRunService: No worker task found", { runId: run.id });
return;
}
const queue = await this._prisma.taskQueue.findUnique({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: workerTask.runtimeEnvironmentId,
name: sanitizeQueueName(run.queue),
},
},
});
if (!queue) {
logger.error("FinalizeTaskRunService: No queue found", { runId: run.id });
return;
}
await this._prisma.taskRunAttempt.create({
data: {
number: 1,
friendlyId: generateFriendlyId("attempt"),
taskRunId: run.id,
backgroundWorkerId: workerTask?.workerId,
backgroundWorkerTaskId: workerTask?.id,
queueId: queue.id,
runtimeEnvironmentId: workerTask.runtimeEnvironmentId,
status: attemptStatus,
error: error ? sanitizeError(error) : undefined,
},
});
}
}
@@ -112,4 +112,24 @@ export class RestoreCheckpointService extends BaseService {
return checkpoint;
}
async getLastCheckpointEventIfUnrestored(runId: string) {
const event = await this._prisma.checkpointRestoreEvent.findFirst({
where: {
runId,
},
take: 1,
orderBy: {
createdAt: "desc",
},
});
if (!event) {
return;
}
if (event.type === "CHECKPOINT") {
return event;
}
}
}
@@ -2,23 +2,24 @@ import {
CoordinatorToPlatformMessages,
TaskRunExecution,
TaskRunExecutionResult,
WaitReason,
} from "@trigger.dev/core/v3";
import type { InferSocketMessageSchema } from "@trigger.dev/core/v3/zodSocket";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { logger } from "~/services/logger.server";
import { marqs } from "~/v3/marqs/index.server";
import { socketIo } from "../handleSocketIo.server";
import { SharedQueueMessageBody, sharedQueueTasks } from "../marqs/sharedQueueConsumer.server";
import { sharedQueueTasks } from "../marqs/sharedQueueConsumer.server";
import { BaseService } from "./baseService.server";
import { TaskRunAttempt } from "@trigger.dev/database";
import { isFinalRunStatus } from "../taskStatus";
export class ResumeAttemptService extends BaseService {
private _logger = logger;
public async call(
params: InferSocketMessageSchema<typeof CoordinatorToPlatformMessages, "READY_FOR_RESUME">
): Promise<void> {
logger.debug(`ResumeAttemptService.call()`, params);
this._logger.debug(`ResumeAttemptService.call()`, params);
await $transaction(this._prisma, async (tx) => {
const attempt = await tx.taskRunAttempt.findUnique({
@@ -77,16 +78,18 @@ export class ResumeAttemptService extends BaseService {
});
if (!attempt) {
logger.error("Could not find attempt", { attemptFriendlyId: params.attemptFriendlyId });
this._logger.error("Could not find attempt", params);
return;
}
this._logger = logger.child({
attemptId: attempt.id,
attemptFriendlyId: attempt.friendlyId,
taskRun: attempt.taskRun,
});
if (isFinalRunStatus(attempt.taskRun.status)) {
logger.error("Run is not resumable", {
attemptId: attempt.id,
runId: attempt.taskRunId,
status: attempt.taskRun.status,
});
this._logger.error("Run is not resumable");
return;
}
@@ -94,10 +97,7 @@ export class ResumeAttemptService extends BaseService {
switch (params.type) {
case "WAIT_FOR_DURATION": {
logger.debug("Sending duration wait resume message", {
attemptId: attempt.id,
attemptFriendlyId: params.attemptFriendlyId,
});
this._logger.debug("Sending duration wait resume message");
await this.#setPostResumeStatuses(attempt, tx);
@@ -114,13 +114,13 @@ export class ResumeAttemptService extends BaseService {
const dependentAttempt = attempt.dependencies[0].taskRun.attempts[0];
if (!dependentAttempt) {
logger.error("No dependent attempt", { attemptId: attempt.id });
this._logger.error("No dependent attempt");
return;
}
completedAttemptIds = [dependentAttempt.id];
} else {
logger.error("No task dependency", { attemptId: attempt.id });
this._logger.error("No task dependency");
return;
}
@@ -134,13 +134,13 @@ export class ResumeAttemptService extends BaseService {
const dependentBatchItems = attempt.batchDependencies[0].items;
if (!dependentBatchItems) {
logger.error("No dependent batch items", { attemptId: attempt.id });
this._logger.error("No dependent batch items");
return;
}
completedAttemptIds = dependentBatchItems.map((item) => item.taskRun.attempts[0]?.id);
} else {
logger.error("No batch dependency", { attemptId: attempt.id });
this._logger.error("No batch dependency");
return;
}
@@ -161,7 +161,7 @@ export class ResumeAttemptService extends BaseService {
tx: PrismaClientOrTransaction
) {
if (completedAttemptIds.length === 0) {
logger.error("No completed attempt IDs", { attemptId: attempt.id });
this._logger.error("No completed attempt IDs");
return;
}
@@ -184,38 +184,36 @@ export class ResumeAttemptService extends BaseService {
});
if (!completedAttempt) {
logger.error("Completed attempt not found", {
attemptId: attempt.id,
completedAttemptId,
});
this._logger.error("Completed attempt not found", { completedAttemptId });
await marqs?.acknowledgeMessage(attempt.taskRunId);
return;
}
const logger = this._logger.child({
completedAttemptId: completedAttempt.id,
completedAttemptFriendlyId: completedAttempt.friendlyId,
completedRunId: completedAttempt.taskRunId,
});
const completion = await sharedQueueTasks.getCompletionPayloadFromAttempt(
completedAttempt.id
);
if (!completion) {
logger.error("Failed to get completion payload", {
attemptId: attempt.id,
completedAttemptId,
});
logger.error("Failed to get completion payload");
await marqs?.acknowledgeMessage(attempt.taskRunId);
return;
}
completions.push(completion);
const executionPayload = await sharedQueueTasks.getExecutionPayloadFromAttempt(
completedAttempt.id
);
const executionPayload = await sharedQueueTasks.getExecutionPayloadFromAttempt({
id: completedAttempt.id,
skipStatusChecks: true, // already checked when getting the completion
});
if (!executionPayload) {
logger.error("Failed to get execution payload", {
attemptId: attempt.id,
completedAttemptId,
});
logger.error("Failed to get execution payload");
await marqs?.acknowledgeMessage(attempt.taskRunId);
return;
}
@@ -378,6 +378,7 @@ export class TriggerTaskService extends BaseService {
maxDurationInSeconds: body.options?.maxDuration
? clampMaxDuration(body.options.maxDuration)
: undefined,
runTags: bodyTags,
},
});

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