Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6fd62fb8 | |||
| e8993ee882 | |||
| e0cdebbe09 | |||
| d6c6dc993d | |||
| 27de884f93 | |||
| c2707800aa | |||
| dbda820a71 | |||
| e417aca879 | |||
| 5a3b4a450b | |||
| f91a118958 | |||
| 9971de6a1e | |||
| 0847bee5b8 | |||
| 6152b2f3dc | |||
| ec8381e2d0 | |||
| 76a15fd278 | |||
| 824a6205f7 | |||
| a0984d5292 | |||
| c61d8ed890 | |||
| 76a0f61dee | |||
| d934feb02a | |||
| 7fdedf3137 | |||
| f6e23e94d1 | |||
| 3d6cfe5ead | |||
| 946efd1fb4 | |||
| 3d645e2b2c | |||
| 45cb75b8a3 | |||
| 91d45e5ba6 | |||
| fa5b3fe11e | |||
| 90506322b5 | |||
| e59dd465d8 | |||
| 98d5f005e4 | |||
| a6b5d2edc5 | |||
| f4a1d5f36d | |||
| 6fb6cb6aaf | |||
| 0b6417f1d8 | |||
| a685f5a4ec | |||
| 62532d5ce1 | |||
| 786d5508f0 | |||
| 187f02780b | |||
| b8b3ea07eb | |||
| e341df806c | |||
| ecf1110ab7 | |||
| 9ed97b3996 | |||
| 2f14e5136d | |||
| 1249523922 | |||
| 9ba5dbaf91 | |||
| 16ad595338 | |||
| 681bb729e9 | |||
| 07fb3fd90d | |||
| 3bfebc06cf | |||
| 30f7866e17 | |||
| 46789292b5 | |||
| e04d44866f | |||
| d48d9a0e00 | |||
| 72f3a4b128 | |||
| 305a6cebd4 | |||
| c9ac6ef93f | |||
| d2d54c9bc4 | |||
| a440101af4 | |||
| 4f95c9de4e | |||
| b53a575464 | |||
| ad68a3cc05 | |||
| be58638904 | |||
| 8f43aecacc | |||
| 6fd7560d5c | |||
| eb3b7b6f9e | |||
| 7aed154854 | |||
| 50506dce9f | |||
| d5772e57bd | |||
| 14c2bdf89b | |||
| 7976d924fb | |||
| 1e667ec28f | |||
| 7e97dcb93e | |||
| b9b86c89a7 | |||
| cd5d2ae92b | |||
| 0e77e7ef7d | |||
| 76a5c6204f | |||
| b171fde483 | |||
| 5ae3da6b4e | |||
| f565829959 | |||
| d57dec6919 | |||
| 75ec4ac6a6 | |||
| 374b6b9c0c | |||
| d0d3a64bd6 | |||
| 568da01785 | |||
| c75e29a9a7 | |||
| e5d26bd12d | |||
| b6f31ab651 | |||
| 50d46a8513 | |||
| 4cc61ac0ec | |||
| a696359c3e | |||
| 52b6f48a94 | |||
| d22a460555 | |||
| 9ba2a217a4 | |||
| 39885a427f | |||
| ccb0bc510a | |||
| 56d66ee07c | |||
| 4ca8887972 | |||
| 89bffc066c | |||
| 34ca7667d3 | |||
| 3e327acc0f | |||
| 77ad4127cb | |||
| 8a5076aacf | |||
| 5399f6bfb7 | |||
| ecef199660 | |||
| 4acfb8f4bb | |||
| 2ef278db67 | |||
| c7a55804d9 | |||
| da6a66efff | |||
| 7c36a1a4b0 | |||
| 225effb599 | |||
| 98eb6ed4f9 | |||
| 3069ebf0d8 | |||
| e133e628ca | |||
| 098932ea96 | |||
| 65f960e883 | |||
| ccbeff47e6 | |||
| 7c8f2df105 | |||
| fd44dabfe0 | |||
| 5daed3f69d | |||
| 596bf78e55 | |||
| 6ca66b76f4 | |||
| 29ef0395ce | |||
| 55d1f8c677 | |||
| 9835f4ec55 | |||
| 7fae10db23 | |||
| 8cf1f0a37d | |||
| dba4313c5c | |||
| 506613dc92 | |||
| 764df23d19 | |||
| 4b961a6ae2 | |||
| 8757fdceef | |||
| 2404e88ac5 | |||
| 88b36f5090 | |||
| b73ae3f927 | |||
| b605b892ac | |||
| 233316f7e8 | |||
| 1b90ffbb8c | |||
| b45ca4e146 | |||
| 25d15578f7 | |||
| fe865a0f49 | |||
| 0ed93a748e | |||
| e02320f65d | |||
| 85a543d8ec | |||
| 10ceb85a92 | |||
| c405ae7117 | |||
| 3687fcb61e | |||
| d4ccdf7105 | |||
| e08b4569e5 | |||
| 79da0ca9b5 | |||
| 3aca603a33 | |||
| c9e97d6b78 | |||
| 01633c9c03 | |||
| 691990d79e | |||
| b2ba403dd3 | |||
| 1d47cab69f | |||
| e23047f9ad | |||
| 68d32429b6 | |||
| 36ac79ac66 | |||
| ca94f0cac3 | |||
| a5d8e453a5 | |||
| c332519e72 | |||
| 52112c3bfc | |||
| eae294a332 | |||
| 465cd0335c | |||
| 35dbaedf69 | |||
| c11a77f50b | |||
| fb52b9efea | |||
| 0896b9fffc | |||
| 3a2dd983c5 | |||
| a627ca67d1 | |||
| afc180aa70 | |||
| 393af1b7c5 | |||
| 6a91fb89b8 | |||
| df7d1de16d | |||
| 8e8ed4a3bf | |||
| 8fc8f57b39 | |||
| 9ebd91ccec | |||
| 665f7c9756 | |||
| 928a632e23 | |||
| 74db2de1bc | |||
| 93acca6c3c | |||
| ebe079d83c | |||
| d272996de3 | |||
| 531bd4970d | |||
| 5c9eb25b5a | |||
| c970e892a7 | |||
| a867b6e5ae | |||
| d44abbd0fc | |||
| 1cc680ac1e | |||
| 9b049bc480 | |||
| c24a23b551 | |||
| ee1ae1fca6 | |||
| 8e5ef176a4 | |||
| 58b6b1aa0d | |||
| 9c0ae1459f | |||
| 2f15a84320 | |||
| a49a0ff416 | |||
| b703ffed29 | |||
| b4f9b70ae2 | |||
| 51bb4c887a | |||
| ba71f959e2 | |||
| bc7bbd4576 | |||
| 5fe23e4b3f | |||
| 7b3b2e0d8e | |||
| 3900ddadce | |||
| ca9e827bd3 | |||
| 04e936b69b | |||
| 98ef170299 | |||
| e69ffd314a | |||
| 782d4f75ae | |||
| b6de469d07 | |||
| 0dd3447c31 | |||
| a5a5d3ae21 | |||
| ee3619bbb1 | |||
| d9ad72446e | |||
| a56f9af9fe | |||
| ece6ca678a | |||
| 6243ae30bb |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Increase span attribute value length limit to 2048
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add an e2e suite to test compiling with v3 CLI.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
cli v3: increase otel force flush timeout to 30s from 500ms
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Prevent uncaught exceptions when handling WebSocket messages
|
||||
- Improve CLI dev command WebSocket debug and error logging
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Increase dev worker timeout
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Improve prisma errors for missing postinstall
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add sox and audiowaveform binaries to worker images
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
try/catch opening the login URL
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Support triggering tasks with non-URL friendly characters in the ID
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Usage tracking
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Fix for calling trigger and passing a custom queue
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: recover from server rate limiting errors in a more reliable way
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: update @depot/cli to latest 0.0.1-cli.2.71.0
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Tasks should now be much more robust and resilient to reconnects during crucial operations and other failure scenarios.
|
||||
|
||||
Task runs now have to signal checkpointable state prior to ALL checkpoints. This ensures flushing always happens.
|
||||
|
||||
All important socket.io RPCs will now be retried with backoff. Actions relying on checkpoints will be replayed if we haven't been checkpointed and restored as expected, e.g. after reconnect.
|
||||
|
||||
Other changes:
|
||||
|
||||
- Fix retry check in shared queue
|
||||
- Fix env var sync spinner
|
||||
- Heartbeat between retries
|
||||
- Fix retry prep
|
||||
- Fix prod worker no tasks detection
|
||||
- Fail runs above `MAX_TASK_RUN_ATTEMPTS`
|
||||
- Additional debug logs in all places
|
||||
- Prevent crashes due to failed socket schema parsing
|
||||
- Remove core-apps barrel
|
||||
- Upgrade socket.io-client to fix an ACK memleak
|
||||
- Additional index failure logs
|
||||
- Prevent message loss during reconnect
|
||||
- Prevent burst of heartbeats on reconnect
|
||||
- Prevent crash on failed cleanup
|
||||
- Handle at-least-once lazy execute message delivery
|
||||
- Handle uncaught entry point exceptions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: Remove aggressive otel flush timeouts in dev/prod
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: Trigger delayed runs and reschedule them
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added config option extraCACerts to ProjectConfig type. This copies the ca file along with additionalFiles and sets NODE_EXTRA_CA_CERTS environment variable in built image as well as running the task.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Improve non-zero exit code error messages
|
||||
- Detect OOM conditions within worker child processes
|
||||
- Internal errors can have optional stack traces
|
||||
- Docker provider can be set to enforce machine presets
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Export AbortTaskRunError from @trigger.dev/sdk/v3
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix issue when using SDK in non-node environments by scoping the stream import with node:
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Output stderr logs on dev worker failure
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Use global setTimeout to ensure cross-runtime support
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add e2e fixtures corresponding to past issues
|
||||
Implement e2e suite parallelism
|
||||
Enhance log level for specific e2e suite messages
|
||||
+51
-1
@@ -46,10 +46,13 @@
|
||||
"changesets": [
|
||||
"afraid-sheep-joke",
|
||||
"angry-eagles-trade",
|
||||
"angry-trees-drop",
|
||||
"beige-pears-explode",
|
||||
"beige-pens-dance",
|
||||
"big-tomatoes-deliver",
|
||||
"blue-pumas-whisper",
|
||||
"breezy-gorillas-mate",
|
||||
"brown-spies-burn",
|
||||
"chilled-hornets-move",
|
||||
"clean-pianos-listen",
|
||||
"clever-apes-collect",
|
||||
@@ -58,70 +61,111 @@
|
||||
"cool-comics-burn",
|
||||
"cool-glasses-bake",
|
||||
"cuddly-feet-approve",
|
||||
"curly-monkeys-tell",
|
||||
"dry-walls-check",
|
||||
"dull-mangos-press",
|
||||
"eight-pumas-float",
|
||||
"eleven-paws-join",
|
||||
"famous-boats-tease",
|
||||
"fast-colts-relax",
|
||||
"few-students-share",
|
||||
"fifty-lions-think",
|
||||
"five-toes-destroy",
|
||||
"friendly-walls-repair",
|
||||
"funny-swans-destroy",
|
||||
"gorgeous-cycles-guess",
|
||||
"gorgeous-gorillas-compete",
|
||||
"green-bags-wink",
|
||||
"hot-buckets-behave",
|
||||
"hot-fishes-retire",
|
||||
"hot-wasps-sin",
|
||||
"itchy-chairs-itch",
|
||||
"khaki-apricots-design",
|
||||
"khaki-poems-lay",
|
||||
"late-icons-lie",
|
||||
"late-steaks-behave",
|
||||
"lazy-files-lay",
|
||||
"lemon-jobs-repair",
|
||||
"lemon-sloths-hide",
|
||||
"light-bulldogs-press",
|
||||
"light-dragons-complain",
|
||||
"little-crabs-cross",
|
||||
"long-fireants-search",
|
||||
"long-hounds-wave",
|
||||
"loud-actors-remember",
|
||||
"lovely-drinks-flash",
|
||||
"many-ligers-pump",
|
||||
"mighty-camels-joke",
|
||||
"mighty-eggs-grab",
|
||||
"mighty-flowers-train",
|
||||
"mighty-parrots-sin",
|
||||
"modern-stingrays-end",
|
||||
"nasty-jars-pump",
|
||||
"nervous-baboons-sin",
|
||||
"nervous-planets-sparkle",
|
||||
"nervous-seas-shave",
|
||||
"new-pants-beg",
|
||||
"new-rivers-tell",
|
||||
"nice-bulldogs-turn",
|
||||
"ninety-pets-travel",
|
||||
"odd-poets-own",
|
||||
"pink-pumas-rhyme",
|
||||
"plenty-ducks-beam",
|
||||
"polite-ducks-switch",
|
||||
"polite-pears-grow",
|
||||
"polite-pots-walk",
|
||||
"polite-rockets-matter",
|
||||
"poor-flowers-cross",
|
||||
"purple-garlics-shop",
|
||||
"purple-spiders-care",
|
||||
"rare-lamps-promise",
|
||||
"rare-roses-float",
|
||||
"real-planets-stare",
|
||||
"rich-kangaroos-unite",
|
||||
"rotten-beers-refuse",
|
||||
"rotten-dryers-exercise",
|
||||
"rude-toys-compare",
|
||||
"selfish-ducks-sort",
|
||||
"serious-hats-rest",
|
||||
"shaggy-spoons-taste",
|
||||
"shaggy-weeks-live",
|
||||
"sharp-emus-compare",
|
||||
"sharp-zebras-serve",
|
||||
"shiny-coats-cry",
|
||||
"silly-buses-obey",
|
||||
"silly-forks-kiss",
|
||||
"silly-suits-switch",
|
||||
"silver-doors-juggle",
|
||||
"six-ligers-exist",
|
||||
"six-rats-hunt",
|
||||
"sixty-insects-watch",
|
||||
"slow-buses-own",
|
||||
"slow-kiwis-hide",
|
||||
"slow-sloths-retire",
|
||||
"smart-needles-move",
|
||||
"smart-olives-eat",
|
||||
"sour-pugs-teach",
|
||||
"spicy-frogs-remain",
|
||||
"spicy-lamps-smoke",
|
||||
"spicy-terms-bow",
|
||||
"strange-ghosts-matter",
|
||||
"strange-sheep-pull",
|
||||
"strong-lemons-add",
|
||||
"strong-owls-know",
|
||||
"strong-phones-smoke",
|
||||
"stupid-adults-sniff",
|
||||
"stupid-bulldogs-applaud",
|
||||
"sweet-ducks-remember",
|
||||
"sweet-lizards-press",
|
||||
"swift-dragons-peel",
|
||||
"tall-bees-wave",
|
||||
"tall-masks-repeat",
|
||||
"tame-apricots-clap",
|
||||
"tame-guests-know",
|
||||
"tender-moose-tell",
|
||||
"tender-oranges-rhyme",
|
||||
"tender-turkeys-compete",
|
||||
"thick-carrots-sneeze",
|
||||
"thin-parents-heal",
|
||||
"thirty-islands-kiss",
|
||||
"tidy-balloons-suffer",
|
||||
@@ -130,8 +174,14 @@
|
||||
"tiny-doors-type",
|
||||
"tiny-elephants-scream",
|
||||
"tricky-bulldogs-heal",
|
||||
"tricky-keys-attack",
|
||||
"tricky-ladybugs-unite",
|
||||
"twelve-knives-notice",
|
||||
"two-pumas-wait",
|
||||
"warm-planes-taste"
|
||||
"violet-clocks-notice",
|
||||
"warm-olives-provide",
|
||||
"warm-planes-taste",
|
||||
"yellow-roses-arrive",
|
||||
"young-snails-sell"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Await file watcher cleanup in dev
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Capture and display stderr on index failures
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: [prod] force flush timeout should be 1s
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: sanitize errors with null unicode characters in some places
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add callback to checkpoint created message
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: Copy over more of the project's package.json keys into the deployed package.json (support for custom config like zenstack)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Make deduplicationKey required when creating/updating a schedule
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Improved ESM module require error detection logic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Set the deploy timeout to 3mins from 1min
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: vercel edge runtime support
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Fix uncaught provider exception
|
||||
- Remove unused provider messages
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: fix otel flushing causing CLEANUP ack timeout errors by always setting a forceFlushTimeoutMillis value
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
- Prevent downgrades during update check and advise to upgrade CLI
|
||||
- Detect bun and use npm instead
|
||||
- During init, fail early and advise if not a TypeScript project
|
||||
- During init, allow specifying custom package manager args
|
||||
- Add links to dev worker started message
|
||||
- Fix links in unsupported terminals
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
- Fix init command SDK pinning
|
||||
- Show --api-url / -a flag where needed
|
||||
- CLI now also respects `TRIGGER_TELEMETRY_DISABLED`
|
||||
- Dedicated docker checkpoint test function
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
fix: allow command login to read api url from cli args
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Management SDK overhaul and adding the runs.list API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Adding SDK functions for triggering tasks in a typesafe way, without importing task file
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Fix artifact detection logs
|
||||
- Fix OOM detection and error messages
|
||||
- Add test link to cli deployment completion
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: postInstall config option now replaces the postinstall script found in package.json
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added timezone support to schedules
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Include presigned urls for downloading large payloads and outputs when using runs.retrieve
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Clear paused states before retry
|
||||
- Detect and handle unrecoverable worker errors
|
||||
- Remove checkpoints after successful push
|
||||
- Permanently switch to DO hosted busybox image
|
||||
- Fix IPC timeout issue, or at least handle it more gracefully
|
||||
- Handle checkpoint failures
|
||||
- Basic chaos monkey for checkpoint testing
|
||||
- Stack traces are back in the dashboard
|
||||
- Display final errors on root span
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: fix missing init output in task run function when no middleware is defined
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix jsonc-parser import
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve handling of IPC timeouts and fix checkpoint cancellation after failures
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add more package exports that can be used from the web app
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Increase cleanup IPC timeout
|
||||
+3
-1
@@ -25,6 +25,8 @@ DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
@@ -69,7 +71,7 @@ COORDINATOR_SECRET=coordinator-secret # generate the actual secret with `openssl
|
||||
# OBJECT_STORE_BASE_URL="https://{bucket}.{accountId}.r2.cloudflarestorage.com"
|
||||
# OBJECT_STORE_ACCESS_KEY_ID=
|
||||
# OBJECT_STORE_SECRET_ACCESS_KEY=
|
||||
# RUNTIME_WAIT_THRESHOLD_IN_MS=10000
|
||||
# CHECKPOINT_THRESHOLD_IN_MS=10000
|
||||
|
||||
# These control the server-side internal telemetry
|
||||
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
name: "🧪 E2E Tests"
|
||||
name: "E2E"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
package:
|
||||
description: The identifier of the job to run
|
||||
default: webapp
|
||||
required: false
|
||||
type: string
|
||||
jobs:
|
||||
e2e:
|
||||
name: "🧪 E2E Tests"
|
||||
cli-v3:
|
||||
name: "🧪 CLI v3 tests"
|
||||
if: inputs.package == 'cli-v3' || inputs.package == ''
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package-manager: ["npm", "pnpm", "yarn"]
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile --filter trigger.dev...
|
||||
|
||||
- name: 🔧 Build v3 cli monorepo dependencies
|
||||
run: pnpm run build --filter trigger.dev^...
|
||||
|
||||
- name: 🔧 Build worker template files
|
||||
run: pnpm --filter trigger.dev run build:workers
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: |
|
||||
PM=${{ matrix.package-manager }} pnpm --filter trigger.dev run test:e2e
|
||||
webapp:
|
||||
name: "🧪 Webapp tests"
|
||||
if: inputs.package == 'webapp' || inputs.package == ''
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
@@ -19,7 +63,7 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
|
||||
@@ -29,4 +29,6 @@ jobs:
|
||||
|
||||
# e2e:
|
||||
# uses: ./.github/workflows/e2e.yml
|
||||
# with:
|
||||
# package: webapp
|
||||
# secrets: inherit
|
||||
|
||||
@@ -39,11 +39,25 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "::set-output name=version::${IMAGE_TAG}"
|
||||
|
||||
- name: 🔢 Get the commit hash
|
||||
id: get_commit
|
||||
run: |
|
||||
echo ::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-7)
|
||||
|
||||
- name: 📛 Set the tags
|
||||
id: set_tags
|
||||
run: |
|
||||
ref_without_tag=ghcr.io/triggerdotdev/trigger.dev
|
||||
image_tags=$ref_without_tag:${{ steps.get_version.outputs.version }}
|
||||
|
||||
# if it's a versioned tag, also tag it as v3
|
||||
if [[ "${{ github.ref_name }}" == v.docker.* ]]; then
|
||||
image_tags=$image_tags,$ref_without_tag:v3
|
||||
fi
|
||||
|
||||
echo "IMAGE_TAGS=${image_tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🐙 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -56,6 +70,5 @@ jobs:
|
||||
with:
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/triggerdotdev/trigger.dev:${{ steps.get_version.outputs.version }}
|
||||
tags: ${{ steps.set_tags.outputs.IMAGE_TAGS }}
|
||||
push: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: "🚢 Publish Infra Images"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
tags:
|
||||
- "infra-dev-*"
|
||||
@@ -29,9 +30,6 @@ permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
@@ -39,7 +37,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
package: [coordinator, kubernetes-provider]
|
||||
package: [coordinator, docker-provider, kubernetes-provider]
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
@@ -48,20 +46,40 @@ jobs:
|
||||
|
||||
- name: Generate image reference
|
||||
id: prep
|
||||
# WARNING: This step expects the workflow to have been triggered by a specific tag format of: infra-${env}-*
|
||||
run: |
|
||||
env=$(echo ${{ github.ref_name }} | cut -d- -f2)
|
||||
sha=${GITHUB_SHA::7}
|
||||
ts=$(date +%s)
|
||||
# set image repo
|
||||
if [[ "${{ matrix.package }}" == *-provider ]]; then
|
||||
provider_type=$(echo ${{ matrix.package }} | cut -d- -f1)
|
||||
provider_type=$(echo "${{ matrix.package }}" | cut -d- -f1)
|
||||
repository=provider/${provider_type}
|
||||
else
|
||||
repository=${{ matrix.package }}
|
||||
repository="${{ matrix.package }}"
|
||||
fi
|
||||
echo "IMAGE_TAG=${env}-${sha}-${ts}" >> "$GITHUB_OUTPUT"
|
||||
echo "REPOSITORY=${repository}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# set image tag
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "${{ github.ref_name }}" == infra-*-* ]]; then
|
||||
env=$(echo ${{ github.ref_name }} | cut -d- -f2)
|
||||
sha=$(echo ${{ github.sha }} | head -c7)
|
||||
ts=$(date +%s)
|
||||
image_tag=${env}-${sha}-${ts}
|
||||
elif [[ "${{ github.ref_name }}" == v.docker.* ]]; then
|
||||
version="${GITHUB_REF_NAME#v.docker.}"
|
||||
image_tag="v${version}"
|
||||
elif [[ "${{ github.ref_name }}" == build-* ]]; then
|
||||
image_tag="${GITHUB_REF_NAME#build-}"
|
||||
else
|
||||
echo "Invalid tag: ${{ github.ref_name }}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||
image_tag="main"
|
||||
else
|
||||
echo "Invalid reference: ${{ github.ref }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "IMAGE_TAG=${image_tag}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -92,3 +110,12 @@ jobs:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: ${{ steps.prep.outputs.REPOSITORY }}
|
||||
IMAGE_TAG: ${{ steps.prep.outputs.IMAGE_TAG }}
|
||||
|
||||
- name: 🐙 Push 'v3' tag to GitHub Container Registry
|
||||
if: startsWith(github.ref_name, 'v.docker.')
|
||||
run: |
|
||||
docker tag infra_image $REGISTRY/$REPOSITORY:v3
|
||||
docker push $REGISTRY/$REPOSITORY:v3
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: ${{ steps.prep.outputs.REPOSITORY }}
|
||||
|
||||
@@ -51,9 +51,16 @@ jobs:
|
||||
|
||||
# e2e:
|
||||
# uses: ./.github/workflows/e2e.yml
|
||||
# with:
|
||||
# package: cli-v3
|
||||
# secrets: inherit
|
||||
|
||||
publish:
|
||||
needs: [typecheck, units]
|
||||
uses: ./.github/workflows/publish-docker.yml
|
||||
secrets: inherit
|
||||
|
||||
publish-infra:
|
||||
needs: [typecheck, units]
|
||||
uses: ./.github/workflows/publish-infra.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
|
||||
@@ -5,64 +5,71 @@
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
### The open source background jobs platform
|
||||
### Open source background jobs with no timeouts
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
|
||||
|
||||
[](https://twitter.com/triggerdotdev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
|
||||
</div>
|
||||
|
||||
> The Trigger.dev v3 developer preview is now open. For more information and to get early access, check out our [developer preview launch post](https://trigger.dev/blog/v3-developer-preview-launch/).
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.
|
||||
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.
|
||||
|
||||
## Long running Jobs on serverless
|
||||
#### Features:
|
||||
|
||||
Reliably run jobs and don’t worry about function timeouts, we handle those for you.
|
||||
|
||||
- Auto-resume after a function timeout
|
||||
- Auto-resume after a server outage
|
||||
- Add delays of up to a year
|
||||
- JavaScript and TypeScript SDK
|
||||
- Write reliable code by default
|
||||
- No infrastructure to manage
|
||||
- Works with your existing tech stack
|
||||
|
||||
## In your codebase
|
||||
|
||||
Create Jobs where they belong: in your codebase. Version control, localhost, test, review, and deploy like you're already used to.
|
||||
Create tasks where they belong: in your codebase. Version control, localhost, test and review like you're already used to.
|
||||
|
||||
## Secure by design
|
||||
```ts
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
We only receive Triggers and the data you choose to send to us. You can even completely self-host the entire platform.
|
||||
//1. You need to export each task
|
||||
export const helloWorld = task({
|
||||
//2. Use a unique id for each task
|
||||
id: "hello-world",
|
||||
//3. The run function is the main function of the task
|
||||
run: async (payload: { message: string }) => {
|
||||
//4. You can write code that runs for a long time here, there are no timeouts
|
||||
console.log(payload.message);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Don't worry about deployment
|
||||
## Deployment
|
||||
|
||||
Just use our SDK to write Jobs in your codebase. There's nothing extra to deploy and no CI to configure, your Jobs just connect to our cloud. Or you can always self-host.
|
||||
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).
|
||||
|
||||
## Environments
|
||||
|
||||
We support `Development`, `Staging`, and `Production` environments, allowing you to test your tasks before deploying them to production.
|
||||
|
||||
## Full visibility of every job run
|
||||
|
||||
View every Task in every Run so you can tell exactly what happened.
|
||||
View every task in every run so you can tell exactly what happened. We provide a full trace view of every task run so you can see what happened at every step.
|
||||
|
||||

|
||||
|
||||
## Built-in integrations
|
||||
|
||||
Easily integrate with hundreds of third-party APIs – including your own. Use API keys (which never leave your server) or let us handle OAuth for you. Install our integration packages and easily subscribe to webhooks and perform common tasks, or you can easily use your existing favorite Node.JS SDKs and get resumability and idempotency through our `runTask` function.
|
||||

|
||||
|
||||
# Getting started
|
||||
|
||||
Visit our docs [here](https://trigger.dev/docs).
|
||||
Visit our docs [here](https://trigger.dev/docs/v3/introduction) for a full guide on how to get started with Trigger.dev.
|
||||
|
||||
## Self-host
|
||||
|
||||
We provide an official trigger.dev docker image you can use to easily self-host the platform. We're working on more extensive guides but we currently provide a [Fly.io example repository](https://github.com/triggerdotdev/fly.io) with instructions in the README for deploying and using a self-hosted instance of Trigger.dev on Fly.io.
|
||||
If you prefer to self-host, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
|
||||
|
||||
## Development
|
||||
|
||||
To setup and develop locally or contribute to the open source project, follow our [development guide](./CONTRIBUTING.md).
|
||||
|
||||
## Meet the Amazing People Behind This Project 🚀
|
||||
## Meet the Amazing People Behind This Project:
|
||||
|
||||
<a href="https://github.com/triggerdotdev/trigger.dev/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=triggerdotdev/trigger.dev" />
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
FROM node:18-bullseye-slim@sha256:a4edd54dcfdcacc8a4100fee71498e8671d99556a1acf5614539214a70092426 AS node-18
|
||||
FROM node:20-bookworm-slim@sha256:72f2f046a5f8468db28730b990b37de63ce93fd1a72a40f531d6aa82afdf0d46 AS node-20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM node-18 AS pruner
|
||||
FROM node-20 AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=coordinator --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-18 AS base
|
||||
FROM node-20 AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init docker.io \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
"execa": "^8.0.1",
|
||||
"nanoid": "^5.0.6",
|
||||
"prom-client": "^15.1.0",
|
||||
"socket.io": "4.7.4",
|
||||
"socket.io-client": "4.7.4"
|
||||
"socket.io": "4.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Execa$ } from "execa";
|
||||
import { setTimeout as timeout } from "node:timers/promises";
|
||||
|
||||
class ChaosMonkeyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ChaosMonkeyError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ChaosMonkey {
|
||||
private chaosEventRate = 0.2;
|
||||
private delayInSeconds = 45;
|
||||
|
||||
constructor(private enabled = false) {
|
||||
if (this.enabled) {
|
||||
console.log("🍌 Chaos monkey enabled");
|
||||
}
|
||||
}
|
||||
|
||||
static Error = ChaosMonkeyError;
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
console.log("🍌 Chaos monkey enabled");
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
console.log("🍌 Chaos monkey disabled");
|
||||
}
|
||||
|
||||
async call({
|
||||
$,
|
||||
throwErrors = true,
|
||||
addDelays = true,
|
||||
}: {
|
||||
$?: Execa$<string>;
|
||||
throwErrors?: boolean;
|
||||
addDelays?: boolean;
|
||||
} = {}) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const random = Math.random();
|
||||
|
||||
if (random > this.chaosEventRate) {
|
||||
// Don't interfere with normal operation
|
||||
return;
|
||||
}
|
||||
|
||||
const chaosEvents: Array<() => Promise<any>> = [];
|
||||
|
||||
if (addDelays) {
|
||||
chaosEvents.push(async () => {
|
||||
console.log("🍌 Chaos monkey: Add delay");
|
||||
|
||||
if ($) {
|
||||
await $`sleep ${this.delayInSeconds}`;
|
||||
} else {
|
||||
await timeout(this.delayInSeconds * 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (throwErrors) {
|
||||
chaosEvents.push(async () => {
|
||||
console.log("🍌 Chaos monkey: Throw error");
|
||||
|
||||
if ($) {
|
||||
await $`false`;
|
||||
} else {
|
||||
throw new ChaosMonkey.Error("🍌 Chaos monkey: Throw error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (chaosEvents.length === 0) {
|
||||
console.error("🍌 Chaos monkey: No events selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * chaosEvents.length);
|
||||
|
||||
const chaosEvent = chaosEvents[randomIndex];
|
||||
|
||||
if (!chaosEvent) {
|
||||
console.error("🍌 Chaos monkey: No event found");
|
||||
return;
|
||||
}
|
||||
|
||||
await chaosEvent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import { ExponentialBackoff } from "@trigger.dev/core-apps/backoff";
|
||||
import { isExecaChildProcess, testDockerCheckpoint } from "@trigger.dev/core-apps/checkpoints";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
|
||||
import { $ } from "execa";
|
||||
import { nanoid } from "nanoid";
|
||||
import fs from "node:fs/promises";
|
||||
import { ChaosMonkey } from "./chaosMonkey";
|
||||
|
||||
type CheckpointerInitializeReturn = {
|
||||
canCheckpoint: boolean;
|
||||
willSimulate: boolean;
|
||||
};
|
||||
|
||||
type CheckpointAndPushOptions = {
|
||||
runId: string;
|
||||
leaveRunning?: boolean;
|
||||
projectRef: string;
|
||||
deploymentVersion: string;
|
||||
shouldHeartbeat?: boolean;
|
||||
};
|
||||
|
||||
type CheckpointAndPushResult =
|
||||
| { success: true; checkpoint: CheckpointData }
|
||||
| {
|
||||
success: false;
|
||||
reason?: "CANCELED" | "DISABLED" | "ERROR" | "IN_PROGRESS" | "NO_SUPPORT" | "SKIP_RETRYING";
|
||||
};
|
||||
|
||||
type CheckpointData = {
|
||||
location: string;
|
||||
docker: boolean;
|
||||
};
|
||||
|
||||
type CheckpointerOptions = {
|
||||
dockerMode: boolean;
|
||||
forceSimulate: boolean;
|
||||
heartbeat: (runId: string) => void;
|
||||
registryHost?: string;
|
||||
registryNamespace?: string;
|
||||
registryTlsVerify?: boolean;
|
||||
disableCheckpointSupport?: boolean;
|
||||
checkpointPath?: string;
|
||||
simulateCheckpointFailure?: boolean;
|
||||
simulateCheckpointFailureSeconds?: number;
|
||||
simulatePushFailure?: boolean;
|
||||
simulatePushFailureSeconds?: number;
|
||||
chaosMonkey?: ChaosMonkey;
|
||||
};
|
||||
|
||||
async function getFileSize(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
console.error("Error getting file size:", error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
async function getParsedFileSize(filePath: string) {
|
||||
const sizeInBytes = await getFileSize(filePath);
|
||||
|
||||
let message = `Size in bytes: ${sizeInBytes}`;
|
||||
|
||||
if (sizeInBytes > 1024 * 1024) {
|
||||
const sizeInMB = (sizeInBytes / 1024 / 1024).toFixed(2);
|
||||
message = `Size in MB (rounded): ${sizeInMB}`;
|
||||
} else if (sizeInBytes > 1024) {
|
||||
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
|
||||
message = `Size in KB (rounded): ${sizeInKB}`;
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
sizeInBytes,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export class Checkpointer {
|
||||
#initialized = false;
|
||||
#canCheckpoint = false;
|
||||
#dockerMode: boolean;
|
||||
|
||||
#logger = new SimpleLogger("[checkptr]");
|
||||
#abortControllers = new Map<string, AbortController>();
|
||||
#failedCheckpoints = new Map<string, unknown>();
|
||||
#waitingForRetry = new Set<string>();
|
||||
|
||||
private registryHost: string;
|
||||
private registryNamespace: string;
|
||||
private registryTlsVerify: boolean;
|
||||
|
||||
private disableCheckpointSupport: boolean;
|
||||
private checkpointPath: string;
|
||||
|
||||
private simulateCheckpointFailure: boolean;
|
||||
private simulateCheckpointFailureSeconds: number;
|
||||
private simulatePushFailure: boolean;
|
||||
private simulatePushFailureSeconds: number;
|
||||
|
||||
private chaosMonkey: ChaosMonkey;
|
||||
|
||||
constructor(private opts: CheckpointerOptions) {
|
||||
this.#dockerMode = opts.dockerMode;
|
||||
|
||||
this.registryHost = opts.registryHost ?? "localhost:5000";
|
||||
this.registryNamespace = opts.registryNamespace ?? "trigger";
|
||||
this.registryTlsVerify = opts.registryTlsVerify ?? true;
|
||||
|
||||
this.disableCheckpointSupport = opts.disableCheckpointSupport ?? false;
|
||||
this.checkpointPath = opts.checkpointPath ?? "/checkpoints";
|
||||
|
||||
this.simulateCheckpointFailure = opts.simulateCheckpointFailure ?? false;
|
||||
this.simulateCheckpointFailureSeconds = opts.simulateCheckpointFailureSeconds ?? 300;
|
||||
this.simulatePushFailure = opts.simulatePushFailure ?? false;
|
||||
this.simulatePushFailureSeconds = opts.simulatePushFailureSeconds ?? 300;
|
||||
|
||||
this.chaosMonkey = opts.chaosMonkey ?? new ChaosMonkey(!!process.env.CHAOS_MONKEY_ENABLED);
|
||||
}
|
||||
|
||||
async init(): Promise<CheckpointerInitializeReturn> {
|
||||
if (this.#initialized) {
|
||||
return this.#getInitReturn(this.#canCheckpoint);
|
||||
}
|
||||
|
||||
this.#logger.log(`${this.#dockerMode ? "Docker" : "Kubernetes"} mode`);
|
||||
|
||||
if (this.#dockerMode) {
|
||||
const testCheckpoint = await testDockerCheckpoint();
|
||||
|
||||
if (testCheckpoint.ok) {
|
||||
return this.#getInitReturn(true);
|
||||
}
|
||||
|
||||
this.#logger.error(testCheckpoint.message, testCheckpoint.error ?? "");
|
||||
return this.#getInitReturn(false);
|
||||
} else {
|
||||
try {
|
||||
await $`buildah login --get-login ${this.registryHost}`;
|
||||
} catch (error) {
|
||||
this.#logger.error(`No checkpoint support: Not logged in to registry ${this.registryHost}`);
|
||||
return this.#getInitReturn(false);
|
||||
}
|
||||
}
|
||||
|
||||
return this.#getInitReturn(true);
|
||||
}
|
||||
|
||||
#getInitReturn(canCheckpoint: boolean): CheckpointerInitializeReturn {
|
||||
this.#canCheckpoint = canCheckpoint;
|
||||
|
||||
if (canCheckpoint) {
|
||||
if (!this.#initialized) {
|
||||
this.#logger.log("Full checkpoint support!");
|
||||
}
|
||||
}
|
||||
|
||||
this.#initialized = true;
|
||||
|
||||
const willSimulate = this.#dockerMode && (!this.#canCheckpoint || this.opts.forceSimulate);
|
||||
|
||||
if (willSimulate) {
|
||||
this.#logger.log("Simulation mode enabled. Containers will be paused, not checkpointed.", {
|
||||
forceSimulate: this.opts.forceSimulate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
canCheckpoint,
|
||||
willSimulate,
|
||||
};
|
||||
}
|
||||
|
||||
#getImageRef(projectRef: string, deploymentVersion: string, shortCode: string) {
|
||||
return `${this.registryHost}/${this.registryNamespace}/${projectRef}:${deploymentVersion}.prod-${shortCode}`;
|
||||
}
|
||||
|
||||
#getExportLocation(projectRef: string, deploymentVersion: string, shortCode: string) {
|
||||
const basename = `${projectRef}-${deploymentVersion}-${shortCode}`;
|
||||
|
||||
if (this.#dockerMode) {
|
||||
return basename;
|
||||
} else {
|
||||
return `${this.checkpointPath}/${basename}.tar`;
|
||||
}
|
||||
}
|
||||
|
||||
async checkpointAndPush(opts: CheckpointAndPushOptions): Promise<CheckpointData | undefined> {
|
||||
const start = performance.now();
|
||||
this.#logger.log(`checkpointAndPush() start`, { start, opts });
|
||||
|
||||
let interval: NodeJS.Timer | undefined;
|
||||
|
||||
if (opts.shouldHeartbeat) {
|
||||
interval = setInterval(() => {
|
||||
this.#logger.log("Sending heartbeat", { runId: opts.runId });
|
||||
this.opts.heartbeat(opts.runId);
|
||||
}, 20_000);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#checkpointAndPushWithBackoff(opts);
|
||||
|
||||
const end = performance.now();
|
||||
this.#logger.log(`checkpointAndPush() end`, {
|
||||
start,
|
||||
end,
|
||||
diff: end - start,
|
||||
opts,
|
||||
success: result.success,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
return result.checkpoint;
|
||||
} finally {
|
||||
if (opts.shouldHeartbeat) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isCheckpointing(runId: string) {
|
||||
return this.#abortControllers.has(runId) || this.#waitingForRetry.has(runId);
|
||||
}
|
||||
|
||||
cancelCheckpoint(runId: string): boolean {
|
||||
// If the last checkpoint failed, pretend we canceled it
|
||||
// This ensures tasks don't wait for external resume messages to continue
|
||||
if (this.#hasFailedCheckpoint(runId)) {
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.#waitingForRetry.has(runId)) {
|
||||
this.#waitingForRetry.delete(runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const controller = this.#abortControllers.get(runId);
|
||||
|
||||
if (!controller) {
|
||||
this.#logger.debug("Nothing to cancel", { runId });
|
||||
return false;
|
||||
}
|
||||
|
||||
controller.abort("cancelCheckpointing()");
|
||||
this.#abortControllers.delete(runId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #checkpointAndPushWithBackoff({
|
||||
runId,
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
|
||||
this.#logger.log("Checkpointing with backoff", {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
});
|
||||
|
||||
const backoff = new ExponentialBackoff()
|
||||
.type("EqualJitter")
|
||||
.base(3)
|
||||
.max(3 * 3600)
|
||||
.maxElapsed(48 * 3600);
|
||||
|
||||
for await (const { delay, retry } of backoff) {
|
||||
try {
|
||||
if (retry > 0) {
|
||||
this.#logger.error("Retrying checkpoint", {
|
||||
runId,
|
||||
retry,
|
||||
delay,
|
||||
});
|
||||
|
||||
this.#waitingForRetry.add(runId);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay.milliseconds));
|
||||
|
||||
if (!this.#waitingForRetry.has(runId)) {
|
||||
this.#logger.log("Checkpoint canceled while waiting for retry", { runId });
|
||||
return { success: false, reason: "CANCELED" };
|
||||
} else {
|
||||
this.#waitingForRetry.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.#checkpointAndPush({
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "CANCELED") {
|
||||
this.#logger.log("Checkpoint canceled, won't retry", { runId });
|
||||
// Don't fail the checkpoint, as it was canceled
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "IN_PROGRESS") {
|
||||
this.#logger.log("Checkpoint already in progress, won't retry", { runId });
|
||||
this.#failCheckpoint(runId, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "NO_SUPPORT") {
|
||||
this.#logger.log("No checkpoint support, won't retry", { runId });
|
||||
this.#failCheckpoint(runId, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "DISABLED") {
|
||||
this.#logger.log("Checkpoint support disabled, won't retry", { runId });
|
||||
this.#failCheckpoint(runId, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "SKIP_RETRYING") {
|
||||
this.#logger.log("Skipping retrying", { runId });
|
||||
return result;
|
||||
}
|
||||
|
||||
continue;
|
||||
} catch (error) {
|
||||
this.#logger.error("Checkpoint error", {
|
||||
retry,
|
||||
runId,
|
||||
delay,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.error(`Checkpoint failed after exponential backoff`, {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
});
|
||||
this.#failCheckpoint(runId, "ERROR");
|
||||
|
||||
return { success: false, reason: "ERROR" };
|
||||
}
|
||||
|
||||
async #checkpointAndPush({
|
||||
runId,
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
|
||||
await this.init();
|
||||
|
||||
const options = {
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
};
|
||||
|
||||
if (!this.#dockerMode && !this.#canCheckpoint) {
|
||||
this.#logger.error("No checkpoint support. Simulation requires docker.");
|
||||
return { success: false, reason: "NO_SUPPORT" };
|
||||
}
|
||||
|
||||
if (this.isCheckpointing(runId)) {
|
||||
this.#logger.error("Checkpoint procedure already in progress", { options });
|
||||
return { success: false, reason: "IN_PROGRESS" };
|
||||
}
|
||||
|
||||
// This is a new checkpoint, clear any last failure for this run
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
|
||||
if (this.disableCheckpointSupport) {
|
||||
this.#logger.error("Checkpoint support disabled", { options });
|
||||
return { success: false, reason: "DISABLED" };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#abortControllers.set(runId, controller);
|
||||
|
||||
const $$ = $({ signal: controller.signal });
|
||||
|
||||
const shortCode = nanoid(8);
|
||||
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
|
||||
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
|
||||
|
||||
const cleanup = async () => {
|
||||
if (this.#dockerMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await $`rm ${exportLocation}`;
|
||||
this.#logger.log("Deleted checkpoint archive", { exportLocation });
|
||||
|
||||
await $`buildah rmi ${imageRef}`;
|
||||
this.#logger.log("Deleted checkpoint image", { imageRef });
|
||||
} catch (error) {
|
||||
this.#logger.error("Failure during checkpoint cleanup", { exportLocation, error });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await this.chaosMonkey.call({ $: $$ });
|
||||
|
||||
this.#logger.log("Checkpointing:", { options });
|
||||
|
||||
const containterName = this.#getRunContainerName(runId);
|
||||
|
||||
// Create checkpoint (docker)
|
||||
if (this.#dockerMode) {
|
||||
try {
|
||||
if (this.opts.forceSimulate || !this.#canCheckpoint) {
|
||||
this.#logger.log("Simulating checkpoint");
|
||||
this.#logger.debug(await $$`docker pause ${containterName}`);
|
||||
} else {
|
||||
if (this.simulateCheckpointFailure) {
|
||||
if (performance.now() < this.simulateCheckpointFailureSeconds * 1000) {
|
||||
this.#logger.error("Simulating checkpoint failure", { options });
|
||||
throw new Error("SIMULATE_CHECKPOINT_FAILURE");
|
||||
}
|
||||
}
|
||||
|
||||
if (leaveRunning) {
|
||||
this.#logger.debug(
|
||||
await $$`docker checkpoint create --leave-running ${containterName} ${exportLocation}`
|
||||
);
|
||||
} else {
|
||||
this.#logger.debug(
|
||||
await $$`docker checkpoint create ${containterName} ${exportLocation}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.error("Failed while creating docker checkpoint", { exportLocation });
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#logger.log("checkpoint created:", {
|
||||
runId,
|
||||
location: exportLocation,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
checkpoint: {
|
||||
location: exportLocation,
|
||||
docker: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Create checkpoint (CRI)
|
||||
if (!this.#canCheckpoint) {
|
||||
this.#logger.error("No checkpoint support in kubernetes mode.");
|
||||
return { success: false, reason: "SKIP_RETRYING" };
|
||||
}
|
||||
|
||||
const containerId = this.#logger.debug(
|
||||
// @ts-expect-error
|
||||
await $$`crictl ps`
|
||||
.pipeStdout($$({ stdin: "pipe" })`grep ${containterName}`)
|
||||
.pipeStdout($$({ stdin: "pipe" })`cut -f1 ${"-d "}`)
|
||||
);
|
||||
|
||||
if (!containerId.stdout) {
|
||||
this.#logger.error("could not find container id", { options, containterName });
|
||||
return { success: false, reason: "SKIP_RETRYING" };
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
if (this.simulateCheckpointFailure) {
|
||||
if (performance.now() < this.simulateCheckpointFailureSeconds * 1000) {
|
||||
this.#logger.error("Simulating checkpoint failure", { options });
|
||||
throw new Error("SIMULATE_CHECKPOINT_FAILURE");
|
||||
}
|
||||
}
|
||||
|
||||
// Create checkpoint
|
||||
this.#logger.debug(await $$`crictl checkpoint --export=${exportLocation} ${containerId}`);
|
||||
const postCheckpoint = performance.now();
|
||||
|
||||
// Print checkpoint size
|
||||
const size = await getParsedFileSize(exportLocation);
|
||||
this.#logger.log("checkpoint archive created", { size, options });
|
||||
|
||||
// Create image from checkpoint
|
||||
const container = this.#logger.debug(await $$`buildah from scratch`);
|
||||
const postFrom = performance.now();
|
||||
|
||||
this.#logger.debug(await $$`buildah add ${container} ${exportLocation} /`);
|
||||
const postAdd = performance.now();
|
||||
|
||||
this.#logger.debug(
|
||||
await $$`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
|
||||
);
|
||||
const postConfig = performance.now();
|
||||
|
||||
this.#logger.debug(await $$`buildah commit ${container} ${imageRef}`);
|
||||
const postCommit = performance.now();
|
||||
|
||||
this.#logger.debug(await $$`buildah rm ${container}`);
|
||||
const postRm = performance.now();
|
||||
|
||||
if (this.simulatePushFailure) {
|
||||
if (performance.now() < this.simulatePushFailureSeconds * 1000) {
|
||||
this.#logger.error("Simulating push failure", { options });
|
||||
throw new Error("SIMULATE_PUSH_FAILURE");
|
||||
}
|
||||
}
|
||||
|
||||
// Push checkpoint image
|
||||
this.#logger.debug(
|
||||
await $$`buildah push --tls-verify=${String(this.registryTlsVerify)} ${imageRef}`
|
||||
);
|
||||
const postPush = performance.now();
|
||||
|
||||
const perf = {
|
||||
"crictl checkpoint": postCheckpoint - start,
|
||||
"buildah from": postFrom - postCheckpoint,
|
||||
"buildah add": postAdd - postFrom,
|
||||
"buildah config": postConfig - postAdd,
|
||||
"buildah commit": postCommit - postConfig,
|
||||
"buildah rm": postRm - postCommit,
|
||||
"buildah push": postPush - postRm,
|
||||
};
|
||||
|
||||
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef, perf });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
checkpoint: {
|
||||
location: imageRef,
|
||||
docker: false,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isExecaChildProcess(error)) {
|
||||
if (error.isCanceled) {
|
||||
this.#logger.error("Checkpoint canceled", { options, error });
|
||||
|
||||
return { success: false, reason: "CANCELED" };
|
||||
}
|
||||
|
||||
this.#logger.error("Checkpoint command error", { options, error });
|
||||
|
||||
return { success: false, reason: "ERROR" };
|
||||
}
|
||||
|
||||
this.#logger.error("Unhandled checkpoint error", { options, error });
|
||||
|
||||
return { success: false, reason: "ERROR" };
|
||||
} finally {
|
||||
this.#abortControllers.delete(runId);
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
#failCheckpoint(runId: string, error: unknown) {
|
||||
this.#failedCheckpoints.set(runId, error);
|
||||
}
|
||||
|
||||
#clearFailedCheckpoint(runId: string) {
|
||||
this.#failedCheckpoints.delete(runId);
|
||||
}
|
||||
|
||||
#hasFailedCheckpoint(runId: string) {
|
||||
return this.#failedCheckpoints.has(runId);
|
||||
}
|
||||
|
||||
#getRunContainerName(suffix: string) {
|
||||
return `task-run-${suffix}`;
|
||||
}
|
||||
}
|
||||
+396
-375
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ PLATFORM_WS_PORT=3030
|
||||
PLATFORM_SECRET=provider-secret
|
||||
SECURE_CONNECTION=false
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://0.0.0.0:3030/otel
|
||||
|
||||
# Use this if you are on macOS
|
||||
# COORDINATOR_HOST="host.docker.internal"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT="http://host.docker.internal:4318"
|
||||
@@ -1,16 +1,47 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
FROM node:18-slim AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y dumb-init
|
||||
|
||||
FROM base
|
||||
FROM node:20-alpine@sha256:7a91aa397f2e2dfbfcdad2e2d72599f374e0b0172be1d86eeb73f1d33f36a4b2 AS node-20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node dist/index.mjs /app/
|
||||
FROM node-20-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=docker-provider --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-20-alpine AS base
|
||||
|
||||
RUN apk add --no-cache dumb-init docker
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
FROM base AS dev-deps
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV development
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --no-frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN corepack enable
|
||||
|
||||
COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter docker-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=builder --chown=node:node /app/apps/docker-provider/dist/index.mjs ./index.mjs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "/app/index.mjs" ]
|
||||
USER node
|
||||
|
||||
CMD [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "./index.mjs" ]
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/core-apps": "workspace:*",
|
||||
"execa": "^8.0.1",
|
||||
"socket.io-client": "^4.7.4"
|
||||
"execa": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.8",
|
||||
|
||||
@@ -1,87 +1,84 @@
|
||||
import { $, type ExecaChildProcess, execa } from "execa";
|
||||
import {
|
||||
SimpleLogger,
|
||||
TaskOperations,
|
||||
ProviderShell,
|
||||
TaskOperationsRestoreOptions,
|
||||
TaskOperations,
|
||||
TaskOperationsCreateOptions,
|
||||
TaskOperationsIndexOptions,
|
||||
} from "@trigger.dev/core-apps";
|
||||
TaskOperationsRestoreOptions,
|
||||
} from "@trigger.dev/core-apps/provider";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
|
||||
import { isExecaChildProcess, testDockerCheckpoint } from "@trigger.dev/core-apps/checkpoints";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
|
||||
|
||||
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
|
||||
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
|
||||
const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
|
||||
|
||||
const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318";
|
||||
|
||||
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
|
||||
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
|
||||
);
|
||||
|
||||
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
|
||||
|
||||
type InitializeReturn = {
|
||||
type TaskOperationsInitReturn = {
|
||||
canCheckpoint: boolean;
|
||||
willSimulate: boolean;
|
||||
};
|
||||
|
||||
function isExecaChildProcess(maybeExeca: unknown): maybeExeca is Awaited<ExecaChildProcess> {
|
||||
return typeof maybeExeca === "object" && maybeExeca !== null && "escapedCommand" in maybeExeca;
|
||||
}
|
||||
|
||||
class DockerTaskOperations implements TaskOperations {
|
||||
#initialized = false;
|
||||
#canCheckpoint = false;
|
||||
|
||||
constructor(private opts = { forceSimulate: false }) {}
|
||||
|
||||
async #initialize(): Promise<InitializeReturn> {
|
||||
async init(): Promise<TaskOperationsInitReturn> {
|
||||
if (this.#initialized) {
|
||||
return this.#getInitializeReturn();
|
||||
return this.#getInitReturn(this.#canCheckpoint);
|
||||
}
|
||||
|
||||
logger.log("Initializing task operations");
|
||||
|
||||
if (this.opts.forceSimulate) {
|
||||
logger.log("Forced simulation enabled. Will simulate regardless of checkpoint support.");
|
||||
const testCheckpoint = await testDockerCheckpoint();
|
||||
|
||||
if (testCheckpoint.ok) {
|
||||
return this.#getInitReturn(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await $`criu --version`;
|
||||
} catch (error) {
|
||||
logger.error("No checkpoint support: Missing CRIU binary. Will simulate instead.");
|
||||
this.#canCheckpoint = false;
|
||||
this.#initialized = true;
|
||||
|
||||
return this.#getInitializeReturn();
|
||||
}
|
||||
|
||||
try {
|
||||
await $`docker checkpoint`;
|
||||
} catch (error) {
|
||||
logger.error("No checkpoint support: Docker needs to have experimental features enabled");
|
||||
logger.error("Will simulate instead");
|
||||
this.#canCheckpoint = false;
|
||||
this.#initialized = true;
|
||||
|
||||
return this.#getInitializeReturn();
|
||||
}
|
||||
|
||||
logger.log("Full checkpoint support!");
|
||||
|
||||
this.#initialized = true;
|
||||
this.#canCheckpoint = true;
|
||||
|
||||
return this.#getInitializeReturn();
|
||||
logger.error(testCheckpoint.message, testCheckpoint.error);
|
||||
return this.#getInitReturn(false);
|
||||
}
|
||||
|
||||
#getInitializeReturn(): InitializeReturn {
|
||||
#getInitReturn(canCheckpoint: boolean): TaskOperationsInitReturn {
|
||||
this.#canCheckpoint = canCheckpoint;
|
||||
|
||||
if (canCheckpoint) {
|
||||
if (!this.#initialized) {
|
||||
logger.log("Full checkpoint support!");
|
||||
}
|
||||
}
|
||||
|
||||
this.#initialized = true;
|
||||
|
||||
const willSimulate = !canCheckpoint || this.opts.forceSimulate;
|
||||
|
||||
if (willSimulate) {
|
||||
logger.log("Simulation mode enabled. Containers will be paused, not checkpointed.", {
|
||||
forceSimulate: this.opts.forceSimulate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
canCheckpoint: this.#canCheckpoint,
|
||||
willSimulate: !this.#canCheckpoint || this.opts.forceSimulate,
|
||||
canCheckpoint,
|
||||
willSimulate,
|
||||
};
|
||||
}
|
||||
|
||||
async index(opts: TaskOperationsIndexOptions) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getIndexContainerName(opts.shortCode);
|
||||
|
||||
@@ -90,60 +87,51 @@ class DockerTaskOperations implements TaskOperations {
|
||||
port: COORDINATOR_PORT,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
await execa("docker", [
|
||||
"run",
|
||||
"--network=host",
|
||||
"--rm",
|
||||
`--env=INDEX_TASKS=true`,
|
||||
`--env=TRIGGER_SECRET_KEY=${opts.apiKey}`,
|
||||
`--env=TRIGGER_API_URL=${opts.apiUrl}`,
|
||||
`--env=TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--name=${containerName}`,
|
||||
`${opts.imageRef}`,
|
||||
])
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (!isExecaChildProcess(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error("Index failed:", {
|
||||
opts,
|
||||
exitCode: error.exitCode,
|
||||
escapedCommand: error.escapedCommand,
|
||||
stdout: error.stdout,
|
||||
stderr: error.stderr,
|
||||
});
|
||||
}
|
||||
logger.debug(
|
||||
await execa("docker", [
|
||||
"run",
|
||||
"--network=host",
|
||||
"--rm",
|
||||
`--env=INDEX_TASKS=true`,
|
||||
`--env=TRIGGER_SECRET_KEY=${opts.apiKey}`,
|
||||
`--env=TRIGGER_API_URL=${opts.apiUrl}`,
|
||||
`--env=TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--name=${containerName}`,
|
||||
`${opts.imageRef}`,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async create(opts: TaskOperationsCreateOptions) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getRunContainerName(opts.runId);
|
||||
|
||||
const runArgs = [
|
||||
"run",
|
||||
"--network=host",
|
||||
"--detach",
|
||||
`--env=TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`--env=TRIGGER_RUN_ID=${opts.runId}`,
|
||||
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--name=${containerName}`,
|
||||
];
|
||||
|
||||
if (process.env.ENFORCE_MACHINE_PRESETS) {
|
||||
runArgs.push(`--cpus=${opts.machine.cpu}`, `--memory=${opts.machine.memory}G`);
|
||||
}
|
||||
|
||||
runArgs.push(`${opts.image}`);
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
await execa("docker", [
|
||||
"run",
|
||||
"--network=host",
|
||||
"--detach",
|
||||
`--env=TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`--env=TRIGGER_RUN_ID=${opts.runId}`,
|
||||
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--name=${containerName}`,
|
||||
`${opts.image}`,
|
||||
])
|
||||
);
|
||||
logger.debug(await execa("docker", runArgs));
|
||||
} catch (error) {
|
||||
if (!isExecaChildProcess(error)) {
|
||||
throw error;
|
||||
@@ -160,7 +148,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async restore(opts: TaskOperationsRestoreOptions) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getRunContainerName(opts.runId);
|
||||
|
||||
@@ -189,7 +177,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async delete(opts: { runId: string }) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getRunContainerName(opts.runId);
|
||||
await this.#sendPreStop(containerName);
|
||||
@@ -198,7 +186,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async get(opts: { runId: string }) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
logger.log("noop: get");
|
||||
}
|
||||
@@ -278,7 +266,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
const provider = new ProviderShell({
|
||||
tasks: new DockerTaskOperations({ forceSimulate: true }),
|
||||
tasks: new DockerTaskOperations({ forceSimulate: FORCE_CHECKPOINT_SIMULATION }),
|
||||
type: "docker",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
FROM node:18-alpine@sha256:ca9f6cb0466f9638e59e0c249d335a07c867cd50c429b5c7830dda1bed584649 AS node-18-alpine
|
||||
FROM node:20-alpine@sha256:7a91aa397f2e2dfbfcdad2e2d72599f374e0b0172be1d86eeb73f1d33f36a4b2 AS node-20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM node-18-alpine AS pruner
|
||||
FROM node-20-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=kubernetes-provider --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-18-alpine AS base
|
||||
FROM node-20-alpine AS base
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
"@kubernetes/client-node": "^0.20.0",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/core-apps": "workspace:*",
|
||||
"p-queue": "^8.0.1",
|
||||
"socket.io-client": "^4.7.4"
|
||||
"p-queue": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.2",
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import {
|
||||
ProviderShell,
|
||||
SimpleLogger,
|
||||
TaskOperations,
|
||||
TaskOperationsCreateOptions,
|
||||
TaskOperationsIndexOptions,
|
||||
TaskOperationsRestoreOptions,
|
||||
} from "@trigger.dev/core-apps";
|
||||
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
|
||||
} from "@trigger.dev/core-apps/provider";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
|
||||
import {
|
||||
MachinePreset,
|
||||
PostStartCauses,
|
||||
PreStopCauses,
|
||||
EnvironmentType,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { randomUUID } from "crypto";
|
||||
import { TaskMonitor } from "./taskMonitor";
|
||||
import { PodCleaner } from "./podCleaner";
|
||||
import { UptimeHeartbeat } from "./uptimeHeartbeat";
|
||||
|
||||
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
|
||||
const NODE_NAME = process.env.NODE_NAME || "local";
|
||||
const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318";
|
||||
|
||||
const POD_CLEANER_INTERVAL_SECONDS = Number(process.env.POD_CLEANER_INTERVAL_SECONDS || "300");
|
||||
|
||||
const UPTIME_HEARTBEAT_URL = process.env.UPTIME_HEARTBEAT_URL;
|
||||
const UPTIME_INTERVAL_SECONDS = Number(process.env.UPTIME_INTERVAL_SECONDS || "60");
|
||||
const UPTIME_MAX_PENDING_RUNS = Number(process.env.UPTIME_MAX_PENDING_RUNS || "25");
|
||||
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 logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
logger.log(`running in ${RUNTIME_ENV} mode`);
|
||||
|
||||
@@ -47,6 +61,10 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
this.#k8sApi = this.#createK8sApi();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// noop
|
||||
}
|
||||
|
||||
async index(opts: TaskOperationsIndexOptions) {
|
||||
await this.#createJob(
|
||||
{
|
||||
@@ -212,7 +230,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
},
|
||||
{
|
||||
name: "populate-taskinfo",
|
||||
image: "docker.io/library/busybox",
|
||||
image: "registry.digitalocean.com/trigger/busybox",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "-c"],
|
||||
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
|
||||
@@ -316,6 +334,9 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
{
|
||||
name: "registry-trigger",
|
||||
},
|
||||
{
|
||||
name: "registry-trigger-failover",
|
||||
},
|
||||
],
|
||||
nodeSelector: {
|
||||
nodetype: "worker",
|
||||
@@ -391,10 +412,10 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
};
|
||||
}
|
||||
|
||||
#getResourcesFromMachineConfig(config: Machine): ComputeResources {
|
||||
#getResourcesFromMachineConfig(preset: MachinePreset): ComputeResources {
|
||||
return {
|
||||
cpu: `${config.cpu}`,
|
||||
memory: `${config.memory}G`,
|
||||
cpu: `${preset.cpu}`,
|
||||
memory: `${preset.memory}G`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -516,27 +537,28 @@ provider.listen();
|
||||
|
||||
const taskMonitor = new TaskMonitor({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
onIndexFailure: async (deploymentId, failureInfo) => {
|
||||
logger.log("Indexing failed", { deploymentId, failureInfo });
|
||||
onIndexFailure: async (deploymentId, details) => {
|
||||
logger.log("Indexing failed", { deploymentId, details });
|
||||
|
||||
try {
|
||||
provider.platformSocket.send("INDEXING_FAILED", {
|
||||
deploymentId,
|
||||
error: {
|
||||
name: `Crashed with exit code ${failureInfo.exitCode}`,
|
||||
message: failureInfo.reason,
|
||||
stack: failureInfo.logs,
|
||||
name: `Crashed with exit code ${details.exitCode}`,
|
||||
message: details.reason,
|
||||
stack: details.logs,
|
||||
},
|
||||
overrideCompletion: details.overrideCompletion,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
},
|
||||
onRunFailure: async (runId, failureInfo) => {
|
||||
logger.log("Run failed:", { runId, failureInfo });
|
||||
onRunFailure: async (runId, details) => {
|
||||
logger.log("Run failed:", { runId, details });
|
||||
|
||||
try {
|
||||
provider.platformSocket.send("WORKER_CRASHED", { runId, ...failureInfo });
|
||||
provider.platformSocket.send("WORKER_CRASHED", { runId, ...details });
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
@@ -548,7 +570,23 @@ taskMonitor.start();
|
||||
const podCleaner = new PodCleaner({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: "default",
|
||||
intervalInSeconds: 300,
|
||||
intervalInSeconds: POD_CLEANER_INTERVAL_SECONDS,
|
||||
});
|
||||
|
||||
podCleaner.start();
|
||||
|
||||
if (UPTIME_HEARTBEAT_URL) {
|
||||
const uptimeHeartbeat = new UptimeHeartbeat({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: "default",
|
||||
intervalInSeconds: UPTIME_INTERVAL_SECONDS,
|
||||
pingUrl: UPTIME_HEARTBEAT_URL,
|
||||
maxPendingRuns: UPTIME_MAX_PENDING_RUNS,
|
||||
maxPendingIndeces: UPTIME_MAX_PENDING_INDECES,
|
||||
maxPendingErrors: UPTIME_MAX_PENDING_ERRORS,
|
||||
});
|
||||
|
||||
uptimeHeartbeat.start();
|
||||
} else {
|
||||
logger.log("Uptime heartbeat is disabled, set UPTIME_HEARTBEAT_URL to enable.");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
|
||||
|
||||
type PodCleanerOptions = {
|
||||
runtimeEnv: "local" | "kubernetes";
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
|
||||
import { EXIT_CODE_ALREADY_HANDLED, EXIT_CODE_CHILD_NONZERO } from "@trigger.dev/core-apps/process";
|
||||
import { setTimeout } from "timers/promises";
|
||||
import PQueue from "p-queue";
|
||||
import type { Prettify } from "@trigger.dev/core/v3";
|
||||
|
||||
type IndexFailureHandler = (
|
||||
deploymentId: string,
|
||||
failureInfo: {
|
||||
exitCode: number;
|
||||
reason: string;
|
||||
logs: string;
|
||||
}
|
||||
) => Promise<any>;
|
||||
type FailureDetails = Prettify<{
|
||||
exitCode: number;
|
||||
reason: string;
|
||||
logs: string;
|
||||
overrideCompletion: boolean;
|
||||
}>;
|
||||
|
||||
type RunFailureHandler = (
|
||||
runId: string,
|
||||
failureInfo: {
|
||||
exitCode: number;
|
||||
reason: string;
|
||||
logs: string;
|
||||
}
|
||||
) => Promise<any>;
|
||||
type IndexFailureHandler = (deploymentId: string, details: FailureDetails) => Promise<any>;
|
||||
|
||||
type RunFailureHandler = (runId: string, details: FailureDetails) => Promise<any>;
|
||||
|
||||
type TaskMonitorOptions = {
|
||||
runtimeEnv: "local" | "kubernetes";
|
||||
@@ -144,8 +139,10 @@ export class TaskMonitor {
|
||||
const containerState = this.#getContainerStateSummary(containerStatus.state);
|
||||
const exitCode = containerState.exitCode ?? -1;
|
||||
|
||||
// We use this special exit code to signal any errors were already handled elsewhere
|
||||
if (exitCode === 111) {
|
||||
if (exitCode === EXIT_CODE_ALREADY_HANDLED) {
|
||||
this.#logger.debug("Ignoring pod failure, already handled by worker", {
|
||||
podName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,6 +159,7 @@ export class TaskMonitor {
|
||||
|
||||
let reason = rawReason || "Unknown error";
|
||||
let logs = rawLogs || "";
|
||||
let overrideCompletion = false;
|
||||
|
||||
switch (rawReason) {
|
||||
case "Error":
|
||||
@@ -181,7 +179,10 @@ export class TaskMonitor {
|
||||
}
|
||||
break;
|
||||
case "OOMKilled":
|
||||
reason = "Out of memory! Try increasing the memory on this task.";
|
||||
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.`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -191,7 +192,8 @@ export class TaskMonitor {
|
||||
exitCode,
|
||||
reason,
|
||||
logs,
|
||||
};
|
||||
overrideCompletion,
|
||||
} satisfies FailureDetails;
|
||||
|
||||
const app = pod.metadata?.labels?.app;
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
|
||||
|
||||
type UptimeHeartbeatOptions = {
|
||||
runtimeEnv: "local" | "kubernetes";
|
||||
pingUrl: string;
|
||||
namespace?: string;
|
||||
intervalInSeconds?: number;
|
||||
maxPendingRuns?: number;
|
||||
maxPendingIndeces?: number;
|
||||
maxPendingErrors?: number;
|
||||
leadingEdge?: boolean;
|
||||
};
|
||||
|
||||
export class UptimeHeartbeat {
|
||||
private enabled = false;
|
||||
private namespace: string;
|
||||
|
||||
private intervalInSeconds: number;
|
||||
private maxPendingRuns: number;
|
||||
private maxPendingIndeces: number;
|
||||
private maxPendingErrors: number;
|
||||
|
||||
private leadingEdge = true;
|
||||
|
||||
private logger = new SimpleLogger("[UptimeHeartbeat]");
|
||||
private k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
};
|
||||
|
||||
constructor(private opts: UptimeHeartbeatOptions) {
|
||||
this.namespace = opts.namespace ?? "default";
|
||||
|
||||
this.intervalInSeconds = opts.intervalInSeconds ?? 60;
|
||||
this.maxPendingRuns = opts.maxPendingRuns ?? 25;
|
||||
this.maxPendingIndeces = opts.maxPendingIndeces ?? 10;
|
||||
this.maxPendingErrors = opts.maxPendingErrors ?? 10;
|
||||
|
||||
this.k8sClient = this.#createK8sClient();
|
||||
}
|
||||
|
||||
#createK8sClient() {
|
||||
const kubeConfig = new k8s.KubeConfig();
|
||||
|
||||
if (this.opts.runtimeEnv === "local") {
|
||||
kubeConfig.loadFromDefault();
|
||||
} else if (this.opts.runtimeEnv === "kubernetes") {
|
||||
kubeConfig.loadFromCluster();
|
||||
} else {
|
||||
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
|
||||
}
|
||||
|
||||
return {
|
||||
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
|
||||
kubeConfig: kubeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
|
||||
if (typeof candidate !== "object" || candidate === null) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#logK8sError(err: unknown, debugOnly = false) {
|
||||
if (debugOnly) {
|
||||
this.logger.debug("K8s API Error", err);
|
||||
} else {
|
||||
this.logger.error("K8s API Error", err);
|
||||
}
|
||||
}
|
||||
|
||||
#handleK8sError(err: unknown) {
|
||||
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
|
||||
this.#logK8sError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError(err, true);
|
||||
|
||||
if (typeof err.body.message === "string") {
|
||||
this.#logK8sError({ message: err.body.message });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError({ body: err.body });
|
||||
}
|
||||
|
||||
async #getPods(opts: {
|
||||
namespace: string;
|
||||
fieldSelector?: string;
|
||||
labelSelector?: string;
|
||||
}): Promise<Array<k8s.V1Pod> | undefined> {
|
||||
const listReturn = await this.k8sClient.core
|
||||
.listNamespacedPod(
|
||||
opts.namespace,
|
||||
undefined, // pretty
|
||||
undefined, // allowWatchBookmarks
|
||||
undefined, // _continue
|
||||
opts.fieldSelector,
|
||||
opts.labelSelector,
|
||||
this.maxPendingRuns * 2, // limit
|
||||
undefined, // resourceVersion
|
||||
undefined, // resourceVersionMatch
|
||||
undefined, // sendInitialEvents
|
||||
this.intervalInSeconds, // timeoutSeconds,
|
||||
undefined // watch
|
||||
)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
|
||||
return listReturn?.body.items;
|
||||
}
|
||||
|
||||
async #getPendingIndeces(): Promise<Array<k8s.V1Pod> | undefined> {
|
||||
return await this.#getPods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Pending",
|
||||
labelSelector: "app=task-index",
|
||||
});
|
||||
}
|
||||
|
||||
async #getPendingTasks(): Promise<Array<k8s.V1Pod> | undefined> {
|
||||
return await this.#getPods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Pending",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
}
|
||||
|
||||
#countPods(pods: Array<k8s.V1Pod>): number {
|
||||
return pods.length;
|
||||
}
|
||||
|
||||
#filterPendingPods(
|
||||
pods: Array<k8s.V1Pod>,
|
||||
waitingReason: "CreateContainerError" | "RunContainerError"
|
||||
): Array<k8s.V1Pod> {
|
||||
return pods.filter((pod) => {
|
||||
const containerStatus = pod.status?.containerStatuses?.[0];
|
||||
return containerStatus?.state?.waiting?.reason === waitingReason;
|
||||
});
|
||||
}
|
||||
|
||||
async #sendPing() {
|
||||
this.logger.log("Sending ping");
|
||||
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutMs = (this.intervalInSeconds * 1000) / 2;
|
||||
|
||||
const fetchTimeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.opts.pingUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error("Failed to send ping, response not OK", {
|
||||
status: response.status,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
this.logger.log("Ping sent", { elapsedMs });
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
this.logger.log("Ping timeout", { timeoutSeconds: timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error("Failed to send ping", error);
|
||||
} finally {
|
||||
clearTimeout(fetchTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
async #heartbeat() {
|
||||
this.logger.log("Performing heartbeat");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const pendingTasks = await this.#getPendingTasks();
|
||||
|
||||
if (!pendingTasks) {
|
||||
this.logger.error("Failed to get pending tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPendingTasks = this.#countPods(pendingTasks);
|
||||
|
||||
const pendingIndeces = await this.#getPendingIndeces();
|
||||
|
||||
if (!pendingIndeces) {
|
||||
this.logger.error("Failed to get pending indeces");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPendingIndeces = this.#countPods(pendingIndeces);
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
this.logger.log("Finished heartbeat checks", { elapsedMs });
|
||||
|
||||
if (totalPendingTasks > this.maxPendingRuns) {
|
||||
this.logger.log("Too many pending tasks, skipping heartbeat", { totalPendingTasks });
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalPendingIndeces > this.maxPendingIndeces) {
|
||||
this.logger.log("Too many pending indeces, skipping heartbeat", { totalPendingIndeces });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCreateContainerErrors = this.#countPods(
|
||||
this.#filterPendingPods(pendingTasks, "CreateContainerError")
|
||||
);
|
||||
const totalRunContainerErrors = this.#countPods(
|
||||
this.#filterPendingPods(pendingTasks, "RunContainerError")
|
||||
);
|
||||
|
||||
if (totalCreateContainerErrors + totalRunContainerErrors > this.maxPendingErrors) {
|
||||
this.logger.log("Too many pending tasks with errors, skipping heartbeat", {
|
||||
totalRunContainerErrors,
|
||||
totalCreateContainerErrors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#sendPing();
|
||||
|
||||
this.logger.log("Heartbeat done", { totalPendingTasks, elapsedMs });
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.enabled = true;
|
||||
this.logger.log("Starting");
|
||||
|
||||
if (this.leadingEdge) {
|
||||
await this.#heartbeat();
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#heartbeat();
|
||||
} catch (error) {
|
||||
this.logger.error("Error while heartbeating", error);
|
||||
}
|
||||
}, this.intervalInSeconds * 1000);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = false;
|
||||
this.logger.log("Shutting down..");
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230419.0",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"typescript": "^5.0.4",
|
||||
"wrangler": "^3.0.0"
|
||||
"wrangler": "^3.57.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sqs": "^3.445.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { queueEvent } from "./events/queueEvent";
|
||||
import { queueEvents } from "./events/queueEvents";
|
||||
import { applyRateLimit } from "./rateLimit";
|
||||
import { Ratelimit } from "./rateLimiter";
|
||||
|
||||
export interface Env {
|
||||
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
|
||||
@@ -9,6 +11,8 @@ export interface Env {
|
||||
AWS_SQS_SECRET_ACCESS_KEY: string;
|
||||
AWS_SQS_QUEUE_URL: string;
|
||||
AWS_SQS_REGION: string;
|
||||
//rate limiter
|
||||
API_RATE_LIMITER: Ratelimit;
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -25,13 +29,13 @@ export default {
|
||||
switch (url.pathname) {
|
||||
case "/api/v1/events": {
|
||||
if (request.method === "POST") {
|
||||
return queueEvent(request, env);
|
||||
return applyRateLimit(request, env, () => queueEvent(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/v1/events/bulk": {
|
||||
if (request.method === "POST") {
|
||||
return queueEvents(request, env);
|
||||
return applyRateLimit(request, env, () => queueEvents(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Env } from "src";
|
||||
import { getApiKeyFromRequest } from "./apikey";
|
||||
import { json } from "./json";
|
||||
|
||||
export async function applyRateLimit(
|
||||
request: Request,
|
||||
env: Env,
|
||||
fn: () => Promise<Response>
|
||||
): Promise<Response> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
if (apiKey) {
|
||||
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
|
||||
const { success } = result;
|
||||
console.log(`Rate limiter`, {
|
||||
success,
|
||||
key: `${apiKey.apiKey.substring(0, 12)}...`,
|
||||
});
|
||||
if (!success) {
|
||||
//60s in the future
|
||||
const reset = Date.now() + 60 * 1000;
|
||||
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
|
||||
|
||||
return json(
|
||||
{
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"x-ratelimit-reset": reset.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Rate limiter: no API key for request`);
|
||||
}
|
||||
|
||||
//call the original function
|
||||
return fn();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Ratelimit {
|
||||
/*
|
||||
* The ratelimit function
|
||||
* @param {RatelimitOptions} options
|
||||
* @returns {Promise<RatelimitResponse>}
|
||||
*/
|
||||
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
|
||||
}
|
||||
|
||||
export interface RatelimitOptions {
|
||||
/*
|
||||
* The key to identify the user, can be an IP address, user ID, etc.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RatelimitResponse {
|
||||
/*
|
||||
* The ratelimit success status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
success: boolean;
|
||||
}
|
||||
@@ -1,7 +1,33 @@
|
||||
name = "proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2023-10-30"
|
||||
compatibility_date = "2024-05-13"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
[env.staging]
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.staging.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "1"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 100, period = 60 }
|
||||
|
||||
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.prod.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "2"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 300, period = 60 }
|
||||
+33
-10
@@ -1,13 +1,36 @@
|
||||
{
|
||||
"extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "prettier"],
|
||||
"rules": {
|
||||
"@typescript-eslint/strict-boolean-expressions": [
|
||||
"error",
|
||||
{
|
||||
"allowNullableBoolean": true,
|
||||
"allowNullableString": true,
|
||||
"allowNullableNumber": true
|
||||
"plugins": [
|
||||
"@trigger.dev/eslint-plugin",
|
||||
"react-hooks",
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"import"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {
|
||||
// Autofixes imports from "@trigger.dev/core" to fine grained modules
|
||||
"@trigger.dev/no-trigger-core-import": "error",
|
||||
// Normalize `import type {}` and `import { type }`
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
// the "type" annotation can get tangled and cause syntax errors
|
||||
// during some autofixes, so easier to just turn it off
|
||||
"prefer": "type-imports",
|
||||
"disallowTypeAnnotations": true,
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
// no-trigger-core-import splits imports into multiple lines
|
||||
// this one merges them back into a single line
|
||||
// if they still import from the same module
|
||||
"import/no-duplicates": ["warn", { "prefer-inline": true }],
|
||||
// lots of undeclared vars, enable this rule if you want to clean them up
|
||||
"turbo/no-undeclared-env-vars": "off"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["seed.js", "seedCloud.ts", "populate.js"]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ export function DefinitionTip({
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger>
|
||||
<span className="underline decoration-charcoal-600 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-500">
|
||||
<span className="cursor-default underline decoration-charcoal-500 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-400">
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" side="right" variant="dark" className="w-[16rem] min-w-[16rem]">
|
||||
<TooltipContent align="end" side="right" className="w-[16rem] min-w-[16rem]">
|
||||
<Header3 className="mb-1">{title}</Header3>
|
||||
{typeof content === "string" ? (
|
||||
<Paragraph variant="small">{content}</Paragraph>
|
||||
|
||||
@@ -55,16 +55,6 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
<SheetTrigger asChild={true}>{button}</SheetTrigger>
|
||||
<SheetContent className="@container">
|
||||
<SheetBody className="flex h-full flex-col justify-between">
|
||||
<Header2 className="mb-2.5 text-xl">Get help from the community</Header2>
|
||||
<Paragraph className="mb-4">
|
||||
The quickest way to get help and feedback or to provide advice to others is to join our
|
||||
Discord.
|
||||
</Paragraph>
|
||||
<div className="flex flex-col gap-x-4 @[30rem]:flex-row">
|
||||
<DiscordBanner />
|
||||
<GitHubDiscussionsBanner />
|
||||
</div>
|
||||
<hr className="mb-4" />
|
||||
<Header2 className="mb-2.5 text-xl">Send us an email</Header2>
|
||||
<Paragraph className="mb-4">We read every message and respond quickly.</Paragraph>
|
||||
<Form method="post" action="/resources/feedback" {...form.props}>
|
||||
@@ -106,6 +96,8 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
</Fieldset>
|
||||
</Form>
|
||||
<hr className="my-4" />
|
||||
<DiscordBanner />
|
||||
<hr className="mb-4" />
|
||||
<Header2 className="mb-2.5 text-xl">Troubleshooting</Header2>
|
||||
<Paragraph className="mb-4">
|
||||
If you're having trouble, check out our documentation or the Trigger.dev Status page.
|
||||
@@ -134,44 +126,20 @@ function DiscordBanner({ className }: { className?: string }) {
|
||||
href="https://trigger.dev/discord"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"group mb-4 flex w-full items-center justify-between rounded-md border border-charcoal-600 p-4 transition hover:border-text-link",
|
||||
"group mb-4 flex w-full items-center justify-between rounded-md border border-grid-bright bg-charcoal-750 p-4 pl-6 transition hover:border-text-link",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<DiscordIcon className="h-8 w-8" />
|
||||
<Header1 className="text-2xl font-semibold text-text-bright transition group-hover:text-white">
|
||||
Join our Discord community
|
||||
</Header1>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Get help or answer questions from the Trigger.dev community.
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-6">
|
||||
<DiscordIcon className="size-12" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header1 className="text-2xl font-semibold text-text-bright transition group-hover:text-white">
|
||||
Join our Discord community
|
||||
</Header1>
|
||||
<Paragraph>The quickest way to get answers from the Trigger.dev community.</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-5 text-charcoal-500 transition group-hover:translate-x-1 group-hover:text-text-link" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubDiscussionsBanner({ className }: { className?: string }) {
|
||||
return (
|
||||
<a
|
||||
href="https://github.com/triggerdotdev/trigger.dev/discussions"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"group mb-4 flex w-full items-center justify-between rounded-md border border-charcoal-600 p-4 transition hover:border-text-dimmed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<GitHubLightIcon className="mb-1 h-7 w-7" />
|
||||
<Header1 className="text-2xl font-semibold text-text-bright transition group-hover:text-white">
|
||||
View our GitHub Discussions
|
||||
</Header1>
|
||||
<Paragraph variant="small">
|
||||
Post your questions, feedback, and feature requests on GitHub.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-5 text-charcoal-500 transition group-hover:translate-x-1 group-hover:text-text-bright" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./primitives/ClientTabs";
|
||||
import { ClipboardField } from "./primitives/ClipboardField";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
|
||||
export function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey: string }) {
|
||||
return (
|
||||
@@ -133,9 +134,38 @@ export function TriggerDevStep({ extra }: { extra?: string }) {
|
||||
// Trigger.dev version 3 setup commands
|
||||
const v3PackageTag = "beta";
|
||||
|
||||
function getApiUrlArg() {
|
||||
const appOrigin = useAppOrigin();
|
||||
|
||||
let apiUrl: string | undefined = undefined;
|
||||
|
||||
switch (appOrigin) {
|
||||
case "https://cloud.trigger.dev":
|
||||
// don't display the arg, use the CLI default
|
||||
break;
|
||||
case "https://test-cloud.trigger.dev":
|
||||
apiUrl = "https://test-api.trigger.dev";
|
||||
break;
|
||||
case "https://internal.trigger.dev":
|
||||
apiUrl = "https://internal-api.trigger.dev";
|
||||
break;
|
||||
default:
|
||||
apiUrl = appOrigin;
|
||||
break;
|
||||
}
|
||||
|
||||
return apiUrl ? `-a ${apiUrl}` : undefined;
|
||||
}
|
||||
|
||||
export function InitCommandV3() {
|
||||
const project = useProject();
|
||||
const projectRef = project.ref;
|
||||
|
||||
const apiUrlArg = getApiUrlArg();
|
||||
|
||||
const initCommandParts = [`trigger.dev@${v3PackageTag}`, "init", `-p ${projectRef}`, apiUrlArg];
|
||||
const initCommand = initCommandParts.filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
@@ -148,7 +178,7 @@ export function InitCommandV3() {
|
||||
variant="primary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`npx trigger.dev@${v3PackageTag} init -p ${projectRef}`}
|
||||
value={`npx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
@@ -156,7 +186,7 @@ export function InitCommandV3() {
|
||||
variant="primary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`pnpm dlx trigger.dev@${v3PackageTag} init -p ${projectRef}`}
|
||||
value={`pnpm dlx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
@@ -164,7 +194,7 @@ export function InitCommandV3() {
|
||||
variant="primary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`yarn dlx trigger.dev@${v3PackageTag} init -p ${projectRef}`}
|
||||
value={`yarn dlx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
type UpgradePromptProps = {
|
||||
runsEnabled: boolean;
|
||||
runCountCap: number;
|
||||
planPath: string;
|
||||
};
|
||||
|
||||
export function UpgradePrompt({ runsEnabled, runCountCap, planPath }: UpgradePromptProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 items-center justify-between border border-error bg-repeat py-0 pl-3 pr-2"
|
||||
style={{ backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
|
||||
<Paragraph variant="small" className="text-error">
|
||||
{runsEnabled
|
||||
? `You have exceeded the monthly ${formatNumberCompact(runCountCap)} runs
|
||||
limit`
|
||||
: `No runs are executing because you have exceeded the free limit`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={planPath}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShowUpgradePrompt(organization?: MatchedOrganization) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
const shouldShow =
|
||||
organization !== undefined &&
|
||||
currentPlan !== undefined &&
|
||||
currentPlan.usage.exceededRunCount &&
|
||||
currentPlan.usage.runCountCap !== undefined;
|
||||
|
||||
if (!shouldShow) {
|
||||
return { shouldShow };
|
||||
}
|
||||
|
||||
return {
|
||||
shouldShow,
|
||||
runCountCap: currentPlan.usage.runCountCap!,
|
||||
runsEnabled: organization.runsEnabled,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -28,7 +28,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
|
||||
<Paragraph className="text-2sm text-text-bright">Free Plan</Paragraph>
|
||||
</div>
|
||||
<Link to={to} className="text-2sm text-text-link">
|
||||
Learn more
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative mt-3 h-1 rounded-full bg-background-dimmed">
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import * as Slider from "@radix-ui/react-slider";
|
||||
import { Plans, estimate } from "@trigger.dev/billing";
|
||||
import { Plans, estimate } from "@trigger.dev/platform/v2";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DefinitionTip } from "../DefinitionTooltip";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { DefinitionTip } from "../../DefinitionTooltip";
|
||||
import { Header2 } from "../../primitives/Headers";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
+6
-6
@@ -2,17 +2,17 @@ import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActiveSubscription, Plan, Plans, SetPlanBodySchema } from "@trigger.dev/billing";
|
||||
import { ActiveSubscription, Plan, Plans, SetPlanBodySchema } from "@trigger.dev/platform/v2";
|
||||
import { useState } from "react";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { DefinitionTip } from "../DefinitionTooltip";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
import { DefinitionTip } from "../../DefinitionTooltip";
|
||||
import { Feedback } from "../../Feedback";
|
||||
import { Button, LinkButton } from "../../primitives/Buttons";
|
||||
import SegmentedControl from "../../primitives/SegmentedControl";
|
||||
import { RunsVolumeDiscountTable } from "./RunsVolumeDiscountTable";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { Spinner } from "../../primitives/Spinner";
|
||||
|
||||
const pricingDefinitions = {
|
||||
concurrentRuns: {
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { RunPriceBracket } from "@trigger.dev/billing";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { RunPriceBracket } from "@trigger.dev/platform/v2";
|
||||
import { Header2 } from "../../primitives/Headers";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
|
||||
export function RunsVolumeDiscountTable({
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { SimpleTooltip } from "../../primitives/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type UsageBarProps = {
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../../primitives/Buttons";
|
||||
import { Icon } from "../../primitives/Icon";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
|
||||
export function UpgradePrompt() {
|
||||
const organization = useOrganization();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
if (!plan || !plan.v3Usage.hasExceededFreeTier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setUTCMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setUTCDate(1);
|
||||
nextMonth.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 items-center justify-between border border-error bg-repeat py-0 pl-3 pr-2"
|
||||
style={{ backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
|
||||
<Paragraph variant="small" className="text-error">
|
||||
You have exceeded the monthly $
|
||||
{(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. No runs
|
||||
will execute in Prod until{" "}
|
||||
<DateTime date={nextMonth} includeTime={false} timeZone="utc" />, or you upgrade.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
leadingIconClassName="px-0"
|
||||
to={v3BillingPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShowUpgradePrompt(organization?: MatchedOrganization) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
const shouldShow = currentPlan?.v3Usage.hasExceededFreeTier === true;
|
||||
return { shouldShow };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { SimpleTooltip } from "../../primitives/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type UsageBarProps = {
|
||||
current: number;
|
||||
billingLimit?: number;
|
||||
tierLimit?: number;
|
||||
projectedUsage?: number;
|
||||
isPaying: boolean;
|
||||
};
|
||||
|
||||
const startFactor = 4;
|
||||
|
||||
export function UsageBar({
|
||||
current,
|
||||
billingLimit,
|
||||
tierLimit,
|
||||
projectedUsage,
|
||||
isPaying,
|
||||
}: UsageBarProps) {
|
||||
const getLargestNumber = Math.max(
|
||||
current,
|
||||
tierLimit ?? -Infinity,
|
||||
projectedUsage ?? -Infinity,
|
||||
billingLimit ?? -Infinity,
|
||||
5
|
||||
);
|
||||
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
|
||||
const maxRange = Math.round(getLargestNumber * 1.1);
|
||||
const tierRunLimitPercentage = tierLimit ? Math.round((tierLimit / maxRange) * 100) : 0;
|
||||
const projectedRunsPercentage = projectedUsage
|
||||
? Math.round((projectedUsage / maxRange) * 100)
|
||||
: 0;
|
||||
const billingLimitPercentage =
|
||||
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
|
||||
const usagePercentage = Math.round((current / maxRange) * 100);
|
||||
|
||||
//cap the usagePercentage to the freeRunLimitPercentage
|
||||
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
|
||||
|
||||
return (
|
||||
<div className="h-fit w-full py-12">
|
||||
<div className="relative h-3 w-full rounded-sm bg-background-bright">
|
||||
{billingLimit !== undefined && (
|
||||
<motion.div
|
||||
initial={{ width: billingLimitPercentage / startFactor + "%" }}
|
||||
animate={{ width: billingLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${billingLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Billing limit:"
|
||||
value={formatCurrency(billingLimit, false)}
|
||||
position="bottomRow2"
|
||||
percentage={billingLimitPercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{tierLimit !== undefined && (
|
||||
<motion.div
|
||||
initial={{ width: tierRunLimitPercentage / startFactor + "%" }}
|
||||
animate={{ width: tierRunLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${tierRunLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-900/50"
|
||||
>
|
||||
<Legend
|
||||
text={isPaying ? `Included usage:` : `Tier limit:`}
|
||||
value={formatCurrency(tierLimit, false)}
|
||||
position="bottomRow1"
|
||||
percentage={tierRunLimitPercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{projectedUsage !== undefined && projectedUsage !== 0 && (
|
||||
<motion.div
|
||||
initial={{ width: projectedRunsPercentage / startFactor + "%" }}
|
||||
animate={{ width: projectedRunsPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${projectedRunsPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Projected:"
|
||||
value={formatCurrency(projectedUsage, false)}
|
||||
position="topRow2"
|
||||
percentage={projectedRunsPercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ width: usagePercentage / startFactor + "%" }}
|
||||
animate={{ width: usagePercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
className={cn(
|
||||
"absolute h-3 rounded-l-sm",
|
||||
tierLimit && current > tierLimit ? "bg-rose-600" : "bg-green-600"
|
||||
)}
|
||||
>
|
||||
<Legend
|
||||
text="Used:"
|
||||
value={formatCurrency(current, false)}
|
||||
position="topRow1"
|
||||
percentage={usagePercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ width: usageCappedToLimitPercentage / startFactor + "%" }}
|
||||
animate={{ width: usageCappedToLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usageCappedToLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const positions = {
|
||||
topRow1: "bottom-0 h-9",
|
||||
topRow2: "bottom-0 h-14",
|
||||
bottomRow1: "top-0 h-9 items-end",
|
||||
bottomRow2: "top-0 h-14 items-end",
|
||||
};
|
||||
|
||||
type LegendProps = {
|
||||
text: string;
|
||||
value: number | string;
|
||||
percentage: number;
|
||||
position: keyof typeof positions;
|
||||
tooltipContent?: string;
|
||||
};
|
||||
|
||||
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
|
||||
const flipLegendPositionValue = 80;
|
||||
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-full z-10 flex border-charcoal-500",
|
||||
positions[position],
|
||||
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
|
||||
)}
|
||||
>
|
||||
{tooltipContent ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
|
||||
{text}
|
||||
<span className="ml-1 text-text-dimmed">{value}</span>
|
||||
</Paragraph>
|
||||
}
|
||||
side="top"
|
||||
content={tooltipContent}
|
||||
className="z-50 h-fit"
|
||||
/>
|
||||
) : (
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
|
||||
{text}
|
||||
<span className="ml-1 text-text-dimmed">{value}</span>
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,103 +58,116 @@ const extraLinesWhenClipping = 0.35;
|
||||
|
||||
const defaultTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: "#9CDCFE",
|
||||
color: "#9C9AF2",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
styles: [
|
||||
{
|
||||
types: ["prolog"],
|
||||
types: ["comment", "prolog", "doctype", "cdata"],
|
||||
style: {
|
||||
color: "rgb(0, 0, 128)",
|
||||
color: "#5F6570",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["comment"],
|
||||
style: {
|
||||
color: "rgb(106, 153, 85)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["builtin", "changed", "keyword", "interpolation-punctuation"],
|
||||
style: {
|
||||
color: "rgb(86, 156, 214)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["number", "inserted"],
|
||||
style: {
|
||||
color: "rgb(181, 206, 168)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["constant"],
|
||||
style: {
|
||||
color: "rgb(100, 102, 149)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["attr-name", "variable"],
|
||||
style: {
|
||||
color: "rgb(156, 220, 254)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["deleted", "string", "attr-value", "template-punctuation"],
|
||||
style: {
|
||||
color: "rgb(206, 145, 120)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["selector"],
|
||||
style: {
|
||||
color: "rgb(215, 186, 125)",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Fix tag color
|
||||
types: ["tag"],
|
||||
style: {
|
||||
color: "rgb(78, 201, 176)",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Fix tag color for HTML
|
||||
types: ["tag"],
|
||||
languages: ["markup"],
|
||||
style: {
|
||||
color: "rgb(86, 156, 214)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["punctuation", "operator"],
|
||||
style: {
|
||||
color: "rgb(212, 212, 212)",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Fix punctuation color for HTML
|
||||
types: ["punctuation"],
|
||||
languages: ["markup"],
|
||||
style: {
|
||||
color: "#808080",
|
||||
color: "#878C99",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["function"],
|
||||
types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"],
|
||||
style: {
|
||||
color: "rgb(220, 220, 170)",
|
||||
color: "#9B99FF",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["class-name"],
|
||||
types: ["selector", "attr-name", "string", "char", "builtin", "inserted"],
|
||||
style: {
|
||||
color: "rgb(78, 201, 176)",
|
||||
color: "#AFEC73",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["operator", "entity", "url"],
|
||||
style: {
|
||||
color: "#D4D4D4",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["variable"],
|
||||
style: {
|
||||
color: "#CCCBFF",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["atrule", "attr-value", "keyword"],
|
||||
style: {
|
||||
color: "#E888F8",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["function", "class-name"],
|
||||
style: {
|
||||
color: "#D9F07C",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["regex"],
|
||||
style: {
|
||||
color: "#d16969",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["important", "bold"],
|
||||
style: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["italic"],
|
||||
style: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["namespace"],
|
||||
style: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["deleted"],
|
||||
style: {
|
||||
color: "#F85149",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["boolean"],
|
||||
style: {
|
||||
color: "#9B99FF",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["char"],
|
||||
style: {
|
||||
color: "rgb(209, 105, 105)",
|
||||
color: "#b5cea8",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["tag"],
|
||||
style: {
|
||||
color: "#D7BA7D",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["keyword.operator"],
|
||||
style: {
|
||||
color: "#8271ED",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["meta.template.expression"],
|
||||
style: {
|
||||
color: "#d4d4d4",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useShowUpgradePrompt } from "../billing/UpgradePrompt";
|
||||
import { useShowUpgradePrompt } from "../billing/v3/UpgradePrompt";
|
||||
|
||||
/** This container is used to surround the entire app, it correctly places the nav bar */
|
||||
export function AppContainer({ children }: { children: React.ReactNode }) {
|
||||
@@ -60,7 +60,9 @@ export function MainCenteredContainer({
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className={cn("mx-auto mt-[25vh] max-w-xs overflow-y-auto", className)}>{children}</div>
|
||||
<div className={cn("mx-auto mt-6 max-w-xs overflow-y-auto p-1 md:mt-[22vh]", className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
CursorArrowRaysIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
SignalIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
v3ApiKeysPath,
|
||||
v3BillingPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ProjectAlertsPath,
|
||||
@@ -52,13 +54,14 @@ import {
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
v3TestPath,
|
||||
v3UsagePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { FreePlanUsage } from "../billing/v2/FreePlanUsage";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
@@ -75,6 +78,7 @@ import {
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
@@ -100,8 +104,8 @@ type SideMenuProps = {
|
||||
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const currentPlan = useCurrentPlan();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -173,16 +177,36 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
to={organizationTeamPath(organization)}
|
||||
iconColor="text-sky-500"
|
||||
iconColor="text-amber-500"
|
||||
data-action="team"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name={isManagedCloud ? "Usage & Billing" : "Usage"}
|
||||
icon={ChartBarIcon}
|
||||
to={organizationBillingPath(organization)}
|
||||
iconColor="text-green-600"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
{organization.projects.some((proj) => proj.version === "V3") && isManagedCloud && (
|
||||
<>
|
||||
<SideMenuItem
|
||||
name="Usage"
|
||||
icon={ChartBarIcon}
|
||||
to={v3UsagePath(organization)}
|
||||
iconColor="text-green-600"
|
||||
data-action="usage"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
to={v3BillingPath(organization)}
|
||||
iconColor="text-blue-600"
|
||||
data-action="billing"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{organization.projects.some((proj) => proj.version === "V2") && (
|
||||
<SideMenuItem
|
||||
name="Usage (v2)"
|
||||
icon={ChartBarIcon}
|
||||
to={organizationBillingPath(organization)}
|
||||
iconColor="text-green-600"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Organization settings"
|
||||
icon="settings"
|
||||
@@ -193,22 +217,19 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-2">
|
||||
{project.version === "V2" ? (
|
||||
<Callout variant={"info"}>This is a v2 project</Callout>
|
||||
) : (
|
||||
<Callout variant={"idea"}>This is a v3 project in Developer Preview</Callout>
|
||||
{project.version === "V2" && (
|
||||
<Callout variant={"info"}>
|
||||
<Paragraph variant="small">
|
||||
This is a v2 project.{" "}
|
||||
<TextLink href="https://trigger.dev/docs/v3/upgrading-from-v2">
|
||||
Upgrade to v3
|
||||
</TextLink>
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
{project.version === "V2" && (
|
||||
<SideMenuItem
|
||||
to="https://trigger.dev/v3-early-access"
|
||||
target="_blank"
|
||||
name="Request access to v3"
|
||||
icon={V3Icon}
|
||||
/>
|
||||
)}
|
||||
{currentPlan?.subscription?.isPaying === true && (
|
||||
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -263,23 +284,13 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
{project.version === "V2" ? (
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
) : (
|
||||
<SideMenuItem
|
||||
name="Documentation (v3)"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs/v3"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
@@ -287,41 +298,31 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
{project.version === "V2" ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Help & Feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Feedback
|
||||
defaultValue="developer preview"
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
leadingIconClassName="text-primary"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<span className="text-primary">Give feedback on v3</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentPlan && !currentPlan.subscription?.isPaying && currentPlan.usage.runCountCap && (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{currentPlan?.v3Subscription?.isPaying === false && (
|
||||
<FreePlanUsage
|
||||
to={organizationBillingPath(organization)}
|
||||
percentage={currentPlan.usage.currentRunCount / currentPlan.usage.runCountCap}
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -474,7 +475,7 @@ function V2ProjectSideMenu({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project"}>
|
||||
<SideMenuHeader title={"Project (v2)"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
@@ -546,12 +547,11 @@ function V3ProjectSideMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project (v3)"} />
|
||||
<SideMenuHeader title={"Project"} />
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon={TaskIcon}
|
||||
iconColor="text-blue-500"
|
||||
count={project.jobCount}
|
||||
to={v3ProjectPath(organization, project)}
|
||||
data-action="tasks"
|
||||
/>
|
||||
@@ -615,15 +615,3 @@ function V3ProjectSideMenu({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function V3Icon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="8" fill="#A8FF53" />
|
||||
<path
|
||||
d="M7.7488 6.17L5.5818 12H3.6678L1.5008 6.17H3.2058L4.6248 10.339L6.0328 6.17H7.7488ZM11.0527 7.292C11.6357 7.303 12.2297 7.006 12.2297 6.28C12.2297 5.774 11.7787 5.433 11.0527 5.433C10.4147 5.433 9.98567 5.741 9.91967 6.214L8.22567 6.126C8.36867 4.861 9.51267 4.014 11.0857 4.014C12.8457 4.014 13.9567 4.806 13.9567 6.049C13.9567 6.951 13.3847 7.534 12.3067 7.776C13.5387 8.04 14.2207 8.777 14.2207 9.855C14.2207 11.274 13.0107 12.176 11.0857 12.176C9.32567 12.176 8.12667 11.197 8.04967 9.712L9.75467 9.646C9.83167 10.405 10.4917 10.757 11.0967 10.757C11.8007 10.757 12.4937 10.394 12.4937 9.591C12.4937 8.81 11.7897 8.425 11.0527 8.447L10.3817 8.458V7.281L11.0527 7.292Z"
|
||||
fill="#15171A"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,8 @@ const Alert = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertPortal = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
|
||||
const AlertPortal = ({ children, ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal {...props}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ const sizes = {
|
||||
shortcut: "-ml-0.5 -mr-1.5 rounded justify-self-center",
|
||||
},
|
||||
large: {
|
||||
button: "h-10 px-2 text-sm font-medium",
|
||||
button: "h-10 px-2 text-base font-medium",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user