Compare commits

...

48 Commits

Author SHA1 Message Date
Matt Aitken 24557a7324 Release 3.0.0-beta.50 2024-07-19 11:34:40 +01:00
github-actions[bot] 8b10a592f3 chore: Update version for release (beta) (#1229)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-19 11:33:36 +01:00
Eric Allam d7089e12a9 Replace findUnique with findFirst when doing a relation aggregate 2024-07-19 10:18:56 +01:00
Eric Allam 162d864517 Fixed prisma dataLoader slow query when getting an attempt payload 2024-07-19 09:42:48 +01:00
Matt Aitken 8ba9987944 Declarative schedules (#1226)
* Added type (STATIC or DYNAMIC) to TaskSchedule. Defaults to dynamic

* WIP with dev indexing of static schedules

* Added a code comment

* First stab at deleting unused static schedules

* Dashboard changes for the static schedules

* Generate the description. Upsert the instances when editing. Fix for the friendlyId

* Don’t allow deleting of static schedules

* Don’t allow enabling/disabling of static schedules

* Added filtering for schedule types

* Syncing of schedule for deployed tasks

* Static schedules are now created for each environment

* Added a second static schedule for testing

* Add the type to the schedule task run payload and the object you get back from the SDK

* Changed static/dynamic to declarative/imperative

* Timezone example

* Changeset

* Updated scheduled docs to include declarative

* When you test a schedule it set the type to “IMPERATIVE”

* Improved the tooltip

* Fix for queue time continuing to rise when a run is canceled/expired etc

* Update the info panel on a selected declarative schedule

* Check if there are no instances. This should never happen but log an error if it does

* Throw errors and push them through to the CLI dev command

* Fail deployments if creating the background tasks or schedules fails

* Format the deployment error so it gets displayed

* Changed the maxed out schedules error message to remove bit about support
2024-07-18 20:24:54 +01:00
nicktrn 7056ce53f2 Add missing dep to webapp image
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-infra (push) Has been skipped
2024-07-18 13:58:33 +01:00
nicktrn 2d6fd62fb8 Release 3.0.0-beta.49
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-infra (push) Has been skipped
2024-07-17 14:20:21 +01:00
github-actions[bot] e8993ee882 chore: Update version for release (beta) (#1211)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-17 14:18:40 +01:00
nicktrn e0cdebbe09 Disable excessive websocket heartbeat logs 2024-07-17 13:28:40 +01:00
Matt Aitken d6c6dc993d try/catch opening the v3 CLI login URL. Fix for Ubuntu 2024-07-17 13:15:42 +01:00
nicktrn 27de884f93 Remove unused ping message schema 2024-07-17 13:01:43 +01:00
nicktrn c2707800aa Improve prisma errors for missing generate step 2024-07-17 09:53:38 +01:00
nicktrn dbda820a71 v3: fix websocket timeouts (#1220)
* prevent uncaught websocket exceptions

* dev cli sends regular pings to keep connection alive

* add changeset

* fix for prod message forwarding

* add server-side websocket ping

* Revert "dev cli sends regular pings to keep connection alive"

This reverts commit 9a6a7a21433b9f0665f72be015d0ba5a8d83eba4.

* add dev cli websocket debug logs, always print errors

* actually start the ping service

* update changeset

* catch remaining dev cli message handler errors
2024-07-17 09:05:15 +01:00
Gautam Singh e417aca879 v3: add extraCACerts config (#1214)
* Added extraCACerts config to help with self signed/private ca cert chain

* changeset

* Update changeset

* resolved pr comments

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-07-17 08:56:52 +01:00
Ray Amjad 5a3b4a450b Update github-actions.mdx (#1222) 2024-07-17 08:55:14 +01:00
Matt Aitken f91a118958 Fix for dev env vars getting deleted when another user edits theirs 2024-07-16 10:57:35 +01:00
nicktrn 9971de6a1e Increase span attribute value length limit to 2048 2024-07-16 08:15:05 +01:00
Matt Aitken 0847bee5b8 Fix for stripping out TRIGGER_ env vars 2024-07-15 17:11:15 +01:00
Matt Aitken 6152b2f3dc Don’t error when setting TRIGGER_SECRET_KEY or TRIGGER_API_URL env vars, just filter them out 2024-07-15 16:39:13 +01:00
Matt Aitken ec8381e2d0 Fix for staging environment variable API endpoints
Ugly but effective fix for the fact we can pass the slug in as “staging” when we use “stg” internally
2024-07-15 16:33:00 +01:00
Matt Aitken 76a15fd278 The run filter for tasks query was very slow if you had a lot of synced tasks 2024-07-15 15:59:11 +01:00
nicktrn 824a6205f7 Fix deployments stuck in deploying state 2024-07-15 12:00:16 +01:00
Matt Aitken a0984d5292 Allow alerts from external and unlinked Slack channels 2024-07-12 11:14:57 +01:00
Matt Aitken c61d8ed890 Removed badge from the Tasks in the side menu
It was showing the v2 job count. Mostly this didn't actually show anything but one user reported they saw a number here and it's definitely a bug.
2024-07-12 10:51:54 +01:00
Eric Allam 76a0f61dee v3: improve schedule reliability by ensuring next tick is always scheduled 2024-07-11 15:35:34 +01:00
Jacob Paris d934feb02a feature: add no-trigger-core-import (#1175)
* feature: add no-trigger-core-import

* fix: handle aliases and limit to exported modules

* fix: use longest export map name

* fix: support export type and interface

* fix: simplify message when export map is needed

* feature: fine grained modules for core

* fix: add prerequisite lint plugins

* fix: why do I have to fix these components again???

* fix: tests

* Add eslint config and vscode settings

* Create yellow-roses-arrive.md

* Remove the vscode linting

This is temporary so no one fixes these until we merge the remix branch

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
Co-authored-by: Eric Allam <eallam@icloud.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-07-11 15:14:55 +01:00
Eric Allam 7fdedf3137 Fix excessive logs from the MarQS rebalancer 2024-07-11 14:20:59 +01:00
Eric Allam f6e23e94d1 Ensure failed migrations fail deployment by not swallowing exit codes in entrypoint.sh 2024-07-11 13:22:40 +01:00
Matt Aitken 3d6cfe5ead Admin API endpoint that adds v3 staging environments, and updates their concurrency 2024-07-11 10:22:21 +01:00
nicktrn 946efd1fb4 v3: deployment index retry (#1205)
* add built at date to worker deployments

* stay on correct deployment page when clicking row

* expose built at via presenters

* more specific deployment statuses

* add deployment indexing retry

* improve deploy timeout logs and add dequeue on completion

* unify deployment indexing retryable status

* only allow index retries of the latest deployment

* improve jsxfu
2024-07-10 17:54:36 +01:00
Matt Aitken 3d645e2b2c Fix for usage of zero displaying weirdly 2024-07-10 17:31:40 +01:00
Matt Aitken 45cb75b8a3 Only show dev runs in the tasks usage table 2024-07-10 17:22:35 +01:00
Matt Aitken 91d45e5ba6 Added a link to the pricing page for machines 2024-07-10 17:04:29 +01:00
James Ritchie fa5b3fe11e Only show the github verified badge on the Select Plan onboarding route 2024-07-10 16:50:52 +01:00
James Ritchie 90506322b5 Hide the tooltip content on the usage graph 2024-07-10 16:46:10 +01:00
Matt Aitken e59dd465d8 Documented the task usage functions 2024-07-10 15:19:45 +01:00
Matt Aitken 98d5f005e4 Added delay to the docs and improved the ttl 2024-07-10 10:28:49 +01:00
Matt Aitken a6b5d2edc5 Added ttl to trigger docs 2024-07-10 09:41:09 +01:00
Matt Aitken f4a1d5f36d Removed broken links from writing tasks page 2024-07-10 06:35:41 +01:00
Matt Aitken 6fb6cb6aaf Docs: added alerts link from feature matrix 2024-07-10 06:35:41 +01:00
Eric Allam 0b6417f1d8 Improve the section on adding trigger.dev to dev deps 2024-07-09 21:37:37 +01:00
Matt Aitken a685f5a4ec Removed the enable/disable v3 from the admin area 2024-07-09 19:00:19 +01:00
Matt Aitken 62532d5ce1 Fix for package name mistake 2024-07-09 15:53:03 +01:00
Matt Aitken 786d5508f0 Cover upgrading dev dependency 2024-07-09 15:52:42 +01:00
Matt Aitken 187f02780b Added upgrading packages guide 2024-07-09 15:49:46 +01:00
Eric Allam b8b3ea07eb Release 3.0.0-beta.48
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-infra (push) Has been skipped
2024-07-09 14:06:41 +01:00
github-actions[bot] e341df806c chore: Update version for release (beta) (#1209)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-09 14:05:49 +01:00
Eric Allam ecf1110ab7 v3: Export AbortTaskRunError from @trigger.dev/sdk/v3 2024-07-09 14:00:44 +01:00
180 changed files with 4370 additions and 953 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Increase span attribute value length limit to 2048
+7
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Improve prisma errors for missing postinstall
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
try/catch opening the login URL
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
v3: Export AbortTaskRunError from @trigger.dev/sdk/v3
+8
View File
@@ -46,6 +46,7 @@
"changesets": [
"afraid-sheep-joke",
"angry-eagles-trade",
"angry-trees-drop",
"beige-pears-explode",
"beige-pens-dance",
"big-tomatoes-deliver",
@@ -60,6 +61,7 @@
"cool-comics-burn",
"cool-glasses-bake",
"cuddly-feet-approve",
"curly-monkeys-tell",
"dry-walls-check",
"dull-mangos-press",
"eight-pumas-float",
@@ -67,9 +69,11 @@
"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",
@@ -97,7 +101,9 @@
"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",
@@ -161,6 +167,7 @@
"tender-turkeys-compete",
"thick-carrots-sneeze",
"thin-parents-heal",
"thirty-hotels-raise",
"thirty-islands-kiss",
"tidy-balloons-suffer",
"tidy-dryers-sleep",
@@ -175,6 +182,7 @@
"violet-clocks-notice",
"warm-olives-provide",
"warm-planes-taste",
"yellow-roses-arrive",
"young-snails-sell"
]
}
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Added declarative cron schedules
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add more package exports that can be used from the web app
+33 -10
View File
@@ -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"]
}
@@ -44,7 +44,7 @@ export function UsageBar({
return (
<div className="h-fit w-full py-12">
<div className="relative h-3 w-full rounded-sm bg-background-bright">
{billingLimit && (
{billingLimit !== undefined && (
<motion.div
initial={{ width: billingLimitPercentage / startFactor + "%" }}
animate={{ width: billingLimitPercentage + "%" }}
@@ -57,11 +57,10 @@ export function UsageBar({
value={formatCurrency(billingLimit, false)}
position="bottomRow2"
percentage={billingLimitPercentage}
tooltipContent={`Billing limit: ${formatCurrency(billingLimit, false)}`}
/>
</motion.div>
)}
{tierLimit && (
{tierLimit !== undefined && (
<motion.div
initial={{ width: tierRunLimitPercentage / startFactor + "%" }}
animate={{ width: tierRunLimitPercentage + "%" }}
@@ -74,14 +73,10 @@ export function UsageBar({
value={formatCurrency(tierLimit, false)}
position="bottomRow1"
percentage={tierRunLimitPercentage}
tooltipContent={`${isPaying ? "Included usage" : "Tier limit"}: ${formatCurrency(
tierLimit,
false
)}`}
/>
</motion.div>
)}
{projectedUsage && projectedUsage !== 0 && (
{projectedUsage !== undefined && projectedUsage !== 0 && (
<motion.div
initial={{ width: projectedRunsPercentage / startFactor + "%" }}
animate={{ width: projectedRunsPercentage + "%" }}
@@ -94,7 +89,6 @@ export function UsageBar({
value={formatCurrency(projectedUsage, false)}
position="topRow2"
percentage={projectedRunsPercentage}
tooltipContent={`Projected runs: ${formatCurrency(projectedUsage, false)}`}
/>
</motion.div>
)}
@@ -113,7 +107,6 @@ export function UsageBar({
value={formatCurrency(current, false)}
position="topRow1"
percentage={usagePercentage}
tooltipContent={`Used: ${formatCurrency(current, false)}`}
/>
</motion.div>
<motion.div
@@ -140,7 +133,7 @@ type LegendProps = {
value: number | string;
percentage: number;
position: keyof typeof positions;
tooltipContent: string;
tooltipContent?: string;
};
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
@@ -154,17 +147,24 @@ function Legend({ text, value, position, percentage, tooltipContent }: LegendPro
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
)}
>
<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"
/>
{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>
);
}
@@ -552,7 +552,6 @@ function V3ProjectSideMenu({
name="Tasks"
icon={TaskIcon}
iconColor="text-blue-500"
count={project.jobCount}
to={v3ProjectPath(organization, project)}
data-action="tasks"
/>
@@ -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>
@@ -10,8 +10,8 @@ const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ className, children, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props}>
const DialogPortal = ({ children, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
@@ -28,8 +28,8 @@ interface SheetPortalProps
extends SheetPrimitive.DialogPortalProps,
VariantProps<typeof portalVariants> {}
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal className={cn(className)} {...props}>
const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal {...props}>
<div className={portalVariants({ position })}>{children}</div>
</SheetPrimitive.Portal>
);
@@ -11,22 +11,32 @@ import { cn } from "~/utils/cn";
export function DeploymentStatus({
status,
isBuilt,
className,
}: {
status: WorkerDeploymentStatus;
isBuilt: boolean;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<DeploymentStatusIcon status={status} className="h-4 w-4" />
<DeploymentStatusLabel status={status} />
<DeploymentStatusLabel status={status} isBuilt={isBuilt} />
</span>
);
}
export function DeploymentStatusLabel({ status }: { status: WorkerDeploymentStatus }) {
export function DeploymentStatusLabel({
status,
isBuilt,
}: {
status: WorkerDeploymentStatus;
isBuilt: boolean;
}) {
return (
<span className={deploymentStatusClassNameColor(status)}>{deploymentStatusTitle(status)}</span>
<span className={deploymentStatusClassNameColor(status)}>
{deploymentStatusTitle(status, isBuilt)}
</span>
);
}
@@ -79,7 +89,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
}
}
export function deploymentStatusTitle(status: WorkerDeploymentStatus): string {
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
switch (status) {
case "PENDING":
return "Pending…";
@@ -92,9 +102,17 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus): string {
case "CANCELED":
return "Canceled";
case "TIMED_OUT":
return "Timed out";
if (!isBuilt) {
return "Build timed out";
}
return "Indexing timed out";
case "FAILED":
return "Failed";
if (!isBuilt) {
return "Build failed";
}
return "Indexing failed";
default: {
assertNever(status);
}
@@ -0,0 +1,55 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
type RetryDeploymentIndexingDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
export function RetryDeploymentIndexingDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RetryDeploymentIndexingDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/retry-indexing`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="retry-indexing">
<DialogHeader>Retry indexing this deployment?</DialogHeader>
<DialogDescription>
Retrying can be useful if indexing failed due to missing environment variables. Make sure
you set them before retrying. In most other cases, indexing will keep failing until you fix
any errors and re-deploy.
</DialogDescription>
<DialogFooter>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/retry-indexing`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/small"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Retrying..." : "Retry indexing"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -1,6 +1,6 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { RuntimeEnvironment } from "@trigger.dev/database";
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { useCallback } from "react";
import { z } from "zod";
import { Input } from "~/components/primitives/Input";
@@ -17,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from "../../primitives/SimpleSelect";
import { ScheduleTypeCombo } from "./ScheduleType";
export const ScheduleListFilters = z.object({
page: z.coerce.number().default(1),
@@ -28,6 +29,7 @@ export const ScheduleListFilters = z.object({
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
type: z.union([z.literal("declarative"), z.literal("imperative")]).optional(),
search: z.string().optional(),
});
@@ -48,7 +50,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environments, tasks, page, search } = ScheduleListFilters.parse(
const { environments, tasks, page, search, type } = ScheduleListFilters.parse(
Object.fromEntries(searchParams.entries())
);
@@ -73,6 +75,10 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
handleFilterChange("environments", value === "ALL" ? undefined : value);
}, []);
const handleTypeChange = useCallback((value: string | typeof All) => {
handleFilterChange("type", value === "ALL" ? undefined : value);
}, []);
const handleSearchChange = useThrottle((value: string) => {
handleFilterChange("search", value.length === 0 ? undefined : value);
}, 300);
@@ -97,6 +103,30 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
<SelectGroup>
<Select name="type" value={type ?? "ALL"} onValueChange={handleTypeChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder={"Select type"} className="ml-2 whitespace-nowrap p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All types
</Paragraph>
</SelectItem>
<SelectItem value={"declarative"}>
<ScheduleTypeCombo type="DECLARATIVE" className="text-xs text-text-dimmed" />
</SelectItem>
<SelectItem value={"imperative"}>
<ScheduleTypeCombo type="IMPERATIVE" className="text-xs text-text-dimmed" />
</SelectItem>
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select
name="environment"
@@ -0,0 +1,29 @@
import { ArchiveBoxIcon, ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
import type { ScheduleType } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
export function ScheduleTypeCombo({ type, className }: { type: ScheduleType; className?: string }) {
return (
<div className={cn("flex items-center space-x-1", className)}>
<ScheduleTypeIcon type={type} />
<span>{scheduleTypeName(type)}</span>
</div>
);
}
export function ScheduleTypeIcon({ type, className }: { type: ScheduleType; className?: string }) {
switch (type) {
case "IMPERATIVE":
return <ArrowsRightLeftIcon className={cn("size-4", className)} />;
case "DECLARATIVE":
return <ArchiveBoxIcon className={cn("size-4", className)} />;
}
}
export function scheduleTypeName(type: ScheduleType) {
switch (type) {
case "IMPERATIVE":
return "Imperative";
case "DECLARATIVE":
return "Declarative";
}
}
@@ -230,8 +230,12 @@ export function TaskRunsTable({
formatDuration(new Date(run.createdAt), new Date(run.startedAt), {
style: "short",
})
) : (
) : run.isCancellable ? (
<LiveTimer startTime={new Date(run.createdAt)} />
) : (
formatDuration(new Date(run.createdAt), new Date(run.updatedAt), {
style: "short",
})
)}
</div>
</TableCell>
+1
View File
@@ -118,6 +118,7 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
id: true,
slug: true,
title: true,
v2Enabled: true,
v3Enabled: true,
members: {
select: {
@@ -25,7 +25,7 @@ export class ApiRunPresenter {
}: ApiRunOptions) {
const take = Math.min(maxTasks, 50);
return await prisma.jobRun.findUnique({
return await prisma.jobRun.findFirst({
where: {
id: runId,
},
@@ -88,6 +88,7 @@ export class DeploymentListPresenter {
version: string;
status: WorkerDeploymentStatus;
environmentId: string;
builtAt: Date | null;
deployedAt: Date | null;
tasksCount: BigInt | null;
userId: string | null;
@@ -107,6 +108,7 @@ export class DeploymentListPresenter {
u."name" AS "userName",
u."displayName" AS "userDisplayName",
u."avatarUrl" AS "userAvatarUrl",
wd."builtAt",
wd."deployedAt"
FROM
${sqlDatabaseSchema}."WorkerDeployment" as wd
@@ -121,7 +123,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
deployments: deployments.map((deployment) => {
deployments: deployments.map((deployment, index) => {
const environment = project.environments.find((env) => env.id === deployment.environmentId);
if (!environment) {
throw new Error(`Environment not found for deployment ${deployment.id}`);
@@ -136,11 +138,14 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
shortCode: deployment.shortCode,
version: deployment.version,
status: deployment.status,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
tasksCount: deployment.tasksCount ? Number(deployment.tasksCount) : null,
label: label?.label,
isBuilt: !!deployment.builtAt,
isCurrent: label?.label === "current",
isDeployed: deployment.status === "DEPLOYED",
isLatest: page === 1 && index === 0,
environment: {
id: environment.id,
type: environment.type,
@@ -90,6 +90,7 @@ export class DeploymentPresenter {
},
},
status: true,
builtAt: true,
deployedAt: true,
createdAt: true,
promotions: {
@@ -134,6 +135,7 @@ export class DeploymentPresenter {
version: deployment.version,
status: deployment.status,
createdAt: deployment.createdAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
@@ -152,6 +154,7 @@ export class DeploymentPresenter {
projectId: deployment.projectId,
organizationId: project.organizationId,
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
isBuilt: !!deployment.builtAt,
},
};
}
@@ -88,6 +88,7 @@ export class EditSchedulePresenter {
const schedule = await this.#prismaClient.taskSchedule.findFirst({
select: {
id: true,
type: true,
friendlyId: true,
generatorExpression: true,
externalId: true,
@@ -78,26 +78,11 @@ export class EnvironmentVariablesPresenter {
where: {
project: {
slug: projectSlug,
},
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
}
},
});
const sortedEnvironments = sortEnvironments(environments).filter(
(e) => e.orgMember?.userId === userId || e.orgMember === null
);
const sortedEnvironments = sortEnvironments(environments);
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
const variables = await repository.getProject(project.id);
@@ -119,7 +104,9 @@ export class EnvironmentVariablesPresenter {
}, {} as Record<string, { value: string | undefined; environment: { type: string; id: string } }>),
};
}),
environments: sortedEnvironments.map((environment) => ({
environments: sortedEnvironments.filter(
(e) => e.orgMember?.userId === userId || e.orgMember === null
).map((environment) => ({
id: environment.id,
type: environment.type,
})),
@@ -69,8 +69,6 @@ async function getSlackChannelsForToken(integration: AuthenticatableIntegration)
return (channels ?? [])
.filter((channel) => !channel.is_archived)
.filter((channel) => channel.is_channel)
.filter((channel) => !channel.is_ext_shared)
.filter((channel) => channel.unlinked === 0)
.filter((channel) => channel.num_members)
.sort((a, b) => a.name!.localeCompare(b.name!));
}
@@ -1,4 +1,4 @@
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
import { Prisma, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import parse from "parse-duration";
import { Direction } from "~/components/runs/RunStatuses";
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
@@ -94,12 +94,16 @@ export class RunListPresenter extends BasePresenter {
});
//get all possible tasks
const possibleTasksAsync = this._replica.backgroundWorkerTask.findMany({
distinct: ["slug"],
where: {
projectId: project.id,
},
});
const possibleTasksAsync = this._replica.$queryRaw<
{
slug: string;
triggerSource: TaskTriggerSource
}[]
>`
SELECT DISTINCT(slug), "triggerSource"
FROM ${sqlDatabaseSchema}."BackgroundWorkerTask"
WHERE "projectId" = ${project.id}
ORDER BY slug ASC;`;
//get possible bulk actions
const bulkActionsAsync = this._replica.bulkActionGroup.findMany({
@@ -1,4 +1,4 @@
import { Prisma, RuntimeEnvironmentType } from "@trigger.dev/database";
import { Prisma, RuntimeEnvironmentType, ScheduleType } from "@trigger.dev/database";
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -16,6 +16,7 @@ const DEFAULT_PAGE_SIZE = 20;
export type ScheduleListItem = {
id: string;
type: ScheduleType;
friendlyId: string;
taskIdentifier: string;
deduplicationKey: string | null;
@@ -44,10 +45,17 @@ export class ScheduleListPresenter extends BasePresenter {
environments,
search,
page,
type,
pageSize = DEFAULT_PAGE_SIZE,
}: ScheduleListOptions) {
const hasFilters =
tasks !== undefined || environments !== undefined || (search !== undefined && search !== "");
type !== undefined ||
tasks !== undefined ||
environments !== undefined ||
(search !== undefined && search !== "");
const filterType =
type === "declarative" ? "DECLARATIVE" : type === "imperative" ? "IMPERATIVE" : undefined;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
@@ -105,6 +113,7 @@ export class ScheduleListPresenter extends BasePresenter {
environmentId: environments ? { in: environments } : undefined,
},
},
type: filterType,
AND: search
? {
OR: [
@@ -141,6 +150,7 @@ export class ScheduleListPresenter extends BasePresenter {
const rawSchedules = await this._replica.taskSchedule.findMany({
select: {
id: true,
type: true,
friendlyId: true,
taskIdentifier: true,
deduplicationKey: true,
@@ -166,6 +176,7 @@ export class ScheduleListPresenter extends BasePresenter {
},
}
: undefined,
type: filterType,
AND: search
? {
OR: [
@@ -215,11 +226,12 @@ export class ScheduleListPresenter extends BasePresenter {
ON t."scheduleId" = r."scheduleId" AND t."createdAt" = r."LatestRun";`
: [];
const schedules = rawSchedules.map((schedule) => {
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
const latestRun = latestRuns.find((r) => r.scheduleId === schedule.id);
return {
id: schedule.id,
type: schedule.type,
friendlyId: schedule.friendlyId,
taskIdentifier: schedule.taskIdentifier,
deduplicationKey: schedule.deduplicationKey,
@@ -78,8 +78,10 @@ export class UsagePresenter extends BasePresenter {
${sqlDatabaseSchema}."TaskRun" tr
JOIN ${sqlDatabaseSchema}."Project" pr ON pr.id = tr."projectId"
JOIN ${sqlDatabaseSchema}."Organization" org ON org.id = pr."organizationId"
JOIN ${sqlDatabaseSchema}."RuntimeEnvironment" env ON env."projectId" = pr.id
WHERE
tr."createdAt" > ${startOfMonth}
env.type <> 'DEVELOPMENT'
AND tr."createdAt" > ${startOfMonth}
AND tr."createdAt" < ${endOfMonth}
AND org.id = ${organizationId}
GROUP BY
@@ -21,6 +21,7 @@ export class ViewSchedulePresenter {
const schedule = await this.#prismaClient.taskSchedule.findFirst({
select: {
id: true,
type: true,
friendlyId: true,
generatorExpression: true,
generatorDescription: true,
@@ -99,6 +100,7 @@ export class ViewSchedulePresenter {
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
const response: ScheduleObject = {
id: result.schedule.friendlyId,
type: result.schedule.type,
task: result.schedule.taskIdentifier,
active: result.schedule.active,
nextRun: result.schedule.nextRuns[0],
@@ -12,7 +12,7 @@ const ParamsSchema = z.object({
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
const org = await prisma.organization.findFirst({
select: {
slug: true,
_count: {
@@ -11,7 +11,7 @@ const ParamsSchema = z.object({
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
const org = await prisma.organization.findFirst({
select: {
slug: true,
_count: {
@@ -12,7 +12,7 @@ const ParamsSchema = z.object({
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
const org = await prisma.organization.findFirst({
select: {
slug: true,
_count: {
@@ -132,7 +132,11 @@ export default function Page() {
</Property>
<Property label="Version">{deployment.version}</Property>
<Property label="Status">
<DeploymentStatus status={deployment.status} className="text-sm" />
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
className="text-sm"
/>
</Property>
<Property label="Tasks">{deployment.tasks ? deployment.tasks.length : ""}</Property>
<Property label="SDK Version">
@@ -143,6 +147,17 @@ export default function Page() {
<DateTimeAccurate date={deployment.createdAt} /> UTC
</Paragraph>
</Property>
<Property label="Built at">
<Paragraph variant="small/bright">
{deployment.builtAt ? (
<>
<DateTimeAccurate date={deployment.builtAt} /> UTC
</>
) : (
""
)}
</Paragraph>
</Property>
<Property label="Deployed at">
<Paragraph variant="small/bright">
{deployment.deployedAt ? (
@@ -1,5 +1,6 @@
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BookOpenIcon,
CommandLineIcon,
ServerIcon,
@@ -7,6 +8,7 @@ import {
} from "@heroicons/react/20/solid";
import { Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react/jsx-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
@@ -38,6 +40,7 @@ import {
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -54,6 +57,7 @@ import {
v3EnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
const SearchParams = z.object({
page: z.coerce.number().optional(),
@@ -124,7 +128,12 @@ export default function Page() {
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(organization, project, deployment);
const path = v3DeploymentPath(
organization,
project,
deployment,
currentPage
);
return (
<TableRow key={deployment.id} className="group">
<TableCell to={path}>
@@ -143,7 +152,10 @@ export default function Page() {
</TableCell>
<TableCell to={path}>{deployment.version}</TableCell>
<TableCell to={path}>
<DeploymentStatus status={deployment.status} />
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
/>
</TableCell>
<TableCell to={path}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
@@ -265,14 +277,19 @@ function DeploymentActionsCell({
const location = useLocation();
const project = useProject();
if (deployment.isCurrent || !deployment.isDeployed) return <TableCell to={path}>{""}</TableCell>;
const canRollback = !deployment.isCurrent && deployment.isDeployed;
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
if (!canRollback && !canRetryIndexing) {
return <TableCell to={path}>{""}</TableCell>;
}
return (
<TableCellMenu isSticky>
{!deployment.isCurrent && deployment.isDeployed && (
{canRollback && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
<Button variant="small-menu-item" LeadingIcon={ArrowUturnLeftIcon}>
Rollback
</Button>
</DialogTrigger>
@@ -283,6 +300,20 @@ function DeploymentActionsCell({
/>
</Dialog>
)}
{canRetryIndexing && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
Retry indexing
</Button>
</DialogTrigger>
<RetryDeploymentIndexingDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</TableCellMenu>
);
}
@@ -301,6 +301,8 @@ function EditEnvironmentVariablePanel({
const lastSubmission = useActionData();
const navigation = useNavigation();
const hiddenValues = Object.values(variable.values).filter((value) => !environments.map(e => e.id).includes(value.environment.id));
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
@@ -336,6 +338,12 @@ function EditEnvironmentVariablePanel({
<input type="hidden" name="action" value="edit" />
<input type="hidden" name="id" value={variable.id} />
<input type="hidden" name="key" value={variable.key} />
{hiddenValues.map((value, index) => (
<Fragment key={index}>
<input type="hidden" name={`values[${index}].environmentId`} value={value.environment.id} />
<input type="hidden" name={`values[${index}].value`} value={value.value} />
</Fragment>
))}
<FormError id={id.errorId}>{id.error}</FormError>
<Fieldset>
<InputGroup fullWidth className="mb-5 mt-2">
@@ -351,6 +359,7 @@ function EditEnvironmentVariablePanel({
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
{environments.map((environment, index) => {
const value = variable.values[environment.id]?.value;
index += hiddenValues.length;
return (
<Fragment key={environment.id}>
<input
@@ -1,15 +1,21 @@
import { parse } from "@conform-to/zod";
import { BoltIcon, BoltSlashIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
import {
BoltIcon,
BoltSlashIcon,
BookOpenIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { DialogDescription } from "@radix-ui/react-dialog";
import { Form, useLocation } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { token } from "morgan";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
@@ -19,6 +25,7 @@ import {
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import {
@@ -31,11 +38,11 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
@@ -197,8 +204,15 @@ export default function Page() {
const isUtc = schedule.timezone === "UTC";
const isImperative = schedule.type === "IMPERATIVE";
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div
className={cn(
"grid h-full max-h-full overflow-hidden bg-background-bright",
isImperative ? "grid-rows-[2.5rem_1fr_3.25rem]" : "grid-rows-[2.5rem_1fr]"
)}
>
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
<LinkButton
@@ -214,6 +228,9 @@ export default function Page() {
<PropertyTable>
<Property label="Schedule ID">{schedule.friendlyId}</Property>
<Property label="Task ID">{schedule.taskIdentifier}</Property>
<Property label="Type">
<ScheduleTypeCombo type={schedule.type} className="text-sm" />
</Property>
<Property label="CRON (UTC)" labelClassName="self-start">
<div className="space-y-2">
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
@@ -224,15 +241,19 @@ export default function Page() {
<Property label="Environments">
<EnvironmentLabels size="small" environments={schedule.environments} />
</Property>
<Property label="External ID">
{schedule.externalId ? schedule.externalId : ""}
</Property>
<Property label="Deduplication key">
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</Property>
<Property label="Status">
<EnabledStatus enabled={schedule.active} />
</Property>
{isImperative && (
<>
<Property label="External ID">
{schedule.externalId ? schedule.externalId : ""}
</Property>
<Property label="Deduplication key">
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</Property>
<Property label="Status">
<EnabledStatus enabled={schedule.active} />
</Property>
</>
)}
</PropertyTable>
<div className="flex flex-col gap-1">
<Header3>Last 5 runs</Header3>
@@ -288,68 +309,84 @@ export default function Page() {
</TableBody>
</Table>
</div>
{!isImperative && (
<InfoPanel
title="Editing declarative schedules"
icon={BookOpenIcon}
iconClassName="text-indigo-500"
variant="info"
buttonLabel="Docs"
to="https://trigger.dev/docs/v3/tasks-scheduled"
panelClassName="max-w-full"
>
You can only edit a declarative schedule by updating your schedules.task and then
running the CLI dev and deploy commands.
</InfoPanel>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex items-center gap-4">
<Form method="post">
<Button
type="submit"
variant="minimal/medium"
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
name="action"
value={schedule.active ? "disable" : "enable"}
>
{schedule.active ? "Disable" : "Enable"}
</Button>
</Form>
<Dialog>
<DialogTrigger asChild>
{isImperative && (
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex items-center gap-4">
<Form method="post">
<Button
type="submit"
variant="minimal/medium"
LeadingIcon={TrashIcon}
leadingIconClassName="text-error"
className="text-error"
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
name="action"
value="delete"
value={schedule.active ? "disable" : "enable"}
>
Delete
{schedule.active ? "Disable" : "Enable"}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Delete schedule</DialogHeader>
<DialogDescription>
Are you sure you want to delete this schedule? This can't be reversed.
</DialogDescription>
<DialogFooter>
<Form method="post">
<Button
type="submit"
variant="danger/small"
LeadingIcon={TrashIcon}
name="action"
value="delete"
>
Delete
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
</Form>
<Dialog>
<DialogTrigger asChild>
<Button
type="submit"
variant="minimal/medium"
LeadingIcon={TrashIcon}
leadingIconClassName="text-error"
className="text-error"
name="action"
value="delete"
>
Delete
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Delete schedule</DialogHeader>
<DialogDescription>
Are you sure you want to delete this schedule? This can't be reversed.
</DialogDescription>
<DialogFooter>
<Form method="post">
<Button
type="submit"
variant="danger/small"
LeadingIcon={TrashIcon}
name="action"
value="delete"
>
Delete
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center gap-4">
<LinkButton
variant="tertiary/medium"
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
LeadingIcon={PencilSquareIcon}
>
Edit schedule
</LinkButton>
</div>
</div>
<div className="flex items-center gap-4">
<LinkButton
variant="tertiary/medium"
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
LeadingIcon={PencilSquareIcon}
>
Edit schedule
</LinkButton>
</div>
</div>
)}
</div>
);
}
@@ -1,8 +1,8 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, v3ScheduleParams } from "~/utils/pathBuilder";
import { ProjectParamSchema, v3ScheduleParams, v3SchedulesPath } from "~/utils/pathBuilder";
import { humanToCronSupported } from "~/v3/humanToCron.server";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route";
@@ -17,6 +17,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
friendlyId: scheduleParam,
});
if (result.schedule?.type === "DECLARATIVE") {
throw redirect(v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }));
}
return typedjson({ ...result, showGenerateField: humanToCronSupported });
};
@@ -10,6 +10,11 @@ import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
ScheduleTypeCombo,
ScheduleTypeIcon,
scheduleTypeName,
} from "~/components/runs/v3/ScheduleType";
import {
Dialog,
DialogContent,
@@ -334,6 +339,51 @@ function SchedulesTable({
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex max-w-xs flex-col gap-4 p-1">
<div>
<div className="mb-0.5 flex items-center gap-1.5 text-sm">
<div className={"flex items-center space-x-1"}>
<ScheduleTypeIcon type={"DECLARATIVE"} className="text-sky-500" />
<span className="font-medium">{scheduleTypeName("DECLARATIVE")}</span>
</div>
</div>
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
Declarative schedules are defined in a{" "}
<InlineCode variant="extra-small">schedules.task</InlineCode> with the{" "}
<InlineCode variant="extra-small">cron</InlineCode>
property. They sync when you update your{" "}
<InlineCode variant="extra-small">schedules.task</InlineCode> definition and run
the CLI dev or deploy commands.
</Paragraph>
</div>
<div>
<div className="mb-0.5 flex items-center gap-1.5 text-sm">
<div className={"flex items-center space-x-1"}>
<ScheduleTypeIcon type={"IMPERATIVE"} className="text-teal-500" />
<span className="font-medium">{scheduleTypeName("IMPERATIVE")}</span>
</div>
</div>
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
Imperative schedules are defined here in the dashboard or by using the SDK
functions to create or delete them. They can be created, updated, disabled, and
deleted from the dashboard or using the SDK.
</Paragraph>
</div>
<div>
<LinkButton
variant="tertiary/medium"
to="https://trigger.dev/docs/v3/tasks-scheduled"
>
View the docs
</LinkButton>
</div>
</div>
}
>
Type
</TableHeaderCell>
<TableHeaderCell>External ID</TableHeaderCell>
<TableHeaderCell>CRON</TableHeaderCell>
<TableHeaderCell hiddenLabel>CRON description</TableHeaderCell>
@@ -362,7 +412,14 @@ function SchedulesTable({
{schedule.taskIdentifier}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.externalId ? schedule.externalId : ""}
<ScheduleTypeCombo type={schedule.type} />
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.type === "IMPERATIVE"
? schedule.externalId
? schedule.externalId
: ""
: "N/A"}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.cron}
@@ -384,13 +441,21 @@ function SchedulesTable({
)}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
{schedule.type === "IMPERATIVE"
? schedule.userProvidedDeduplicationKey
? schedule.deduplicationKey
: ""
: "N/A"}
</TableCell>
<TableCell to={path} className={cellClass}>
<EnvironmentLabels environments={schedule.environments} size="small" />
</TableCell>
<TableCell to={path}>
<EnabledStatus enabled={schedule.active} />
{schedule.type === "IMPERATIVE" ? (
<EnabledStatus enabled={schedule.active} />
) : (
"N/A"
)}
</TableCell>
</TableRow>
);
@@ -35,6 +35,8 @@ import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { InformationCircleIcon } from "@heroicons/react/20/solid";
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
@@ -213,52 +215,60 @@ export default function Page() {
>
{(tasks) => {
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell alignment="right">Runs</TableHeaderCell>
<TableHeaderCell alignment="right">Average duration</TableHeaderCell>
<TableHeaderCell alignment="right">Average cost</TableHeaderCell>
<TableHeaderCell alignment="right">Total duration</TableHeaderCell>
<TableHeaderCell alignment="right">Total cost</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{tasks.length === 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6}>
<Paragraph variant="small">No runs.</Paragraph>
</TableCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell alignment="right">Runs</TableHeaderCell>
<TableHeaderCell alignment="right">Average duration</TableHeaderCell>
<TableHeaderCell alignment="right">Average cost</TableHeaderCell>
<TableHeaderCell alignment="right">Total duration</TableHeaderCell>
<TableHeaderCell alignment="right">Total cost</TableHeaderCell>
</TableRow>
) : (
tasks.map((task) => (
<TableRow key={task.taskIdentifier}>
<TableCell>{task.taskIdentifier}</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatNumber(task.runCount)}
</TableCell>
<TableCell alignment="right">
{formatDurationMilliseconds(task.averageDuration, {
style: "short",
})}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatCurrencyAccurate(task.averageCost)}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatDurationMilliseconds(task.totalDuration, {
style: "short",
})}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatCurrencyAccurate(task.totalCost)}
</TableHeader>
<TableBody>
{tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<div className="flex items-center justify-center">
<Paragraph variant="small">No runs for this period</Paragraph>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
tasks.map((task) => (
<TableRow key={task.taskIdentifier}>
<TableCell>{task.taskIdentifier}</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatNumber(task.runCount)}
</TableCell>
<TableCell alignment="right">
{formatDurationMilliseconds(task.averageDuration, {
style: "short",
})}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatCurrencyAccurate(task.averageCost)}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatDurationMilliseconds(task.totalDuration, {
style: "short",
})}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatCurrencyAccurate(task.totalCost)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-[22rem] mt-3">
Dev environment runs are excluded from the usage data above, since they do
not have an associated compute cost.
</InfoPanel>
</>
);
}}
</Await>
@@ -34,7 +34,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const organization = await prisma.organization.findUnique({
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
select: {
id: true,
@@ -51,6 +51,7 @@ export default function ChoosePlanPage() {
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan
showGithubVerificationBadge
/>
</MainCenteredContainer>
);
@@ -0,0 +1,69 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { createEnvironment } from "~/models/organization.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { marqs } from "~/v3/marqs/index.server";
const ParamsSchema = z.object({
organizationId: z.string(),
});
/**
* It will create a staging environment for all the projects where there isn't one already
*/
export async function action({ request, params }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const { organizationId } = ParamsSchema.parse(params);
const organization = await prisma.organization.findUnique({
where: {
id: organizationId,
},
include: {
projects: {
include: { environments: true },
},
},
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
let created = 0;
for (const project of organization.projects) {
const stagingEnvironment = project.environments.find((env) => env.type === "STAGING");
if (!stagingEnvironment) {
const staging = await createEnvironment(organization, project, "STAGING");
await marqs?.updateEnvConcurrencyLimits({ ...staging, organization, project });
created++;
} else {
await marqs?.updateEnvConcurrencyLimits({ ...stagingEnvironment, organization, project });
}
}
return json({ success: true, created, total: organization.projects.length });
}
+5 -47
View File
@@ -1,6 +1,6 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -16,8 +16,7 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { adminGetOrganizations, setV3Enabled } from "~/models/admin.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { adminGetOrganizations } from "~/models/admin.server";
import { requireUserId } from "~/services/session.server";
import { createSearchParams } from "~/utils/searchParams";
@@ -40,23 +39,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson(result);
};
const FormSchema = z.object({ id: z.string(), v3: z.enum(["enable", "disable"]) });
export async function action({ request }: ActionFunctionArgs) {
if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
const userId = await requireUserId(request);
const payload = Object.fromEntries(await request.formData());
const { id, v3 } = FormSchema.parse(payload);
const result = await setV3Enabled(userId, id, v3 === "enable");
return redirectWithSuccessMessage("/admin/orgs", request, `v3 ${v3}d for org ${id}`);
}
export default function AdminDashboardRoute() {
const { organizations, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
@@ -87,6 +69,7 @@ export default function AdminDashboardRoute() {
<TableHeaderCell>Slug</TableHeaderCell>
<TableHeaderCell>Members</TableHeaderCell>
<TableHeaderCell>id</TableHeaderCell>
<TableHeaderCell>v2?</TableHeaderCell>
<TableHeaderCell>v3?</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
@@ -114,34 +97,9 @@ export default function AdminDashboardRoute() {
))}
</TableCell>
<TableCell>{org.id}</TableCell>
<TableCell>{org.v2Enabled ? "✅" : ""}</TableCell>
<TableCell>{org.v3Enabled ? "✅" : ""}</TableCell>
<TableCell isSticky={true}>
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={org.id} />
{org.v3Enabled ? (
<Button
type="submit"
name="v3"
value="disable"
className="mr-2"
variant="tertiary/small"
>
Disable v3
</Button>
) : (
<Button
type="submit"
name="v3"
value="enable"
className="mr-2"
variant="tertiary/small"
>
Enable v3
</Button>
)}
</Form>
</TableCell>
<TableCell isSticky={true}> </TableCell>
</TableRow>
);
})
@@ -3,7 +3,11 @@ import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CreateBackgroundWorkerService } from "~/v3/services/createBackgroundWorker.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import {
CreateBackgroundWorkerService,
CreateDeclarativeScheduleError,
} from "~/v3/services/createBackgroundWorker.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -42,14 +46,26 @@ export async function action({ request, params }: ActionFunctionArgs) {
const service = new CreateBackgroundWorkerService();
const backgroundWorker = await service.call(projectRef, authenticatedEnv, body.data);
try {
const backgroundWorker = await service.call(projectRef, authenticatedEnv, body.data);
return json(
{
id: backgroundWorker.friendlyId,
version: backgroundWorker.version,
contentHash: backgroundWorker.contentHash,
},
{ status: 200 }
);
return json(
{
id: backgroundWorker.friendlyId,
version: backgroundWorker.version,
contentHash: backgroundWorker.contentHash,
},
{ status: 200 }
);
} catch (e) {
logger.error("Failed to create background worker", { error: e });
if (e instanceof ServiceValidationError) {
return json({ error: e.message }, { status: 400 });
} else if (e instanceof CreateDeclarativeScheduleError) {
return json({ error: e.message }, { status: 400 });
}
return json({ error: "Failed to create background worker" }, { status: 500 });
}
}
@@ -88,6 +88,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const responseObject: ScheduleObject = {
id: schedule.id,
type: schedule.type,
task: schedule.task,
active: schedule.active,
generator: {
@@ -50,6 +50,7 @@ export async function action({ request }: ActionFunctionArgs) {
const responseObject: ScheduleObject = {
id: schedule.id,
type: schedule.type,
task: schedule.task,
active: schedule.active,
generator: {
@@ -107,6 +108,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
data: result.schedules.map((schedule) => ({
id: schedule.friendlyId,
type: schedule.type,
task: schedule.taskIdentifier,
generator: {
type: "CRON",
@@ -0,0 +1,108 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
import { RetryDeploymentIndexingService } from "~/v3/services/retryDeploymentIndexing.server";
export const rollbackSchema = z.object({
redirectUrl: z.string(),
});
const ParamSchema = z.object({
projectId: z.string(),
deploymentShortCode: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectId, deploymentShortCode } = ParamSchema.parse(params);
console.log("projectId", projectId);
console.log("deploymentShortCode", deploymentShortCode);
const formData = await request.formData();
const submission = parse(formData, { schema: rollbackSchema });
if (!submission.value) {
return json(submission);
}
try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
}
const deployment = await prisma.workerDeployment.findUnique({
where: {
projectId_shortCode: {
projectId: project.id,
shortCode: deploymentShortCode,
},
},
});
if (!deployment) {
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment not found"
);
}
if (!deploymentIndexingIsRetryable(deployment)) {
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment indexing not in retryable state"
);
}
const startIndexing = new RetryDeploymentIndexingService();
await startIndexing.call(deployment.id);
return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
"Retrying deployment indexing"
);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to retry deployment indexing", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
projectId,
deploymentShortCode,
});
submission.error = { runParam: error.message };
return json(submission);
} else {
logger.error("Failed to retry deployment indexing", {
error,
projectId,
deploymentShortCode,
});
submission.error = { runParam: JSON.stringify(error) };
return json(submission);
}
}
};
@@ -133,6 +133,7 @@ type PricingPlansProps = {
subscription?: SubscriptionResult;
organizationSlug: string;
hasPromotedPlan: boolean;
showGithubVerificationBadge?: boolean;
};
export function PricingPlans({
@@ -140,6 +141,7 @@ export function PricingPlans({
subscription,
organizationSlug,
hasPromotedPlan,
showGithubVerificationBadge,
}: PricingPlansProps) {
return (
<div className="flex w-full flex-col">
@@ -148,6 +150,7 @@ export function PricingPlans({
plan={plans.free}
subscription={subscription}
organizationSlug={organizationSlug}
showGithubVerificationBadge={showGithubVerificationBadge}
/>
<TierHobby
plan={plans.hobby}
@@ -168,10 +171,12 @@ export function TierFree({
plan,
subscription,
organizationSlug,
showGithubVerificationBadge,
}: {
plan: FreePlanDefinition;
subscription?: SubscriptionResult;
organizationSlug: string;
showGithubVerificationBadge?: boolean;
}) {
const location = useLocation();
const navigation = useNavigation();
@@ -184,7 +189,7 @@ export function TierFree({
<TierContainer>
<div className="relative">
<PricingHeader title={plan.title} cost={0} />
{status === "approved" && (
{showGithubVerificationBadge && status === "approved" && (
<SimpleTooltip
buttonClassName="absolute right-1 top-1"
button={
@@ -37,7 +37,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
}
function projectForUpdates(id: string) {
return prisma.project.findUnique({
return prisma.project.findFirst({
where: {
id,
},
@@ -153,6 +153,10 @@ export async function authenticatedEnvironmentForAuthentication(
projectRef: string,
slug: string
): Promise<AuthenticatedEnvironment> {
if (slug === "staging") {
slug = "stg";
}
switch (auth.type) {
case "apiKey": {
if (auth.result.environment.project.externalRef !== projectRef) {
+2 -2
View File
@@ -590,11 +590,11 @@ function getWorkerQueue() {
},
"v3.triggerScheduledTask": {
priority: 0,
maxAttempts: 3,
maxAttempts: 3, // total delay of 30 seconds
handler: async (payload, job) => {
const service = new TriggerScheduledTaskService();
return await service.call(payload.instanceId);
return await service.call(payload.instanceId, job.attempts === job.max_attempts);
},
},
"v3.performTaskAttemptAlerts": {
+4 -2
View File
@@ -442,9 +442,11 @@ export function v3DeploymentsPath(organization: OrgForPath, project: ProjectForP
export function v3DeploymentPath(
organization: OrgForPath,
project: ProjectForPath,
deployment: DeploymentForPath
deployment: DeploymentForPath,
currentPage: number
) {
return `${v3DeploymentsPath(organization, project)}/${deployment.shortCode}`;
const query = currentPage ? `?page=${currentPage}` : "";
return `${v3DeploymentsPath(organization, project)}/${deployment.shortCode}${query}`;
}
export function v3BillingPath(organization: OrgForPath) {
@@ -2,10 +2,12 @@ import { clientWebsocketMessages, serverWebsocketMessages } from "@trigger.dev/c
import { ZodMessageHandler, ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler";
import { Evt } from "evt";
import { randomUUID } from "node:crypto";
import type { CloseEvent, ErrorEvent, MessageEvent, WebSocket } from "ws";
import type { CloseEvent, ErrorEvent, MessageEvent } from "ws";
import { WebSocket } from "ws";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DevQueueConsumer } from "./marqs/devQueueConsumer.server";
import { HeartbeatService } from "./services/heartbeatService.server";
export class AuthenticatedSocketConnection {
public id: string;
@@ -14,6 +16,7 @@ export class AuthenticatedSocketConnection {
private _sender: ZodMessageSender<typeof serverWebsocketMessages>;
private _consumer: DevQueueConsumer;
private _messageHandler: ZodMessageHandler<typeof clientWebsocketMessages>;
private _pingService: HeartbeatService;
constructor(
public ws: WebSocket,
@@ -50,8 +53,42 @@ export class AuthenticatedSocketConnection {
ws.addEventListener("close", this.#handleClose.bind(this));
ws.addEventListener("error", this.#handleError.bind(this));
ws.on("ping", (data) => {
logger.debug("[AuthenticatedSocketConnection] Received ping", {
id: this.id,
envId: this.authenticatedEnv.id,
data,
});
});
ws.on("pong", (data) => {
// logger.debug("[AuthenticatedSocketConnection] Received pong", {
// id: this.id,
// envId: this.authenticatedEnv.id,
// data,
// });
});
this._pingService = new HeartbeatService({
heartbeat: async () => {
if (ws.readyState !== WebSocket.OPEN) {
logger.debug("[AuthenticatedSocketConnection] Websocket not open, skipping ping");
return;
}
// logger.debug("[AuthenticatedSocketConnection] Sending ping", {
// id: this.id,
// envId: this.authenticatedEnv.id,
// });
ws.ping();
},
});
this._pingService.start();
this._messageHandler = new ZodMessageHandler({
schema: clientWebsocketMessages,
logger,
messages: {
READY_FOR_TASKS: async (payload) => {
await this._consumer.registerBackgroundWorker(
@@ -99,14 +136,29 @@ export class AuthenticatedSocketConnection {
}
async #handleMessage(ev: MessageEvent) {
const data = JSON.parse(ev.data.toString());
try {
const data = JSON.parse(ev.data.toString());
await this._messageHandler.handleMessage(data);
await this._messageHandler.handleMessage(data);
} catch (error) {
logger.error("[AuthenticatedSocketConnection] Failed to handle message", {
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
}
: error,
message: ev.data.toString(),
});
}
}
async #handleClose(ev: CloseEvent) {
logger.debug("[AuthenticatedSocketConnection] Websocket closed", { ev });
this._pingService.stop();
await this._consumer.stop();
const result = this.onClose.post(ev);
+11
View File
@@ -0,0 +1,11 @@
import { WorkerDeployment } from "@trigger.dev/database";
export const deploymentIndexingIsRetryable = ({
builtAt,
status,
}: {
builtAt: Date | null;
status: WorkerDeployment["status"];
}) => {
return builtAt && (status === "FAILED" || status === "TIMED_OUT");
};
@@ -91,17 +91,13 @@ export class EnvironmentVariablesRepository implements Repository {
return { success: false as const, error: `Environment not found` };
}
// Check to see if any of the variables are `TRIGGER_SECRET_KEY` or `TRIGGER_API_URL`
const triggerKeys = options.variables.map((v) => v.key);
if (triggerKeys.includes("TRIGGER_SECRET_KEY") || triggerKeys.includes("TRIGGER_API_URL")) {
return {
success: false as const,
error: `You cannot set the variables TRIGGER_SECRET_KEY or TRIGGER_API_URL as they will be set automatically`,
};
}
// Remove `TRIGGER_SECRET_KEY` or `TRIGGER_API_URL`
let values = options.variables.filter(
(v) => v.key !== "TRIGGER_SECRET_KEY" && v.key !== "TRIGGER_API_URL"
);
//get rid of empty variables
const values = options.variables.filter((v) => v.key.trim() !== "" && v.value.trim() !== "");
values = values.filter((v) => v.key.trim() !== "" && v.value.trim() !== "");
if (values.length === 0) {
return { success: false as const, error: `You must set at least one value` };
}
+6 -3
View File
@@ -69,7 +69,6 @@ export class MarQS {
private redis: Redis;
public keys: MarQSKeyProducer;
private queuePriorityStrategy: MarQSQueuePriorityStrategy;
#requeueingWorkers: Array<AsyncWorker> = [];
#rebalanceWorkers: Array<AsyncWorker> = [];
constructor(private readonly options: MarQSOptions) {
@@ -846,10 +845,14 @@ export class MarQS {
});
stream.on("data", async (keys) => {
stream.pause();
const uniqueKeys = Array.from(new Set<string>(keys));
if (uniqueKeys.length === 0) {
return;
}
stream.pause();
logger.debug("Rebalancing parent queues", {
component: "marqs",
operation: "rebalanceParentQueues",
@@ -1080,7 +1080,7 @@ class SharedQueueTasks {
const variables = await this.#buildEnvironmentVariables(
attempt.runtimeEnvironment,
taskRun,
taskRun.id,
machinePreset
);
@@ -1136,16 +1136,18 @@ class SharedQueueTasks {
return;
}
const run = await prisma.taskRun.findUnique({
const run = await prisma.taskRun.findFirst({
where: {
id: runId,
runtimeEnvironmentId: environment.id,
},
include: {
lockedBy: true,
_count: {
select: {
id: true,
traceContext: true,
friendlyId: true,
isTest: true,
lockedBy: {
select: {
attempts: true,
machineConfig: true,
},
},
},
@@ -1156,9 +1158,20 @@ class SharedQueueTasks {
return;
}
const attemptCount = await prisma.taskRunAttempt.count({
where: {
taskRunId: run.id,
},
});
logger.debug("Getting lazy attempt payload for run", {
run,
attemptCount,
});
const machinePreset = machinePresetFromConfig(run.lockedBy?.machineConfig ?? {});
const variables = await this.#buildEnvironmentVariables(environment, run, machinePreset);
const variables = await this.#buildEnvironmentVariables(environment, run.id, machinePreset);
return {
traceContext: run.traceContext as Record<string, unknown>,
@@ -1169,7 +1182,7 @@ class SharedQueueTasks {
runId: run.friendlyId,
messageId: run.id,
isTest: run.isTest,
attemptCount: run._count.attempts,
attemptCount,
} satisfies TaskRunExecutionLazyAttemptPayload;
}
@@ -1203,13 +1216,13 @@ class SharedQueueTasks {
async #buildEnvironmentVariables(
environment: RuntimeEnvironment,
run: TaskRun,
runId: string,
machinePreset: MachinePreset
): Promise<Array<EnvironmentVariable>> {
const variables = await resolveVariablesForEnvironment(environment);
const jwt = await generateJWTTokenForEnvironment(environment, {
run_id: run.id,
run_id: runId,
machine_preset: machinePreset.name,
});
@@ -1217,7 +1230,7 @@ class SharedQueueTasks {
...variables,
...[
{ key: "TRIGGER_JWT", value: jwt },
{ key: "TRIGGER_RUN_ID", value: run.id },
{ key: "TRIGGER_RUN_ID", value: runId },
{
key: "TRIGGER_MACHINE_PRESET",
value: machinePreset.name,
@@ -0,0 +1,93 @@
import { ZodError } from "zod";
import { CronPattern } from "../schedules";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { getLimit } from "~/services/platform.v3.server";
import { getTimezones } from "~/utils/timezones.server";
import { env } from "~/env.server";
type Schedule = {
cron: string;
timezone?: string;
taskIdentifier: string;
friendlyId?: string;
};
export class CheckScheduleService extends BaseService {
public async call(projectId: string, schedule: Schedule) {
//validate the cron expression
try {
CronPattern.parse(schedule.cron);
} catch (e) {
if (e instanceof ZodError) {
throw new ServiceValidationError(`Invalid cron expression: ${e.issues[0].message}`);
}
throw new ServiceValidationError(
`Invalid cron expression: ${e instanceof Error ? e.message : JSON.stringify(e)}`
);
}
//chek it's a valid timezone
if (schedule.timezone) {
const possibleTimezones = getTimezones();
if (!possibleTimezones.includes(schedule.timezone)) {
throw new ServiceValidationError(
`Invalid IANA timezone: '${schedule.timezone}'. View the list of valid timezones at ${env.APP_ORIGIN}/timezones`
);
}
}
//check the task exists
const task = await this._prisma.backgroundWorkerTask.findFirst({
where: {
slug: schedule.taskIdentifier,
projectId: projectId,
},
orderBy: {
createdAt: "desc",
},
});
if (!task) {
throw new ServiceValidationError(
`Task with identifier ${schedule.taskIdentifier} not found in project.`
);
}
if (task.triggerSource !== "SCHEDULED") {
throw new ServiceValidationError(
`Task with identifier ${schedule.taskIdentifier} is not a scheduled task.`
);
}
//if creating a schedule, check they're under the limits
if (!schedule.friendlyId) {
//check they're within their limit
const project = await this._prisma.project.findFirst({
where: {
id: projectId,
},
select: {
organizationId: true,
},
});
if (!project) {
throw new ServiceValidationError("Project not found");
}
const limit = await getLimit(project.organizationId, "schedules", 500);
const schedulesCount = await this._prisma.taskSchedule.count({
where: {
projectId,
},
});
if (schedulesCount >= limit) {
throw new ServiceValidationError(
`You have created ${schedulesCount}/${limit} schedules so you'll need to increase your limits or delete some schedules.`
);
}
}
}
}
@@ -8,6 +8,9 @@ import { generateFriendlyId } from "../friendlyIdentifiers";
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
import { BaseService } from "./baseService.server";
import { projectPubSub } from "./projectPubSub.server";
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
import cronstrue from "cronstrue";
import { CheckScheduleService } from "./checkSchedule.server";
export class CreateBackgroundWorkerService extends BaseService {
public async call(
@@ -68,6 +71,12 @@ export class CreateBackgroundWorkerService extends BaseService {
});
await createBackgroundTasks(body.metadata.tasks, backgroundWorker, environment, this._prisma);
await syncDeclarativeSchedules(
body.metadata.tasks,
backgroundWorker,
environment,
this._prisma
);
try {
//send a notification that a new worker has been created
@@ -220,3 +229,155 @@ export async function createBackgroundTasks(
}
}
}
//CreateDeclarativeScheduleError with a message
export class CreateDeclarativeScheduleError extends Error {
constructor(message: string) {
super(message);
this.name = "CreateDeclarativeScheduleError";
}
}
export async function syncDeclarativeSchedules(
tasks: TaskResource[],
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction
) {
const tasksWithDeclarativeSchedules = tasks.filter((task) => task.schedule);
logger.info("Syncing declarative schedules", {
tasksWithDeclarativeSchedules,
environment,
});
const existingDeclarativeSchedules = await prisma.taskSchedule.findMany({
where: {
type: "DECLARATIVE",
projectId: environment.projectId,
},
include: {
instances: true,
},
});
const checkSchedule = new CheckScheduleService(prisma);
const registerNextService = new RegisterNextTaskScheduleInstanceService(prisma);
//start out by assuming they're all missing
const missingSchedules = new Set<string>(
existingDeclarativeSchedules.map((schedule) => schedule.id)
);
//create/update schedules (+ instances)
for (const task of tasksWithDeclarativeSchedules) {
if (task.schedule === undefined) continue;
const existingSchedule = existingDeclarativeSchedules.find(
(schedule) =>
schedule.taskIdentifier === task.id &&
schedule.instances.some((instance) => instance.environmentId === environment.id)
);
//this throws errors if the schedule is invalid
await checkSchedule.call(environment.projectId, {
cron: task.schedule.cron,
timezone: task.schedule.timezone,
taskIdentifier: task.id,
friendlyId: existingSchedule?.friendlyId,
});
if (existingSchedule) {
const schedule = await prisma.taskSchedule.update({
where: {
id: existingSchedule.id,
},
data: {
generatorExpression: task.schedule.cron,
generatorDescription: cronstrue.toString(task.schedule.cron),
timezone: task.schedule.timezone,
},
include: {
instances: true,
},
});
missingSchedules.delete(existingSchedule.id);
const instance = schedule.instances.at(0);
if (instance) {
await registerNextService.call(instance.id);
} else {
throw new CreateDeclarativeScheduleError(
`Missing instance for declarative schedule ${schedule.id}`
);
}
} else {
const newSchedule = await prisma.taskSchedule.create({
data: {
friendlyId: generateFriendlyId("sched"),
projectId: environment.projectId,
taskIdentifier: task.id,
generatorExpression: task.schedule.cron,
generatorDescription: cronstrue.toString(task.schedule.cron),
timezone: task.schedule.timezone,
type: "DECLARATIVE",
instances: {
create: [
{
environmentId: environment.id,
},
],
},
},
include: {
instances: true,
},
});
const instance = newSchedule.instances.at(0);
if (instance) {
await registerNextService.call(instance.id);
} else {
throw new CreateDeclarativeScheduleError(
`Missing instance for declarative schedule ${newSchedule.id}`
);
}
}
}
//Delete instances for this environment
//Delete schedules that have no instances left
const potentiallyDeletableSchedules = await prisma.taskSchedule.findMany({
where: {
id: {
in: Array.from(missingSchedules),
},
},
include: {
instances: true,
},
});
for (const schedule of potentiallyDeletableSchedules) {
const canDeleteSchedule =
schedule.instances.length === 0 ||
schedule.instances.every((instance) => instance.environmentId === environment.id);
if (canDeleteSchedule) {
//we can delete schedules with no instances other than ones for the current environment
await prisma.taskSchedule.delete({
where: {
id: schedule.id,
},
});
} else {
//otherwise we delete the instance (other environments remain untouched)
await prisma.taskScheduleInstance.deleteMany({
where: {
taskScheduleId: schedule.id,
environmentId: environment.id,
},
});
}
}
}
@@ -3,13 +3,14 @@ import type { BackgroundWorker } from "@trigger.dev/database";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { BaseService } from "./baseService.server";
import { createBackgroundTasks } from "./createBackgroundWorker.server";
import { createBackgroundTasks, syncDeclarativeSchedules } from "./createBackgroundWorker.server";
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
import { projectPubSub } from "./projectPubSub.server";
import { marqs } from "~/v3/marqs/index.server";
import { logger } from "~/services/logger.server";
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
export class CreateDeployedBackgroundWorkerService extends BaseService {
public async call(
@@ -49,7 +50,39 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
},
});
await createBackgroundTasks(body.metadata.tasks, backgroundWorker, environment, this._prisma);
try {
await createBackgroundTasks(
body.metadata.tasks,
backgroundWorker,
environment,
this._prisma
);
await syncDeclarativeSchedules(
body.metadata.tasks,
backgroundWorker,
environment,
this._prisma
);
} catch (error) {
const name = error instanceof Error ? error.name : "UnknownError";
const message = error instanceof Error ? error.message : JSON.stringify(error);
await this._prisma.workerDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "FAILED",
failedAt: new Date(),
errorData: {
name,
message,
},
},
});
throw error;
}
// Link the deployment with the background worker
await this._prisma.workerDeployment.update({
@@ -101,6 +134,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
await ExecuteTasksWaitingForDeployService.enqueue(backgroundWorker.id, this._prisma);
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
await TimeoutDeploymentService.dequeue(deployment.id, this._prisma);
return backgroundWorker;
});
@@ -27,6 +27,20 @@ export class DeleteTaskScheduleService extends BaseService {
}
try {
const schedule = await this._prisma.taskSchedule.findFirst({
where: {
friendlyId,
},
});
if (!schedule) {
throw new Error("Schedule not found");
}
if (schedule.type === "DECLARATIVE") {
throw new Error("Cannot delete declarative schedules");
}
await this._prisma.taskSchedule.delete({
where: {
friendlyId,
@@ -0,0 +1,49 @@
type HeartbeatServiceOptions = {
heartbeat: () => Promise<void>;
pingIntervalInMs?: number;
leadingEdge?: boolean;
};
export class HeartbeatService {
private _heartbeat: () => Promise<void>;
private _heartbeatIntervalInMs: number;
private _nextHeartbeat: NodeJS.Timeout | undefined;
private _leadingEdge: boolean;
constructor(opts: HeartbeatServiceOptions) {
this._heartbeat = opts.heartbeat;
this._heartbeatIntervalInMs = opts.pingIntervalInMs ?? 45_000;
this._nextHeartbeat = undefined;
this._leadingEdge = opts.leadingEdge ?? false;
}
start() {
if (this._leadingEdge) {
this.#doHeartbeat();
} else {
this.#scheduleNextHeartbeat();
}
}
stop() {
this.#clearNextHeartbeat();
}
#doHeartbeat = async () => {
this.#clearNextHeartbeat();
await this._heartbeat();
this.#scheduleNextHeartbeat();
};
#clearNextHeartbeat() {
if (this._nextHeartbeat) {
clearTimeout(this._nextHeartbeat);
}
}
#scheduleNextHeartbeat() {
this._nextHeartbeat = setTimeout(this.#doHeartbeat, this._heartbeatIntervalInMs);
}
}
@@ -45,6 +45,14 @@ export class IndexDeploymentService extends BaseService {
// just broadcast for now - there should only ever be one provider connected
try {
// timeout the deployment if 180 seconds have passed and the deployment is still not indexed
await TimeoutDeploymentService.enqueue(
deployment.id,
"DEPLOYING",
"Could not index deployment in time",
new Date(Date.now() + 180_000)
);
const responses = await socketIo.providerNamespace.timeout(30_000).emitWithAck("INDEX", {
version: "v1",
shortCode: deployment.shortCode,
@@ -61,15 +69,7 @@ export class IndexDeploymentService extends BaseService {
logger.debug("Index ACK received", { responses });
if (responses.length === 0) {
// timeout the deployment if 180 seconds have passed and the deployment is still not indexed
await TimeoutDeploymentService.enqueue(
deployment.id,
"DEPLOYING",
"Could not index deployment in time",
new Date(Date.now() + 180_000)
);
} else {
if (responses.length > 0) {
const indexFailed = new DeploymentIndexFailed();
for (const response of responses) {
@@ -83,12 +83,20 @@ export class IndexDeploymentService extends BaseService {
const indexFailed = new DeploymentIndexFailed();
await indexFailed.call(
deployment.friendlyId,
error instanceof Error
? { message: error.message, name: error.name }
: { message: "Could not index deployment in time", name: "TimeoutError" }
);
let indexError = {
message: `Could not index deployment: ${error}`,
name: "IndexError",
};
if (error instanceof Error) {
if (error.message === "operation has timed out") {
indexError = { message: "Provider failed to respond in time", name: "TimeoutError" };
} else {
indexError = { message: error.message, name: error.name };
}
}
await indexFailed.call(deployment.friendlyId, indexError);
}
}
@@ -0,0 +1,50 @@
import { deploymentIndexingIsRetryable } from "../deploymentStatus";
import { BaseService } from "./baseService.server";
import { IndexDeploymentService } from "./indexDeployment.server";
export class RetryDeploymentIndexingService extends BaseService {
public async call(deploymentId: string) {
const deployment = await this._prisma.workerDeployment.findUnique({
where: {
id: deploymentId,
},
});
if (!deployment) {
throw new Error("Deployment not found");
}
if (!deploymentIndexingIsRetryable(deployment)) {
throw new Error("Deployment indexing not retryable");
}
const latestDeployment = await this._prisma.workerDeployment.findFirst({
where: {
projectId: deployment.projectId,
},
select: {
id: true,
},
orderBy: {
createdAt: "desc",
},
});
if (!latestDeployment || latestDeployment.id !== deployment.id) {
throw new Error("Deployment is not the latest");
}
await this._prisma.workerDeployment.update({
where: {
id: deploymentId,
},
data: {
status: "DEPLOYING",
},
});
await IndexDeploymentService.enqueue(deployment.id);
return deployment;
}
}
@@ -28,6 +28,20 @@ export class SetActiveOnTaskScheduleService extends BaseService {
}
try {
const schedule = await this._prisma.taskSchedule.findFirst({
where: {
friendlyId,
},
});
if (!schedule) {
throw new Error("Schedule not found");
}
if (schedule.type === "DECLARATIVE") {
throw new Error("Cannot enable/disable declarative schedules");
}
await this._prisma.taskSchedule.update({
where: {
friendlyId,
@@ -20,6 +20,7 @@ export class StartDeploymentIndexing extends BaseService {
? registryProxy.rewriteImageReference(body.imageReference)
: body.imageReference,
status: "DEPLOYING",
builtAt: new Date(),
},
});
@@ -24,6 +24,7 @@ export class TestTaskService extends BaseService {
case "SCHEDULED": {
const payload = {
scheduleId: "sched_1234",
type: "IMPERATIVE",
timestamp: data.timestamp,
lastTimestamp: data.lastTimestamp,
timezone: data.timezone,
@@ -2,6 +2,7 @@ import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { workerQueue } from "~/services/worker.server";
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { PrismaClientOrTransaction } from "~/db.server";
export class TimeoutDeploymentService extends BaseService {
public async call(id: string, fromStatus: string, errorMessage: string) {
@@ -20,6 +21,10 @@ export class TimeoutDeploymentService extends BaseService {
}
if (deployment.status !== fromStatus) {
logger.error("Deployment is not in the correct state to be timed out", {
currentStatus: deployment.status,
fromStatus,
});
return;
}
@@ -57,4 +62,8 @@ export class TimeoutDeploymentService extends BaseService {
}
);
}
static async dequeue(deploymentId: string, tx?: PrismaClientOrTransaction) {
await workerQueue.dequeue(`timeoutDeployment:${deploymentId}`, { tx });
}
}
@@ -9,7 +9,9 @@ import { findCurrentWorkerDeployment } from "../models/workerDeployment.server";
import { logger } from "~/services/logger.server";
export class TriggerScheduledTaskService extends BaseService {
public async call(instanceId: string) {
public async call(instanceId: string, finalAttempt: boolean) {
const registerNextService = new RegisterNextTaskScheduleInstanceService();
const instance = await this._prisma.taskScheduleInstance.findUnique({
where: {
id: instanceId,
@@ -30,124 +32,136 @@ export class TriggerScheduledTaskService extends BaseService {
return;
}
let shouldTrigger = true;
if (!instance.active) {
shouldTrigger = false;
}
if (!instance.taskSchedule.active) {
shouldTrigger = false;
}
if (!instance.nextScheduledTimestamp) {
shouldTrigger = false;
}
if (
instance.environment.type === "DEVELOPMENT" &&
(!instance.environment.currentSession || instance.environment.currentSession.disconnectedAt)
) {
shouldTrigger = false;
}
if (instance.environment.type !== "DEVELOPMENT") {
// Get the current backgroundWorker for this environment
const currentWorkerDeployment = await findCurrentWorkerDeployment(instance.environment.id);
if (!currentWorkerDeployment) {
logger.debug("No current worker deployment found, skipping task trigger", {
instanceId,
scheduleId: instance.taskSchedule.friendlyId,
environmentId: instance.environment.id,
});
try {
let shouldTrigger = true;
if (!instance.active) {
shouldTrigger = false;
} else if (
!currentWorkerDeployment.worker ||
!currentWorkerDeployment.worker.tasks.some(
(t) => t.slug === instance.taskSchedule.taskIdentifier
)
}
if (!instance.taskSchedule.active) {
shouldTrigger = false;
}
if (!instance.nextScheduledTimestamp) {
shouldTrigger = false;
}
if (
instance.environment.type === "DEVELOPMENT" &&
(!instance.environment.currentSession || instance.environment.currentSession.disconnectedAt)
) {
logger.debug(
"Current worker deployment does not contain the scheduled task identifier, skipping task trigger",
{
shouldTrigger = false;
}
if (instance.environment.type !== "DEVELOPMENT") {
// Get the current backgroundWorker for this environment
const currentWorkerDeployment = await findCurrentWorkerDeployment(instance.environment.id);
if (!currentWorkerDeployment) {
logger.debug("No current worker deployment found, skipping task trigger", {
instanceId,
scheduleId: instance.taskSchedule.friendlyId,
environmentId: instance.environment.id,
workerDeploymentId: currentWorkerDeployment.id,
workerId: currentWorkerDeployment.worker?.id,
taskIdentifier: instance.taskSchedule.taskIdentifier,
}
);
});
shouldTrigger = false;
shouldTrigger = false;
} else if (
!currentWorkerDeployment.worker ||
!currentWorkerDeployment.worker.tasks.some(
(t) => t.slug === instance.taskSchedule.taskIdentifier
)
) {
logger.debug(
"Current worker deployment does not contain the scheduled task identifier, skipping task trigger",
{
instanceId,
scheduleId: instance.taskSchedule.friendlyId,
environmentId: instance.environment.id,
workerDeploymentId: currentWorkerDeployment.id,
workerId: currentWorkerDeployment.worker?.id,
taskIdentifier: instance.taskSchedule.taskIdentifier,
}
);
shouldTrigger = false;
}
}
}
const registerNextService = new RegisterNextTaskScheduleInstanceService();
if (shouldTrigger) {
// Enqueue triggering the task
const triggerTask = new TriggerTaskService();
if (shouldTrigger) {
// Enqueue triggering the task
const triggerTask = new TriggerTaskService();
const payload = {
scheduleId: instance.taskSchedule.friendlyId,
timestamp: instance.nextScheduledTimestamp,
lastTimestamp: instance.lastScheduledTimestamp ?? undefined,
externalId: instance.taskSchedule.externalId ?? undefined,
timezone: instance.taskSchedule.timezone,
upcoming: nextScheduledTimestamps(
instance.taskSchedule.generatorExpression,
instance.taskSchedule.timezone,
instance.nextScheduledTimestamp!,
10
),
};
const payloadPacket = await stringifyIO(payload);
logger.debug("Triggering scheduled task", {
instance,
payloadPacket,
});
const run = await triggerTask.call(
instance.taskSchedule.taskIdentifier,
instance.environment,
{ payload: payloadPacket.data, options: { payloadType: payloadPacket.dataType } },
{ customIcon: "scheduled" }
);
if (!run) {
logger.error("Failed to trigger task", {
instanceId,
const payload = {
scheduleId: instance.taskSchedule.friendlyId,
type: instance.taskSchedule.type,
timestamp: instance.nextScheduledTimestamp,
lastTimestamp: instance.lastScheduledTimestamp ?? undefined,
externalId: instance.taskSchedule.externalId ?? undefined,
timezone: instance.taskSchedule.timezone,
upcoming: nextScheduledTimestamps(
instance.taskSchedule.generatorExpression,
instance.taskSchedule.timezone,
instance.nextScheduledTimestamp!,
10
),
};
const payloadPacket = await stringifyIO(payload);
logger.debug("Triggering scheduled task", {
instance,
payloadPacket,
});
} else {
await this._prisma.taskRun.update({
where: {
id: run.id,
},
data: {
scheduleId: instance.taskSchedule.id,
scheduleInstanceId: instance.id,
},
const run = await triggerTask.call(
instance.taskSchedule.taskIdentifier,
instance.environment,
{ payload: payloadPacket.data, options: { payloadType: payloadPacket.dataType } },
{ customIcon: "scheduled" }
);
if (!run) {
logger.error("Failed to trigger task", {
instanceId,
scheduleId: instance.taskSchedule.friendlyId,
payloadPacket,
});
} else {
await this._prisma.taskRun.update({
where: {
id: run.id,
},
data: {
scheduleId: instance.taskSchedule.id,
scheduleInstanceId: instance.id,
},
});
}
}
await this._prisma.taskScheduleInstance.update({
where: {
id: instanceId,
},
data: {
lastScheduledTimestamp: instance.nextScheduledTimestamp,
},
});
await registerNextService.call(instanceId);
} catch (e) {
if (finalAttempt) {
logger.error("Failed to trigger scheduled task, rescheduling the next run", {
instanceId,
error: e,
});
await registerNextService.call(instanceId);
} else {
throw e;
}
}
await this._prisma.taskScheduleInstance.update({
where: {
id: instanceId,
},
data: {
lastScheduledTimestamp: instance.nextScheduledTimestamp,
},
});
await registerNextService.call(instanceId);
}
public static async enqueue(instanceId: string, runAt: Date, tx?: PrismaClientOrTransaction) {
@@ -1,16 +1,13 @@
import { Prisma, TaskSchedule } from "@trigger.dev/database";
import cronstrue from "cronstrue";
import { nanoid } from "nanoid";
import { ZodError } from "zod";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { CronPattern, UpsertSchedule } from "../schedules";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
import cronstrue from "cronstrue";
import { UpsertSchedule } from "../schedules";
import { calculateNextScheduledTimestamp } from "../utils/calculateNextSchedule.server";
import { getTimezones } from "~/utils/timezones.server";
import { env } from "~/env.server";
import { getLimit } from "~/services/platform.v3.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { CheckScheduleService } from "./checkSchedule.server";
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
export type UpsertTaskScheduleServiceOptions = UpsertSchedule;
@@ -30,79 +27,9 @@ type InstanceWithEnvironment = Prisma.TaskScheduleInstanceGetPayload<{
export class UpsertTaskScheduleService extends BaseService {
public async call(projectId: string, schedule: UpsertTaskScheduleServiceOptions) {
//validate the cron expression
try {
CronPattern.parse(schedule.cron);
} catch (e) {
if (e instanceof ZodError) {
throw new ServiceValidationError(`Invalid cron expression: ${e.issues[0].message}`);
}
throw new ServiceValidationError(
`Invalid cron expression: ${e instanceof Error ? e.message : JSON.stringify(e)}`
);
}
const task = await this._prisma.backgroundWorkerTask.findFirst({
where: {
slug: schedule.taskIdentifier,
projectId: projectId,
},
orderBy: {
createdAt: "desc",
},
});
if (!task) {
throw new ServiceValidationError(
`Task with identifier ${schedule.taskIdentifier} not found in project.`
);
}
if (task.triggerSource !== "SCHEDULED") {
throw new ServiceValidationError(
`Task with identifier ${schedule.taskIdentifier} is not a scheduled task.`
);
}
//if creating a schedule, check they're under the limits
if (!schedule.friendlyId) {
//check they're within their limit
const project = await this._prisma.project.findFirst({
where: {
id: projectId,
},
select: {
organizationId: true,
},
});
if (!project) {
throw new ServiceValidationError("Project not found");
}
const limit = await getLimit(project.organizationId, "schedules", 500);
const schedulesCount = await this._prisma.taskSchedule.count({
where: {
projectId,
},
});
if (schedulesCount >= limit) {
throw new ServiceValidationError(
`You have created ${schedulesCount}/${limit} schedules so you'll need to increase your limits or delete some schedules. Increase your limits by contacting support.`
);
}
}
if (schedule.timezone) {
const possibleTimezones = getTimezones();
if (!possibleTimezones.includes(schedule.timezone)) {
throw new ServiceValidationError(
`Invalid IANA timezone: "${schedule.timezone}". View the list of valid timezones at ${env.APP_ORIGIN}/timezones`
);
}
}
//this throws errors if the schedule is invalid
const checkSchedule = new CheckScheduleService(this._prisma);
await checkSchedule.call(projectId, schedule);
const result = await $transaction(this._prisma, async (tx) => {
const deduplicationKey =
@@ -126,6 +53,10 @@ export class UpsertTaskScheduleService extends BaseService {
});
if (existingSchedule) {
if (existingSchedule.type === "DECLARATIVE") {
throw new ServiceValidationError("Cannot update a declarative schedule");
}
return await this.#updateExistingSchedule(tx, existingSchedule, schedule, projectId);
} else {
return await this.#createNewSchedule(tx, schedule, projectId, deduplicationKey);
@@ -317,6 +248,7 @@ export class UpsertTaskScheduleService extends BaseService {
#createReturnObject(taskSchedule: TaskSchedule, instances: InstanceWithEnvironment[]) {
return {
id: taskSchedule.friendlyId,
type: taskSchedule.type,
task: taskSchedule.taskIdentifier,
active: taskSchedule.active,
externalId: taskSchedule.externalId,
@@ -96,6 +96,7 @@ export class SharedSocketConnection {
this._messageHandler = new ZodMessageHandler({
schema: clientWebsocketMessages,
logger,
messages: {
READY_FOR_TASKS: async (payload) => {
this._sharedQueueConsumerPool.start();
+11 -4
View File
@@ -39,6 +39,7 @@ class RedisZodSubscriber<TMessageCatalog extends ZodMessageCatalogSchema>
this._subscriber = new Redis(_options.redis);
this._messageHandler = new ZodMessageHandler({
schema: _options.schema,
logger: this._logger,
});
}
@@ -76,20 +77,26 @@ class RedisZodSubscriber<TMessageCatalog extends ZodMessageCatalogSchema>
const message = this._messageHandler.parseMessage(parsedMessage);
if (typeof message.type !== "string") {
if (!message.success) {
this._logger.error(`Failed to parse message: ${message.error}`, { parsedMessage });
return;
}
const listener = this._listeners.get(message.type);
if (typeof message.data.type !== "string") {
this._logger.error(`Failed to parse message: invalid type`, { parsedMessage });
return;
}
const listener = this._listeners.get(message.data.type);
if (!listener) {
this._logger.debug(`No listener for message type: ${message.type}`, { parsedMessage });
this._logger.debug(`No listener for message type: ${message.data.type}`, { parsedMessage });
return;
}
try {
await listener(message.payload);
await listener(message.data.payload);
} catch (error) {
this._logger.error("Error handling message", { error, message });
}
+4
View File
@@ -188,6 +188,7 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@total-typescript/ts-reset": "^0.4.2",
"@trigger.dev/eslint-plugin": "workspace:*",
"@types/bcryptjs": "^2.4.2",
"@types/compression": "^1.7.2",
"@types/eslint": "^8.4.6",
@@ -221,6 +222,9 @@
"esbuild": "^0.15.10",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-turbo": "^2.0.4",
"npm-run-all": "^4.1.5",
"postcss-import": "^16.0.1",
"postcss-loader": "^8.1.1",
+1 -1
View File
@@ -54,7 +54,7 @@ RUN pnpm run build --filter=webapp...
# Runner
FROM node:20.11.1-bullseye-slim@sha256:5a5a92b3a8d392691c983719dbdc65d9f30085d6dcd65376e7a32e6fe9bf4cbe AS runner
RUN apt-get update && apt-get install -y openssl
RUN apt-get update && apt-get install -y openssl netcat-openbsd
WORKDIR /triggerdotdev
RUN corepack enable
ENV NODE_ENV production
+2 -3
View File
@@ -1,5 +1,5 @@
#!/bin/sh
set -x
set -xe
if [ -n "$DATABASE_HOST" ]; then
scripts/wait-for-it.sh ${DATABASE_HOST} -- echo "database is up"
@@ -8,10 +8,9 @@ fi
# Run migrations
pnpm --filter @trigger.dev/database db:migrate:deploy
# Copy over required prisma files and invoke bundled seed file
# Copy over required prisma files
cp packages/database/prisma/schema.prisma apps/webapp/prisma/
cp node_modules/@prisma/engines/*.node apps/webapp/prisma/
pnpm --filter webapp db:seed
cd /triggerdotdev/apps/webapp
# exec dumb-init pnpm run start:local
+24 -78
View File
@@ -1,14 +1,8 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "Trigger.dev",
"openapi": [
"/openapi.yml",
"/v3-openapi.yaml"
],
"versions": [
"v3",
"v2"
],
"openapi": ["/openapi.yml", "/v3-openapi.yaml"],
"versions": ["v3", "v2"],
"api": {
"playground": {
"mode": "simple"
@@ -76,9 +70,7 @@
{
"group": "",
"version": "v3",
"pages": [
"v3/introduction"
]
"pages": ["v3/introduction"]
},
{
"group": "Getting Started",
@@ -101,10 +93,7 @@
"v3/apikeys",
{
"group": "Task types",
"pages": [
"v3/tasks-regular",
"v3/tasks-scheduled"
]
"pages": ["v3/tasks-regular", "v3/tasks-scheduled"]
},
"v3/trigger-config"
]
@@ -112,10 +101,7 @@
{
"group": "Development",
"version": "v3",
"pages": [
"v3/cli-dev",
"v3/run-tests"
]
"pages": ["v3/cli-dev", "v3/run-tests"]
},
{
"group": "Deployment",
@@ -126,9 +112,7 @@
"v3/github-actions",
{
"group": "Deployment integrations",
"pages": [
"v3/vercel-integration"
]
"pages": ["v3/vercel-integration"]
}
]
},
@@ -154,7 +138,8 @@
"v3/machines",
"v3/idempotency",
"v3/reattempting-replaying",
"v3/notifications"
"v3/notifications",
"v3/run-usage"
]
},
{
@@ -164,10 +149,7 @@
"v3/management/overview",
{
"group": "Tasks API",
"pages": [
"v3/management/tasks/trigger",
"v3/management/tasks/batch-trigger"
]
"pages": ["v3/management/tasks/trigger", "v3/management/tasks/batch-trigger"]
},
{
"group": "Runs API",
@@ -205,26 +187,21 @@
},
{
"group": "Projects API",
"pages": [
"v3/management/projects/runs"
]
"pages": ["v3/management/projects/runs"]
}
]
},
{
"group": "Open source",
"version": "v3",
"pages": [
"v3/github-repo",
"v3/open-source-self-hosting",
"v3/open-source-contributing"
]
"pages": ["v3/github-repo", "v3/open-source-self-hosting", "v3/open-source-contributing"]
},
{
"group": "Troubleshooting",
"version": "v3",
"pages": [
"v3/troubleshooting",
"v3/upgrading-packages",
"v3/troubleshooting-alerts",
"v3/troubleshooting-uptime-status",
"v3/troubleshooting-github-issues",
@@ -234,11 +211,7 @@
{
"group": "Help",
"version": "v3",
"pages": [
"v3/community",
"v3/help-slack",
"v3/help-email"
]
"pages": ["v3/community", "v3/help-slack", "v3/help-email"]
},
{
"group": "Getting Started",
@@ -430,10 +403,7 @@
"pages": [
{
"group": "Airtable",
"pages": [
"integrations/apis/airtable",
"integrations/apis/airtable-tasks"
]
"pages": ["integrations/apis/airtable", "integrations/apis/airtable-tasks"]
},
{
"group": "GitHub",
@@ -459,25 +429,16 @@
},
{
"group": "Plain",
"pages": [
"integrations/apis/plain",
"integrations/apis/plain-tasks"
]
"pages": ["integrations/apis/plain", "integrations/apis/plain-tasks"]
},
"integrations/apis/replicate",
{
"group": "SendGrid",
"pages": [
"integrations/apis/sendgrid",
"integrations/apis/sendgrid-tasks"
]
"pages": ["integrations/apis/sendgrid", "integrations/apis/sendgrid-tasks"]
},
{
"group": "Resend",
"pages": [
"integrations/apis/resend",
"integrations/apis/resend-tasks"
]
"pages": ["integrations/apis/resend", "integrations/apis/resend-tasks"]
},
{
"group": "Shopify",
@@ -489,10 +450,7 @@
},
{
"group": "Slack",
"pages": [
"integrations/apis/slack",
"integrations/apis/slack-tasks"
]
"pages": ["integrations/apis/slack", "integrations/apis/slack-tasks"]
},
"integrations/apis/stripe",
{
@@ -518,9 +476,7 @@
"sdk/triggerclient/constructor",
{
"group": "Instance properties",
"pages": [
"sdk/triggerclient/store"
]
"pages": ["sdk/triggerclient/store"]
},
{
"group": "Instance methods",
@@ -583,10 +539,7 @@
"sdk/dynamictrigger/constructor",
{
"group": "Instance methods",
"pages": [
"sdk/dynamictrigger/register",
"sdk/dynamictrigger/unregister"
]
"pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"]
}
]
},
@@ -597,10 +550,7 @@
"sdk/dynamicschedule/constructor",
{
"group": "Instance methods",
"pages": [
"sdk/dynamicschedule/register",
"sdk/dynamicschedule/unregister"
]
"pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"]
}
]
},
@@ -613,9 +563,7 @@
{
"group": "HTTP Reference",
"version": "v2",
"pages": [
"sdk/api-reference/events/create-an-event"
]
"pages": ["sdk/api-reference/events/create-an-event"]
},
{
"group": "React SDK",
@@ -631,9 +579,7 @@
{
"group": "Overview",
"version": "v2",
"pages": [
"examples/introduction"
]
"pages": ["examples/introduction"]
}
],
"footerSocials": {
@@ -641,4 +587,4 @@
"github": "https://github.com/triggerdotdev",
"linkedin": "https://www.linkedin.com/company/triggerdotdev"
}
}
}
+30 -12
View File
@@ -16,7 +16,7 @@ paths:
post:
operationId: create_schedule_v1
summary: Create a schedule
description: Create a new schedule based on the specified options.
description: Create a new `IMPERATIVE` schedule based on the specified options.
requestBody:
required: true
content:
@@ -127,7 +127,7 @@ paths:
put:
operationId: update_schedule_v1
summary: Update Schedule
description: Update a schedule by its ID.
description: Update a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
parameters:
- in: path
name: schedule_id
@@ -173,7 +173,7 @@ paths:
delete:
operationId: delete_schedule_v1
summary: Delete Schedule
description: Delete a schedule by its ID.
description: Delete a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
parameters:
- in: path
name: schedule_id
@@ -203,7 +203,7 @@ paths:
post:
operationId: deactivate_schedule_v1
summary: Deactivate Schedule.
description: Deactivate a schedule by its ID.
description: Deactivate a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
parameters:
- in: path
name: schedule_id
@@ -237,7 +237,7 @@ paths:
post:
operationId: activate_schedule_v1
summary: Activate Schedule
description: Activate a schedule by its ID.
description: Activate a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
parameters:
- in: path
name: schedule_id
@@ -925,7 +925,6 @@ paths:
override: false
});
"/api/v1/projects/{projectRef}/envvars/{env}/{name}":
parameters:
- $ref: "#/components/parameters/projectRef"
@@ -1314,10 +1313,6 @@ paths:
]
}'
components:
parameters:
taskIdentifier:
@@ -1496,14 +1491,33 @@ components:
type: object
properties:
queue:
$ref: '#/components/schemas/QueueOptions'
$ref: "#/components/schemas/QueueOptions"
concurrencyKey:
type: string
description: Scope the concurrency limit to a specific key.
idempotencyKey:
type: string
description: An optional property that specifies the idempotency key used to prevent creating duplicate runs. If you provide an existing idempotency key, we will return the existing run ID.
ttl:
$ref: "#/components/schemas/TTL"
delay:
$ref: "#/components/schemas/Delay"
TTL:
type:
- string
- number
description: "The time-to-live for this run. If the run is not executed within this time, it will be removed from the queue and never execute. You can use a string in this format: `1h`, `1m`, `1h42m` or a number of seconds (min. 1)."
Delay:
type: string
description: |
The delay before the task is executed. This can be a Date object, a string like `1h` or a date-time string.
* "1h" - 1 hour
* "30d" - 30 days
* "15m" - 15 minutes
* "2w" - 2 weeks
* "60s" - 60 seconds
* new Date("2025-01-01T00:00:00Z")
EnvFilter:
type: object
properties:
@@ -1957,6 +1971,10 @@ components:
example: my-scheduled-task
description: The id of the scheduled task that will be triggered by this
schedule
"type":
type: string
example: IMPERATIVE
description: The type of schedule, `DECLARATIVE` or `IMPERATIVE`. Declarative schedules are declared in your code by setting the `cron` property on a `schedules.task`. Imperative schedules are created in the dashboard or by using the imperative SDK functions like `schedules.create()`.
active:
type: boolean
example: true
+1 -2
View File
@@ -14,12 +14,11 @@ description: "What features are currently available in the Developer Preview"
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
| Alerts | Add alerts in the UI for errors and deploys | ✅ |
| [Alerts](/v3/troubleshooting-alerts) | Add alerts in the UI for errors and deploys | ✅ |
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | ✅ |
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | ⏳ |
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
| Logs view with search | All logs view with filtering and full text search | ⏳ |
| Notifications | Send data to your web app from a run | ⏳ |
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
[Let us know](https://trigger.dev/discord) what we should prioritize and what we are missing.
+2 -2
View File
@@ -3,7 +3,7 @@ title: "GitHub Actions"
description: "You can easily deploy your tasks with GitHub actions."
---
This simple GitHub action file will deploy you Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it.
This simple GitHub action file will deploy your Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it.
<Warning>
The deploy step will fail if any version mismatches are detected. Please see the [version
@@ -77,7 +77,7 @@ jobs:
If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file.
You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can create a new access token by going to your profile page and then clicking on the "Personal Access Tokens" tab.
You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can create a new access token by going to your profile page and then clicking on the ["Personal Access Tokens"](https://cloud.trigger.dev/account/tokens) tab.
To set it in GitHub go to your repository, click on "Settings", "Secrets and variables" and then "Actions". Add a new secret with the name `TRIGGER_ACCESS_TOKEN` and use the value of your access token.
+2
View File
@@ -39,3 +39,5 @@ export const config: TriggerConfig = {
| medium-2x | 2 | 4 |
| large-1x | 4 | 8 |
| large-2x | 8 | 16 |
You can view the Trigger.dev cloud pricing for these machines [here](https://trigger.dev/pricing#computePricing).
+85
View File
@@ -0,0 +1,85 @@
---
title: "Usage"
description: "Get compute duration and cost from inside a run, or for a specific block of code."
---
## Getting the run cost and duration
You can get the cost and duration of the current including retries of the same run.
```ts
export const heavyTask = task({
id: "heavy-task",
machine: {
preset: "medium-2x",
},
run: async (payload, { ctx }) => {
// Do some compute
const result = await convertVideo(payload.videoUrl);
// Get the current cost and duration up until this line of code
// This includes the compute time of the previous lines
let currentUsage = usage.getCurrent();
/* currentUsage = {
compute: {
attempt: {
costInCents: 0.01700,
durationMs: 1000,
},
total: {
costInCents: 0.0255,
durationMs: 1500,
},
},
baseCostInCents: 0.0025,
totalCostInCents: 0.028,
}
*/
// In the cloud product we do not count waits towards the compute cost or duration.
// We also don't include time between attempts or before the run starts executing your code.
// So this line does not affect the cost or duration.
await wait.for({ seconds: 5 });
// This will give the same result as before the wait.
currentUsage = usage.getCurrent();
// Do more compute
const result = await convertVideo(payload.videoUrl);
// This would give a different value
currentUsage = usage.getCurrent();
},
});
```
<Note>
In Trigger.dev cloud we do not include time between attempts, before your code executes, or waits
towards the compute cost or duration.
</Note>
## Getting the cost and duration of a block of code
You can also wrap code with `usage.measure` to get the cost and duration of that block of code:
```ts
// Inside a task run function, or inside a function that's called from there.
const { result, compute } = await usage.measure(async () => {
//...Do something for 1 second
return {
foo: "bar",
};
});
logger.info("Result", { result, compute });
/* result = {
foo: "bar"
}
compute = {
costInCents: 0.01700,
durationMs: 1000,
}
*/
```
This will work from inside the `run` function, our lifecycle hooks (like `onStart`, `onFailure`, `onSuccess`, etc.), or any function you're calling from the `run` function. It won't work for code that's not executed using Trigger.dev.
+62 -10
View File
@@ -3,21 +3,13 @@ title: "Scheduled tasks"
description: "A task that is triggered on a recurring schedule using CRON syntax."
---
To use scheduled tasks you need to do two things:
1. Define a task in your code using `schedules.task()`.
2. Attach a schedule to the task either using the dashboard or the SDK.
<Info>A task can have multiple schedules attached to it.</Info>
Like all tasks they don't have timeouts, they should be placed inside a [/trigger folder](/v3/trigger-folder), and you [can configure them](/v3/tasks-overview#defining-a-task).
## Defining a scheduled task
This task will run when any of the attached schedules trigger. They have a predefined payload with some useful properties:
```ts
import { schedules } from "@trigger.dev/sdk/v3";
//this task will run when any of the attached schedules trigger
export const firstScheduledTask = schedules.task({
id: "first-scheduled-task",
run: async (payload) => {
@@ -68,6 +60,66 @@ You can see from the comments that the payload has several useful properties:
to do that.
</Note>
Like all tasks they don't have timeouts, they should be placed inside a [/trigger folder](/v3/trigger-folder), and you [can configure them](/v3/tasks-overview#defining-a-task).
## How to attach a schedule
Now that we've defined a scheduled task, we need to define when it will actually run. To do this we need to attach one or more schedules.
There are two ways of doing this:
- **Declarative:** defined on your `schedules.task`. They sync when you run the dev command or deploy.
- **Imperative:** created from the dashboard or by using the imperative SDK functions like `schedules.create()`.
<Info>
A scheduled task can have multiple schedules attached to it, including a declarative schedule
and/or many imperative schedules.
</Info>
### Declarative schedules
These sync when you run the [dev](/v3/cli-dev) or [deploy](/v3/cli-deploy) commands.
To create them you add the `cron` property to your `schedules.task()`. This property is optional and is only used if you want to add a declarative schedule to your task:
```ts
export const firstScheduledTask = schedules.task({
id: "first-scheduled-task",
//every two hours (UTC timezone)
cron: "0 */2 * * *",
run: async (payload, { ctx }) => {
//do something
},
});
```
If you use a string it will be in UTC. Alternatively, you can specify a timezone like this:
```ts
export const secondScheduledTask = schedules.task({
id: "second-scheduled-task",
cron: {
//5am every day Tokyo time
pattern: "0 5 * * *",
timezone: "Asia/Tokyo",
},
run: async (payload) => {},
});
```
When you run the [dev](/v3/cli-dev) or [deploy](/v3/cli-deploy) commands, declarative schedules will be synced. If you add, delete or edit the `cron` property it will be updated when you run these commands. You can view your schedules on the Schedules page in the dashboard.
### Imperative schedules
Alternatively you can explicitly attach schedules to a `schedules.task`. You can do this in the Schedules page in the dashboard by just pressing the "New schedule" button, or you can use the SDK to create schedules.
The advantage of imperative schedules is that they can be created dynamically, for example, you could create a schedule for each user in your database. They can also be activated, disabled, edited, and deleted without deploying new code by using the SDK or dashboard.
To use imperative schedules you need to do two things:
1. Define a task in your code using `schedules.task()`.
2. Attach 1+ schedules to the task either using the dashboard or the SDK.
## Supported CRON syntax
```
+80
View File
@@ -0,0 +1,80 @@
---
title: "How to upgrade the Trigger.dev packages"
sidebarTitle: "Upgrading packages"
description: "When we release fixes and new features we recommend you upgrade your Trigger.dev packages."
---
## Update command
Run this command in your project:
```sh
npx trigger.dev@beta update
```
This will update all of the Trigger.dev packages in your project to the latest version.
## Running the CLI locally
When you run the CLI locally use the latest version for the `dev` and `deploy` commands:
```sh
npx trigger.dev@beta dev
```
```sh
npx trigger.dev@beta deploy
```
These commands will also give you the option to upgrade if you are behind on versions.
## Deploying with GitHub Actions
You can deploy using [GitHub Actions](/v3/github-actions). We recommend that you lock your version in the workflow file so make sure to upgrade.
<Warning>
The deploy step will fail if version mismatches are detected. It's important that you update the
version using the steps below.
</Warning>
<Steps>
<Step title="Find your workflow file">
In your `.githubs/workflows` folder you can find your workflow yml files. You may have a prod
and staging one.
</Step>
<Step title="Update the version for the run command">
In the steps you'll see a `run` command. It will run the trigger.dev deploy CLI command. Make
sure to update this version to the latest version (e.g. `npx trigger.dev@3.0.0-beta.48 deploy`).
</Step>
</Steps>
## package.json dev dependency
Instead of using `npx`, `pnpm dlx` or `yarn dlx` you can add the Trigger.dev CLI as a dev dependency to your package.json file.
For example:
```json
{
"devDependencies": {
"trigger.dev": "3.0.0-beta.48"
}
}
```
If you've done this make sure to update the version to match the `@trigger.dev/sdk` package.
Once you have added the `trigger.dev` package to your `devDependencies`, you can use `npm exec trigger.dev`, `pnpm exec trigger.dev`, or `yarn exec trigger.dev` to run the CLI.
But we recommend adding your dev and deploy commands to the `scripts` section of your `package.json` file:
```json
{
"scripts": {
"dev:trigger": "trigger.dev dev",
"deploy:trigger": "trigger.dev deploy"
}
}
```
Then you can run `npm run dev:trigger` and `npm run deploy:trigger` to run the CLI.
-5
View File
@@ -19,8 +19,3 @@ Before digging deeper into the details of writing tasks, you should read the [fu
| [Idempotency](/v3/idempotency) | Protect against mutations happening twice. |
| [Reattempting & Replaying](/v3/reattempting-replaying) | You can reattempt a task that has failed all of its attempts. You can also replay a task with a new version of your code. |
| [Notifications](/v3/notifications) | Send realtime notifications from your task that you can subscribe to from your backend or frontend. |
| [Rollbacks](/v3/rollbacks) | Rollback code inside a task when errors happen to provide transactional guarantees. |
| [Using APIs](/v3/using-apis) | How to use APIs from within your tasks. |
| [Trigger filters](/v3/trigger-filters) | Prevent unwanted filters where the payload doesn't match your filter. |
| [Middleware](/v3/middleware) | Middleware can wrap the task run function. |
| [Automated tests](/v3/automated-tests) | Write automated tests in code. |
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/airtable
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/airtable",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "Trigger.dev integration for airtable",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"airtable": "^0.12.1",
"zod": "3.22.3"
},
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/github
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/github",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "The official GitHub integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -30,8 +30,8 @@
"@octokit/request-error": "^5.0.1",
"@octokit/webhooks": "^12.0.10",
"octokit": "^3.1.2",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"zod": "3.22.3"
},
"engines": {
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/linear
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/linear",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "Trigger.dev integration for @linear/sdk",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@linear/sdk": "^8.0.0",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"zod": "3.22.3"
},
"engines": {
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/slack
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/openai",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "The official OpenAI integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -42,8 +42,8 @@
},
"dependencies": {
"openai": "^4.16.1",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47"
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50"
},
"engines": {
"node": ">=18.0.0"
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/plain
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/plain",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "The official Plain.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"@team-plain/typescript-sdk": "^2.7.0"
},
"engines": {
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/replicate
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/replicate",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "Trigger.dev integration for replicate",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"replicate": "^0.18.1",
"zod": "3.22.3"
},
+23
View File
@@ -1,5 +1,28 @@
# @trigger.dev/resend
## 3.0.0-beta.50
### Patch Changes
- Updated dependencies [8ba998794]
- @trigger.dev/sdk@3.0.0-beta.50
- @trigger.dev/integration-kit@3.0.0-beta.50
## 3.0.0-beta.49
### Patch Changes
- @trigger.dev/integration-kit@3.0.0-beta.49
- @trigger.dev/sdk@3.0.0-beta.49
## 3.0.0-beta.48
### Patch Changes
- Updated dependencies [ecf1110ab]
- @trigger.dev/sdk@3.0.0-beta.48
- @trigger.dev/integration-kit@3.0.0-beta.48
## 3.0.0-beta.47
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/resend",
"version": "3.0.0-beta.47",
"version": "3.0.0-beta.50",
"description": "The official Resend.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.47",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.47",
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.50",
"@trigger.dev/sdk": "workspace:^3.0.0-beta.50",
"resend": "^2.1.0"
},
"engines": {

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