From 4e6c4ea2f9bf35dd6e196ea3c2a23f8bb62dde28 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Apr 2025 18:50:31 +0100 Subject: [PATCH 01/29] WIP on secret env vars --- .../EnvironmentVariablesPresenter.server.ts | 47 +-- .../route.tsx | 295 +++++++++--------- .../migration.sql | 3 + .../database/prisma/schema.prisma | 8 +- 4 files changed, 187 insertions(+), 166 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 1123a66b62..078987568f 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,3 +1,4 @@ +import { flipCauseOption } from "effect/Cause"; import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; @@ -48,6 +49,7 @@ export class EnvironmentVariablesPresenter { key: true, }, }, + isSecret: true, }, }, }, @@ -82,34 +84,41 @@ export class EnvironmentVariablesPresenter { }, }); - const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)); + const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter( + (e) => e.orgMember?.userId === userId || e.orgMember === null + ); const repository = new EnvironmentVariablesRepository(this.#prismaClient); const variables = await repository.getProject(project.id); return { - environmentVariables: environmentVariables.map((environmentVariable) => { + environmentVariables: environmentVariables.flatMap((environmentVariable) => { const variable = variables.find((v) => v.key === environmentVariable.key); - return { - id: environmentVariable.id, - key: environmentVariable.key, - values: sortedEnvironments.reduce((previous, env) => { - const val = variable?.values.find((v) => v.environment.id === env.id); - previous[env.id] = { - value: val?.value, + return sortedEnvironments.flatMap((env) => { + const val = variable?.values.find((v) => v.environment.id === env.id); + const isSecret = + environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false; + + if (!val) { + return []; + } + + return [ + { + id: environmentVariable.id, + key: environmentVariable.key, environment: { type: env.type, id: env.id }, - }; - return { ...previous }; - }, {} as Record), - }; + value: val.value, + isSecret, + }, + ]; + }); }), - environments: sortedEnvironments - .filter((e) => e.orgMember?.userId === userId || e.orgMember === null) - .map((environment) => ({ - id: environment.id, - type: environment.type, - })), + environments: sortedEnvironments.map((environment) => ({ + id: environment.id, + type: environment.type, + })), hasStaging: environments.some((environment) => environment.type === "STAGING"), }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index cd8317580c..4c99848c4e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -2,7 +2,9 @@ import { useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BookOpenIcon, + CheckIcon, InformationCircleIcon, + LockClosedIcon, PencilSquareIcon, PlusIcon, TrashIcon, @@ -23,6 +25,7 @@ import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -43,6 +46,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -188,7 +192,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, environments, hasStaging } = useTypedLoaderData(); + const { environmentVariables, environments } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -231,11 +235,8 @@ export default function Page() { Key - {environments.map((environment) => ( - - - - ))} + Value + Environment Actions @@ -243,37 +244,41 @@ export default function Page() { {environmentVariables.length > 0 ? ( environmentVariables.map((variable) => ( - {variable.key} - {environments.map((environment) => { - const value = variable.values[environment.id]?.value; + + + + + {variable.isSecret ? ( + } + content="This variable is secret and cannot be revealed." + /> + ) : ( + + )} + - if (!value) { - return Not set; - } - return ( - - - - ); - })} + + + - + /> */} } - > + /> )) ) : ( @@ -309,132 +314,132 @@ export default function Page() { ); } -function EditEnvironmentVariablePanel({ - variable, - environments, - revealAll, -}: { - variable: EnvironmentVariableWithSetValues; - environments: Pick[]; - revealAll: boolean; -}) { - const [reveal, setReveal] = useState(revealAll); +// function EditEnvironmentVariablePanel({ +// variable, +// environments, +// revealAll, +// }: { +// variable: EnvironmentVariableWithSetValues; +// environments: Pick[]; +// revealAll: boolean; +// }) { +// const [reveal, setReveal] = useState(revealAll); - const [isOpen, setIsOpen] = useState(false); - const lastSubmission = useActionData(); - const navigation = useNavigation(); +// const [isOpen, setIsOpen] = useState(false); +// const lastSubmission = useActionData(); +// const navigation = useNavigation(); - const hiddenValues = Object.values(variable.values).filter( - (value) => !environments.map((e) => e.id).includes(value.environment.id) - ); +// 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" && - navigation.formData?.get("action") === "edit"; +// const isLoading = +// navigation.state !== "idle" && +// navigation.formMethod === "post" && +// navigation.formData?.get("action") === "edit"; - const [form, { id }] = useForm({ - id: "edit-environment-variable", - // TODO: type this - lastSubmission: lastSubmission as any, - onValidate({ formData }) { - return parse(formData, { schema }); - }, - shouldRevalidate: "onSubmit", - }); +// const [form, { id }] = useForm({ +// id: "edit-environment-variable", +// // TODO: type this +// lastSubmission: lastSubmission as any, +// onValidate({ formData }) { +// return parse(formData, { schema }); +// }, +// shouldRevalidate: "onSubmit", +// }); - return ( - - - - - - Edit {variable.key} -
- - - - {hiddenValues.map((value, index) => ( - - - - - ))} - {id.error} -
- - - - {variable.key} - - -
-
- -
- - setReveal(e.valueOf())} - /> -
-
- {environments.map((environment, index) => { - const value = variable.values[environment.id]?.value; - index += hiddenValues.length; - return ( - - - - - - ); - })} -
-
+// return ( +// +// +// +// +// +// Edit {variable.key} +// +// +// +// +// {hiddenValues.map((value, index) => ( +// +// +// +// +// ))} +// {id.error} +//
+// +// +// +// {variable.key} +// +// +//
+//
+// +//
+// +// setReveal(e.valueOf())} +// /> +//
+//
+// {environments.map((environment, index) => { +// const value = variable.values[environment.id]?.value; +// index += hiddenValues.length; +// return ( +// +// +// +// +// +// ); +// })} +//
+//
- {form.error} +// {form.error} - - {isLoading ? "Saving…" : "Save"} - - } - cancelButton={ - - } - /> -
- -
-
- ); -} +// +// {isLoading ? "Saving…" : "Save"} +// +// } +// cancelButton={ +// +// } +// /> +//
+// +//
+//
+// ); +// } function DeleteEnvironmentVariableButton({ variable, diff --git a/internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql b/internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql new file mode 100644 index 0000000000..44598a65b9 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "EnvironmentVariableValue" +ADD COLUMN "isSecret" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index b73a9f8e1f..e2b000406f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2728,8 +2728,12 @@ model EnvironmentVariableValue { variableId String environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) environmentId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + /// If true, the value is secret and cannot be revealed + isSecret Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([variableId, environmentId]) } From 0e8b5d3e682a9c0dbe3b993926a1e4bc2d21a912 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 12 Apr 2025 12:51:51 +0100 Subject: [PATCH 02/29] Editing individual env var values is working --- .../EnvironmentVariablesPresenter.server.ts | 2 +- .../route.tsx | 260 ++++++++---------- .../environmentVariablesRepository.server.ts | 79 ++++++ .../app/v3/environmentVariables/repository.ts | 8 + 4 files changed, 208 insertions(+), 141 deletions(-) diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 078987568f..ca4ca2345b 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -109,7 +109,7 @@ export class EnvironmentVariablesPresenter { id: environmentVariable.id, key: environmentVariable.key, environment: { type: env.type, id: env.id }, - value: val.value, + value: isSecret ? "" : val.value, isSecret, }, ]; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 4c99848c4e..7ae255a913 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -1,4 +1,4 @@ -import { useForm } from "@conform-to/react"; +import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BookOpenIcon, @@ -56,6 +56,7 @@ import { type EnvironmentVariableWithSetValues, EnvironmentVariablesPresenter, } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { @@ -69,6 +70,7 @@ import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/enviro import { DeleteEnvironmentVariable, EditEnvironmentVariable, + EditEnvironmentVariableValue, } from "~/v3/environmentVariables/repository"; export const meta: MetaFunction = () => { @@ -105,7 +107,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; const schema = z.discriminatedUnion("action", [ - z.object({ action: z.literal("edit"), key: z.string(), ...EditEnvironmentVariable.shape }), + z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }), z.object({ action: z.literal("delete"), key: z.string(), ...DeleteEnvironmentVariable.shape }), ]); @@ -146,8 +148,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (submission.value.action) { case "edit": { + logger.debug("ENVVARS edit", { submission: submission.value }); const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.edit(project.id, submission.value); + const result = await repository.editValue(project.id, submission.value); + + logger.debug("ENVVARS edit result", { result }); if (!result.success) { submission.error.key = result.error; @@ -237,20 +242,27 @@ export default function Page() { Key Value Environment - Actions + + Actions + {environmentVariables.length > 0 ? ( environmentVariables.map((variable) => ( - + {variable.isSecret ? ( } + button={ +
+ + Secret +
+ } content="This variable is secret and cannot be revealed." /> ) : ( @@ -258,7 +270,9 @@ export default function Page() { className="-ml-2" secure={!revealAll} value={variable.value} - variant={"secondary/small"} + variant={"tertiary/small"} + iconButton + fullWidth={false} /> )}
@@ -268,13 +282,9 @@ export default function Page() { - {/* */} + } @@ -283,7 +293,7 @@ export default function Page() { )) ) : ( - +
You haven't set any environment variables yet. []; -// revealAll: boolean; -// }) { -// const [reveal, setReveal] = useState(revealAll); - -// const [isOpen, setIsOpen] = useState(false); -// 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" && -// navigation.formData?.get("action") === "edit"; - -// const [form, { id }] = useForm({ -// id: "edit-environment-variable", -// // TODO: type this -// lastSubmission: lastSubmission as any, -// onValidate({ formData }) { -// return parse(formData, { schema }); -// }, -// shouldRevalidate: "onSubmit", -// }); - -// return ( -// -// -// -// -// -// Edit {variable.key} -//
-// -// -// -// {hiddenValues.map((value, index) => ( -// -// -// -// -// ))} -// {id.error} -//
-// -// -// -// {variable.key} -// -// -//
-//
-// -//
-// -// setReveal(e.valueOf())} -// /> -//
-//
-// {environments.map((environment, index) => { -// const value = variable.values[environment.id]?.value; -// index += hiddenValues.length; -// return ( -// -// -// -// -// -// ); -// })} -//
-//
- -// {form.error} - -// -// {isLoading ? "Saving…" : "Save"} -// -// } -// cancelButton={ -// -// } -// /> -//
-//
-//
-//
-// ); -// } +function EditEnvironmentVariablePanel({ + variable, + revealAll, +}: { + variable: EnvironmentVariableWithSetValues; + revealAll: boolean; +}) { + const [reveal, setReveal] = useState(revealAll); + + const [isOpen, setIsOpen] = useState(false); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "edit"; + + const [form, { id, environmentId, value }] = useForm({ + id: "edit-environment-variable", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + console.log("edit form", { form, variable, id, environmentId, value }); + + return ( + + + + + + Edit environment variable +
+ + + + {id.error} + {environmentId.error} +
+ + + + {variable.key} + + + + +
+ setReveal(e.valueOf())} + /> +
+ + + + {value.error} + + + {form.error} + + + {isLoading ? "Saving…" : "Save"} + + } + cancelButton={ + + } + /> +
+
+
+
+ ); +} function DeleteEnvironmentVariableButton({ variable, diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 05970a4578..63bd4b213b 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -353,6 +353,85 @@ export class EnvironmentVariablesRepository implements Repository { } } + async editValue( + projectId: string, + options: { + id: string; + environmentId: string; + value: string; + } + ): Promise { + const project = await this.prismaClient.project.findFirst({ + where: { + id: projectId, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + }, + }, + }); + + if (!project) { + return { success: false as const, error: "Project not found" }; + } + + if (!project.environments.some((e) => e.id === options.environmentId)) { + return { success: false as const, error: "Environment not found" }; + } + + const environmentVariable = await this.prismaClient.environmentVariable.findFirst({ + select: { + id: true, + key: true, + values: { + where: { + environmentId: options.environmentId, + }, + select: { + valueReferenceId: true, + }, + }, + }, + where: { + id: options.id, + }, + }); + + if (!environmentVariable) { + return { success: false as const, error: "Environment variable not found" }; + } + + if (environmentVariable.values.length === 0) { + return { success: false as const, error: "Environment variable value not found" }; + } + + try { + await $transaction(this.prismaClient, "edit env var value", async (tx) => { + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + const key = secretKey(projectId, options.environmentId, environmentVariable.key); + await secretStore.setSecret<{ secret: string }>(key, { + secret: options.value, + }); + }); + + return { + success: true as const, + }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : "Something went wrong", + }; + } + } + async getProject(projectId: string): Promise { const project = await this.prismaClient.project.findFirst({ where: { diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index ed5e3e70c2..5d814bec06 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -47,6 +47,13 @@ export const DeleteEnvironmentVariableValue = z.object({ }); export type DeleteEnvironmentVariableValue = z.infer; +export const EditEnvironmentVariableValue = z.object({ + id: z.string(), + environmentId: z.string(), + value: z.string(), +}); +export type EditEnvironmentVariableValue = z.infer; + export type Result = | { success: true; @@ -75,6 +82,7 @@ export type EnvironmentVariable = { export interface Repository { create(projectId: string, options: CreateEnvironmentVariables): Promise; edit(projectId: string, options: EditEnvironmentVariable): Promise; + editValue(projectId: string, options: EditEnvironmentVariableValue): Promise; getProject(projectId: string): Promise; getEnvironment(projectId: string, environmentId: string): Promise; getEnvironmentVariables(projectId: string, environmentId: string): Promise; From 7d73552490e9b90450a91bb6f118d497f5dde999 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 12 Apr 2025 12:53:23 +0100 Subject: [PATCH 03/29] Sort the env vars by the key --- .../EnvironmentVariablesPresenter.server.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index ca4ca2345b..f03121ae83 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -92,29 +92,31 @@ export class EnvironmentVariablesPresenter { const variables = await repository.getProject(project.id); return { - environmentVariables: environmentVariables.flatMap((environmentVariable) => { - const variable = variables.find((v) => v.key === environmentVariable.key); + environmentVariables: environmentVariables + .flatMap((environmentVariable) => { + const variable = variables.find((v) => v.key === environmentVariable.key); - return sortedEnvironments.flatMap((env) => { - const val = variable?.values.find((v) => v.environment.id === env.id); - const isSecret = - environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false; + return sortedEnvironments.flatMap((env) => { + const val = variable?.values.find((v) => v.environment.id === env.id); + const isSecret = + environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false; - if (!val) { - return []; - } + if (!val) { + return []; + } - return [ - { - id: environmentVariable.id, - key: environmentVariable.key, - environment: { type: env.type, id: env.id }, - value: isSecret ? "" : val.value, - isSecret, - }, - ]; - }); - }), + return [ + { + id: environmentVariable.id, + key: environmentVariable.key, + environment: { type: env.type, id: env.id }, + value: isSecret ? "" : val.value, + isSecret, + }, + ]; + }); + }) + .sort((a, b) => a.key.localeCompare(b.key)), environments: sortedEnvironments.map((environment) => ({ id: environment.id, type: environment.type, From d930104a529139cf5692e45ce081318c7a0ea528 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 12 Apr 2025 12:58:24 +0100 Subject: [PATCH 04/29] Deleting values --- .../route.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 7ae255a913..656fffab3c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -2,7 +2,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BookOpenIcon, - CheckIcon, InformationCircleIcon, LockClosedIcon, PencilSquareIcon, @@ -16,8 +15,7 @@ import { json, redirectDocument, } from "@remix-run/server-runtime"; -import { type RuntimeEnvironment } from "@trigger.dev/database"; -import { Fragment, useState } from "react"; +import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; @@ -69,7 +67,7 @@ import { import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { DeleteEnvironmentVariable, - EditEnvironmentVariable, + DeleteEnvironmentVariableValue, EditEnvironmentVariableValue, } from "~/v3/environmentVariables/repository"; @@ -108,7 +106,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const schema = z.discriminatedUnion("action", [ z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }), - z.object({ action: z.literal("delete"), key: z.string(), ...DeleteEnvironmentVariable.shape }), + z.object({ + action: z.literal("delete"), + key: z.string(), + ...DeleteEnvironmentVariableValue.shape, + }), ]); export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -148,12 +150,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (submission.value.action) { case "edit": { - logger.debug("ENVVARS edit", { submission: submission.value }); const repository = new EnvironmentVariablesRepository(prisma); const result = await repository.editValue(project.id, submission.value); - logger.debug("ENVVARS edit result", { result }); - if (!result.success) { submission.error.key = result.error; return json(submission); @@ -175,7 +174,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } case "delete": { const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.delete(project.id, submission.value); + const result = await repository.deleteValue(project.id, submission.value); if (!result.success) { submission.error.key = result.error; @@ -448,6 +447,7 @@ function DeleteEnvironmentVariableButton({
+ - -
- } - cancelButton={ - - Cancel - - } - /> + + + + + } + cancelButton={ + + Cancel + + } + /> ); } -function FieldLayout({ - children, - showDeleteButton, -}: { - children: React.ReactNode; - showDeleteButton: boolean; -}) { - return ( -
- {children} -
- ); +function FieldLayout({ children }: { children: React.ReactNode }) { + return
{children}
; } function VariableFields({ @@ -423,9 +409,9 @@ function VariableFields({ /> ); })} -
1 && "pr-10")}> +
- Tip: Paste your .env into this form to populate it. + Tip: Paste all your .env values at once into this form to populate it.
{fields.key.error} From 7c57aa6a4370e60b9dbf54d36d3ca6dabcce37a4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 14 Apr 2025 14:07:21 +0100 Subject: [PATCH 08/29] =?UTF-8?q?=E2=80=9CCopy=20text=E2=80=9D=20->=20?= =?UTF-8?q?=E2=80=9CCopy=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/primitives/CopyableText.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 71eb4aa1b1..4417db8710 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -51,7 +51,7 @@ export function CopyableText({ value, className }: { value: string; className?: )} } - content={copied ? "Copied!" : "Copy text"} + content={copied ? "Copied!" : "Copy"} className="font-sans" disableHoverableContent /> From c7a87daca59f0dd94fc2571ddaefca632443a7fe Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 14 Apr 2025 14:07:29 +0100 Subject: [PATCH 09/29] Draw a divider between hidden buttons --- apps/webapp/app/components/primitives/Table.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 07c72309a8..a51c78cc82 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -349,7 +349,9 @@ export const TableCellMenu = forwardRef< variants[variant].menuButtonDivider )} > -
{hiddenButtons}
+
+ {hiddenButtons} +
)} {/* Always visible buttons */} From c1b942653f66335dfa0b6806ee57d0f35ba17b14 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 14 Apr 2025 14:08:20 +0100 Subject: [PATCH 10/29] Env var tweaks --- .../route.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index c6cb4ef097..8c5a131300 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -54,7 +54,6 @@ import { type EnvironmentVariableWithSetValues, EnvironmentVariablesPresenter, } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; -import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { @@ -66,7 +65,6 @@ import { } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { - DeleteEnvironmentVariable, DeleteEnvironmentVariableValue, EditEnvironmentVariableValue, } from "~/v3/environmentVariables/repository"; @@ -238,9 +236,9 @@ export default function Page() { - Key - Value - Environment + Key + Value + Environment Actions @@ -258,7 +256,7 @@ export default function Page() { - + Secret } @@ -266,12 +264,10 @@ export default function Page() { /> ) : ( )} @@ -445,7 +441,7 @@ function DeleteEnvironmentVariableButton({ textAlignLeft LeadingIcon={TrashIcon} leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors" - className="transition-colors group-hover/button:bg-error" + className="ml-0.5 transition-colors group-hover/button:bg-error" > {isLoading ? "Deleting" : "Delete"} From 10e714a86365a5924659b1dcd30084f108e973d2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 14 Apr 2025 14:08:46 +0100 Subject: [PATCH 11/29] =?UTF-8?q?Don=E2=80=99t=20show=20Dev:you=20anymore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../environmentVariablesRepository.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 7dc247a21c..1552217acb 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -5,7 +5,7 @@ import { RuntimeEnvironmentType, } from "@trigger.dev/database"; import { z } from "zod"; -import { environmentTitle } from "~/components/environments/EnvironmentLabel"; +import { environmentFullTitle, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { $transaction, prisma } from "~/db.server"; import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; @@ -128,7 +128,7 @@ export class EnvironmentVariablesRepository implements Repository { variableErrors: existingVariableKeys.map((val) => ({ key: val.key, error: `Variable already set in ${val.environments - .map((e) => environmentTitle({ type: e })) + .map((e) => environmentFullTitle({ type: e })) .join(", ")}.`, })), }; From 707294e09c7e9f0f3fe401f8e287a40a0e5b5473 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 14:47:14 +0100 Subject: [PATCH 12/29] Grouping the same env var keys together --- .../route.tsx | 151 +++++++++++++----- 1 file changed, 110 insertions(+), 41 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 8c5a131300..4159bebdbb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -15,7 +15,7 @@ import { json, redirectDocument, } from "@remix-run/server-runtime"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; @@ -199,6 +199,43 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); + // Add isFirst and isLast to each environment variable + // They're set based on if they're the first or last time that `key` has been seen in the list + const groupedEnvironmentVariables = useMemo(() => { + // Create a map to track occurrences of each key + const keyOccurrences = new Map(); + + // First pass: count total occurrences of each key + environmentVariables.forEach((variable) => { + keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1); + }); + + // Second pass: add isFirstTime, isLastTime, and occurrences flags + const seenKeys = new Set(); + const currentOccurrences = new Map(); + + return environmentVariables.map((variable) => { + // Track current occurrence number for this key + const currentCount = (currentOccurrences.get(variable.key) || 0) + 1; + currentOccurrences.set(variable.key, currentCount); + + const totalOccurrences = keyOccurrences.get(variable.key) || 1; + const isFirstTime = !seenKeys.has(variable.key); + const isLastTime = currentCount === totalOccurrences; + + if (isFirstTime) { + seenKeys.add(variable.key); + } + + return { + ...variable, + isFirstTime, + isLastTime, + occurences: totalOccurrences, + }; + }); + }, [environmentVariables]); + return ( @@ -245,47 +282,79 @@ export default function Page() { - {environmentVariables.length > 0 ? ( - environmentVariables.map((variable) => ( - - - - - - {variable.isSecret ? ( - - - Secret - - } - content="This variable is secret and cannot be revealed." - /> - ) : ( - - )} - - - - - - - - - + {groupedEnvironmentVariables.length > 0 ? ( + groupedEnvironmentVariables.map((variable) => { + let cellClassName = ""; + let borderedCellClassName = ""; + + if (variable.occurences > 1) { + cellClassName = "py-1"; + borderedCellClassName = + "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"; + if (variable.isLastTime) { + cellClassName = "pt-1 pb-2"; + borderedCellClassName = ""; + } else if (variable.isFirstTime) { + cellClassName = "pt-2 pb-1"; + } + } else { + cellClassName = "py-2"; + } + + return ( + - - )) + > + + {variable.isFirstTime ? ( + + ) : null} + + + {variable.isSecret ? ( + + + Secret + + } + content="This variable is secret and cannot be revealed." + /> + ) : ( + + )} + + + + + + + + + + } + /> + + ); + }) ) : ( From 2d64f0939b74b7a039791540a05cee2998ea1965 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 15:11:39 +0100 Subject: [PATCH 13/29] Styles improved --- .../route.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 4159bebdbb..911c1f5005 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -284,21 +284,17 @@ export default function Page() { {groupedEnvironmentVariables.length > 0 ? ( groupedEnvironmentVariables.map((variable) => { - let cellClassName = ""; + const cellClassName = "py-2"; let borderedCellClassName = ""; if (variable.occurences > 1) { - cellClassName = "py-1"; borderedCellClassName = - "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"; + "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright"; if (variable.isLastTime) { - cellClassName = "pt-1 pb-2"; borderedCellClassName = ""; } else if (variable.isFirstTime) { - cellClassName = "pt-2 pb-1"; } } else { - cellClassName = "py-2"; } return ( From a6223fda83f4074d05df78cb937d93ea5a8fa410 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 15:43:46 +0100 Subject: [PATCH 14/29] Improved styling of edit panel --- .../route.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 911c1f5005..b7c24c8189 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -315,9 +315,9 @@ export default function Page() { {variable.isSecret ? ( +
- Secret + Secret
} content="This variable is secret and cannot be revealed." @@ -429,13 +429,15 @@ function EditEnvironmentVariablePanel({ {id.error} {environmentId.error}
- + - - {variable.key} - + + + + + + - From f9adbc2933a240188a615b8435f51a3539842370 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 11 Apr 2025 20:11:13 +0100 Subject: [PATCH 15/29] Fix bun detection, dev flushing, and init command (#1914) * update nypm to support text-based bun lockfiles * add nypm changeset * handle dev flushing failures gracefully * fix path normalization for init.ts * add changesets * chore: remove pre.json after exiting pre mode * init command to install v4-beta packages * Revert "chore: remove pre.json after exiting pre mode" This reverts commit f5694fde9314114c74a220c2213d19667bca1a6c. * make init default to cli version for all packages --- .changeset/late-chairs-ring.md | 5 ++ .changeset/moody-squids-count.md | 5 ++ .changeset/polite-lies-fix.md | 5 ++ .changeset/shiny-kiwis-beam.md | 5 ++ packages/cli-v3/package.json | 2 +- packages/cli-v3/src/build/bundle.ts | 15 +++--- packages/cli-v3/src/commands/init.ts | 10 ++-- .../cli-v3/src/entryPoints/dev-run-worker.ts | 39 ++++++++++++++- pnpm-lock.yaml | 49 +++++++++++++++++-- 9 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 .changeset/late-chairs-ring.md create mode 100644 .changeset/moody-squids-count.md create mode 100644 .changeset/polite-lies-fix.md create mode 100644 .changeset/shiny-kiwis-beam.md diff --git a/.changeset/late-chairs-ring.md b/.changeset/late-chairs-ring.md new file mode 100644 index 0000000000..cd7c9f3620 --- /dev/null +++ b/.changeset/late-chairs-ring.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Fix init.ts in custom trigger dirs diff --git a/.changeset/moody-squids-count.md b/.changeset/moody-squids-count.md new file mode 100644 index 0000000000..e475088102 --- /dev/null +++ b/.changeset/moody-squids-count.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Init command will now correctly install v4-beta packages diff --git a/.changeset/polite-lies-fix.md b/.changeset/polite-lies-fix.md new file mode 100644 index 0000000000..6e60a77604 --- /dev/null +++ b/.changeset/polite-lies-fix.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Update nypm package to support test-based bun.lock files diff --git a/.changeset/shiny-kiwis-beam.md b/.changeset/shiny-kiwis-beam.md new file mode 100644 index 0000000000..c01b131162 --- /dev/null +++ b/.changeset/shiny-kiwis-beam.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Handle flush errors gracefully in dev diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index b16cee9719..352fb43f88 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -109,7 +109,7 @@ "magicast": "^0.3.4", "minimatch": "^10.0.1", "mlly": "^1.7.1", - "nypm": "^0.3.9", + "nypm": "^0.5.4", "object-hash": "^3.0.0", "open": "^10.0.3", "p-limit": "^6.2.0", diff --git a/packages/cli-v3/src/build/bundle.ts b/packages/cli-v3/src/build/bundle.ts index 6206289ce8..90b488a4dc 100644 --- a/packages/cli-v3/src/build/bundle.ts +++ b/packages/cli-v3/src/build/bundle.ts @@ -3,7 +3,7 @@ import { DEFAULT_RUNTIME, ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget, TaskFile } from "@trigger.dev/core/v3/schemas"; import * as esbuild from "esbuild"; import { createHash } from "node:crypto"; -import { join, relative, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { createFile } from "../utilities/fileSystem.js"; import { logger } from "../utilities/logger.js"; import { resolveFileSources } from "../utilities/sourceFiles.js"; @@ -239,15 +239,18 @@ export async function getBundleResultFromBuild( // Check if the entry point is an init.ts file at the root of a trigger directory function isInitEntryPoint(entryPoint: string): boolean { - const normalizedEntryPoint = entryPoint.replace(/\\/g, "/"); // Normalize path separators const initFileNames = ["init.ts", "init.mts", "init.cts", "init.js", "init.mjs", "init.cjs"]; // Check if it's directly in one of the trigger directories return resolvedConfig.dirs.some((dir) => { - const normalizedDir = dir.replace(/\\/g, "/"); - return initFileNames.some( - (fileName) => normalizedEntryPoint === `${normalizedDir}/${fileName}` - ); + const normalizedDir = resolve(dir); + const normalizedEntryDir = resolve(dirname(entryPoint)); + + if (normalizedDir !== normalizedEntryDir) { + return false; + } + + return initFileNames.includes(basename(entryPoint)); }); } diff --git a/packages/cli-v3/src/commands/init.ts b/packages/cli-v3/src/commands/init.ts index 32e1ff017e..fcae774961 100644 --- a/packages/cli-v3/src/commands/init.ts +++ b/packages/cli-v3/src/commands/init.ts @@ -32,11 +32,15 @@ import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; import { spinner } from "../utilities/windows.js"; import { login } from "./login.js"; +import { VERSION } from "../version.js"; + +const cliVersion = VERSION as string; +const cliTag = cliVersion.includes("v4-beta") ? "v4-beta" : "latest"; const InitCommandOptions = CommonCommandOptions.extend({ projectRef: z.string().optional(), overrideConfig: z.boolean().default(false), - tag: z.string().default("latest"), + tag: z.string().default(cliVersion), skipPackageInstall: z.boolean().default(false), runtime: z.string().default("node"), pkgArgs: z.string().optional(), @@ -60,7 +64,7 @@ export function configureInitCommand(program: Command) { .option( "-t, --tag ", "The version of the @trigger.dev/sdk package to install", - "latest" + cliVersion ) .option( "-r, --runtime ", @@ -193,7 +197,7 @@ async function _initCommand(dir: string, options: InitCommandOptions) { log.info("Next steps:"); log.info( ` 1. To start developing, run ${chalk.green( - `npx trigger.dev@${options.tag} dev${options.profile ? "" : ` --profile ${options.profile}`}` + `npx trigger.dev@${cliTag} dev${options.profile ? "" : ` --profile ${options.profile}`}` )} in your project directory` ); log.info(` 2. Visit your ${projectDashboard} to view your newly created tasks.`); diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index 76c892720c..d821621ebd 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -472,7 +472,34 @@ const zodIpc = new ZodIpcConnection({ async function flushAll(timeoutInMs: number = 10_000) { const now = performance.now(); - await Promise.all([flushTracingSDK(timeoutInMs), flushMetadata(timeoutInMs)]); + const results = await Promise.allSettled([ + flushTracingSDK(timeoutInMs), + flushMetadata(timeoutInMs), + ]); + + const successfulFlushes = results + .filter((result) => result.status === "fulfilled") + .map((result) => result.value.flushed); + + const failedFlushes = ["tracingSDK", "runMetadata"].filter( + (flushed) => !successfulFlushes.includes(flushed) + ); + + if (failedFlushes.length > 0) { + logError(`Failed to flush ${failedFlushes.join(", ")}`); + } + + const errorMessages = results + .filter((result) => result.status === "rejected") + .map((result) => result.reason); + + if (errorMessages.length > 0) { + logError(errorMessages.join("\n")); + } + + for (const flushed of successfulFlushes) { + log(`Flushed ${flushed} successfully`); + } const duration = performance.now() - now; @@ -487,6 +514,11 @@ async function flushTracingSDK(timeoutInMs: number = 10_000) { const duration = performance.now() - now; log(`Flushed tracingSDK in ${duration}ms`); + + return { + flushed: "tracingSDK", + durationMs: duration, + }; } async function flushMetadata(timeoutInMs: number = 10_000) { @@ -497,6 +529,11 @@ async function flushMetadata(timeoutInMs: number = 10_000) { const duration = performance.now() - now; log(`Flushed runMetadata in ${duration}ms`); + + return { + flushed: "runMetadata", + durationMs: duration, + }; } const managedWorkerRuntime = new ManagedRuntimeManager(zodIpc, showInternalLogs); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04abb649d5..ae295a72e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1216,8 +1216,8 @@ importers: specifier: ^1.7.1 version: 1.7.1 nypm: - specifier: ^0.3.9 - version: 0.3.9 + specifier: ^0.5.4 + version: 0.5.4 object-hash: specifier: ^3.0.0 version: 3.0.0 @@ -19483,6 +19483,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /acorn@8.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: false + /acorn@8.8.1: resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} engines: {node: '>=0.4.0'} @@ -21004,7 +21010,7 @@ packages: /citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} dependencies: - consola: 3.2.3 + consola: 3.4.2 dev: false /cjs-module-lexer@1.2.3: @@ -21334,6 +21340,10 @@ packages: /confbox@0.1.7: resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + /confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + dev: false + /config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} dependencies: @@ -27925,6 +27935,15 @@ packages: pkg-types: 1.1.3 ufo: 1.5.4 + /mlly@1.7.4: + resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} + dependencies: + acorn: 8.14.1 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.5.4 + dev: false + /module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} @@ -28528,13 +28547,26 @@ packages: hasBin: true dependencies: citty: 0.1.6 - consola: 3.2.3 + consola: 3.4.2 execa: 8.0.1 pathe: 1.1.2 pkg-types: 1.1.3 ufo: 1.5.4 dev: false + /nypm@0.5.4: + resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 1.3.1 + tinyexec: 0.3.2 + ufo: 1.5.4 + dev: false + /oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} dev: false @@ -29260,7 +29292,6 @@ packages: /pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - dev: true /pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} @@ -29456,6 +29487,14 @@ packages: mlly: 1.7.1 pathe: 1.1.2 + /pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + dependencies: + confbox: 0.1.8 + mlly: 1.7.4 + pathe: 2.0.3 + dev: false + /platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} dev: false From e359aaef20bb9bc1606be9e52d59a4df08f2ef1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Apr 2025 20:19:03 +0100 Subject: [PATCH 16/29] Release 4.0.0-v4-beta.1 (#1916) * chore: Update version for release (v4-beta) * Release 4.0.0-v4-beta.1 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- .changeset/pre.json | 4 +++ packages/build/CHANGELOG.md | 7 ++++++ packages/build/package.json | 4 +-- packages/cli-v3/CHANGELOG.md | 12 +++++++++ packages/cli-v3/package.json | 6 ++--- packages/core/CHANGELOG.md | 2 ++ packages/core/package.json | 2 +- packages/python/CHANGELOG.md | 9 +++++++ packages/python/package.json | 12 ++++----- packages/react-hooks/CHANGELOG.md | 7 ++++++ packages/react-hooks/package.json | 4 +-- packages/redis-worker/CHANGELOG.md | 7 ++++++ packages/redis-worker/package.json | 4 +-- packages/rsc/CHANGELOG.md | 7 ++++++ packages/rsc/package.json | 6 ++--- packages/trigger-sdk/CHANGELOG.md | 7 ++++++ packages/trigger-sdk/package.json | 4 +-- pnpm-lock.yaml | 39 ++++++++++++++++++------------ 18 files changed, 106 insertions(+), 37 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 2ee6787d63..3863ea8ac2 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -20,8 +20,12 @@ "breezy-turtles-talk", "four-needles-add", "honest-files-decide", + "late-chairs-ring", + "moody-squids-count", "nice-colts-boil", + "polite-lies-fix", "red-wasps-cover", + "shiny-kiwis-beam", "smart-coins-hammer", "weak-jobs-hide" ] diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index 1bf1737980..5b917475d4 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/build +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/build/package.json b/packages/build/package.json index be10a3144a..1c08f57e42 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/build", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -69,7 +69,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.0", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index 319efefd1f..0a236e6dbd 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,17 @@ # trigger.dev +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Fix init.ts in custom trigger dirs ([#1914](https://github.com/triggerdotdev/trigger.dev/pull/1914)) +- Init command will now correctly install v4-beta packages ([#1914](https://github.com/triggerdotdev/trigger.dev/pull/1914)) +- Update nypm package to support test-based bun.lock files ([#1914](https://github.com/triggerdotdev/trigger.dev/pull/1914)) +- Handle flush errors gracefully in dev ([#1914](https://github.com/triggerdotdev/trigger.dev/pull/1914)) +- Updated dependencies: + - `@trigger.dev/build@4.0.0-v4-beta.1` + - `@trigger.dev/core@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 352fb43f88..88b706a904 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "A Command-Line Interface for Trigger.dev (v3) projects", "type": "module", "license": "MIT", @@ -89,8 +89,8 @@ "@opentelemetry/sdk-trace-base": "1.25.1", "@opentelemetry/sdk-trace-node": "1.25.1", "@opentelemetry/semantic-conventions": "1.25.1", - "@trigger.dev/build": "workspace:4.0.0-v4-beta.0", - "@trigger.dev/core": "workspace:4.0.0-v4-beta.0", + "@trigger.dev/build": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", "c12": "^1.11.1", "chalk": "^5.2.0", "chokidar": "^3.6.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 5bef7c5646..df27f07bc6 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # internal-platform +## 4.0.0-v4-beta.1 + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/core/package.json b/packages/core/package.json index 768c709d94..f05d4abe01 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "publishConfig": { diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index 44af22c1ca..7788b36f14 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/build@4.0.0-v4-beta.1` + - `@trigger.dev/core@4.0.0-v4-beta.1` + - `@trigger.dev/sdk@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/python/package.json b/packages/python/package.json index e0b2e68e08..ba33d490bc 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/python", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "Python runtime and build extension for Trigger.dev", "license": "MIT", "publishConfig": { @@ -45,7 +45,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.0", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", "tinyexec": "^0.3.2" }, "devDependencies": { @@ -56,12 +56,12 @@ "tsx": "4.17.0", "esbuild": "^0.23.0", "@arethetypeswrong/cli": "^0.15.4", - "@trigger.dev/build": "workspace:4.0.0-v4-beta.0", - "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.0" + "@trigger.dev/build": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.1" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.0", - "@trigger.dev/build": "workspace:^4.0.0-v4-beta.0" + "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.1", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.1" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index 49a11ce18c..3bfb48ccd8 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react-hooks +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index ab95411ff2..f31815d7de 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react-hooks", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "trigger.dev react hooks", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.0.0-v4-beta.0", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.1", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index f69529cfe3..f911463564 100644 --- a/packages/redis-worker/CHANGELOG.md +++ b/packages/redis-worker/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/redis-worker +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 5e7801ec23..b5149f0b9d 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/redis-worker", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "Redis worker for trigger.dev", "license": "MIT", "publishConfig": { @@ -23,7 +23,7 @@ "test": "vitest --sequence.concurrent=false --no-file-parallelism" }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.0", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", "lodash.omit": "^4.5.0", "nanoid": "^5.0.7", "p-limit": "^6.2.0", diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md index e8aa1036c4..5e92fbd92a 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index 3313790cab..03f53625ac 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/rsc", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "trigger.dev rsc", "license": "MIT", "publishConfig": { @@ -37,14 +37,14 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.0.0-v4-beta.0", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.1", "mlly": "^1.7.1", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.4", - "@trigger.dev/build": "workspace:^4.0.0-v4-beta.0", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.1", "@types/node": "^20.14.14", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index a28d117e16..961cc607fc 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sdk +## 4.0.0-v4-beta.1 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.1` + ## 4.0.0-v4-beta.0 ### Major Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 99686167ae..29384d57f1 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "4.0.0-v4-beta.0", + "version": "4.0.0-v4-beta.1", "description": "trigger.dev Node.JS SDK", "license": "MIT", "publishConfig": { @@ -52,7 +52,7 @@ "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.52.1", "@opentelemetry/semantic-conventions": "1.25.1", - "@trigger.dev/core": "workspace:4.0.0-v4-beta.0", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae295a72e1..850239c5a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1080,7 +1080,7 @@ importers: packages/build: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../core pkg-types: specifier: ^1.1.3 @@ -1156,10 +1156,10 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../build '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../core c12: specifier: ^1.11.1 @@ -1485,7 +1485,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1495,10 +1495,10 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../trigger-sdk '@types/node': specifier: 20.14.14 @@ -1522,7 +1522,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.0 + specifier: workspace:^4.0.0-v4-beta.1 version: link:../core react: specifier: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1556,7 +1556,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../core lodash.omit: specifier: ^4.5.0 @@ -1602,7 +1602,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.0 + specifier: workspace:^4.0.0-v4-beta.1 version: link:../core mlly: specifier: ^1.7.1 @@ -1618,7 +1618,7 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:^4.0.0-v4-beta.0 + specifier: workspace:^4.0.0-v4-beta.1 version: link:../build '@types/node': specifier: ^20.14.14 @@ -1651,7 +1651,7 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.0 + specifier: workspace:4.0.0-v4-beta.1 version: link:../core chalk: specifier: ^5.2.0 @@ -19445,6 +19445,14 @@ packages: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: acorn: 8.12.1 + dev: true + + /acorn-jsx@5.3.2(acorn@8.14.1): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.14.1 /acorn-node@1.8.2: resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} @@ -19487,7 +19495,6 @@ packages: resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} hasBin: true - dev: false /acorn@8.8.1: resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} @@ -21177,7 +21184,7 @@ packages: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 '@types/estree': 1.0.6 - acorn: 8.12.1 + acorn: 8.14.1 estree-walker: 3.0.3 periscopic: 3.1.0 @@ -23703,8 +23710,8 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) eslint-visitor-keys: 3.4.3 /esprima@4.0.1: @@ -32961,7 +32968,7 @@ packages: '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 '@types/estree': 1.0.6 - acorn: 8.12.1 + acorn: 8.14.1 aria-query: 5.3.0 axobject-query: 4.1.0 code-red: 1.0.4 From eded8027bd2abe5214cd16d76cb5a0735c7682d1 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 13 Apr 2025 21:51:42 +0100 Subject: [PATCH 17/29] Both run engines will only lock to versions they can handle (#1922) * run engine v1 will only lock to v1 deployments * run engine v2 will only lock to managed v2 deployments * test: create background worker and deployment with correct engine version --- .../app/v3/models/workerDeployment.server.ts | 45 ++++++++++++++++++- .../run-engine/src/engine/db/worker.ts | 41 +++++++++++++++-- .../run-engine/src/engine/tests/setup.ts | 2 + 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/v3/models/workerDeployment.server.ts b/apps/webapp/app/v3/models/workerDeployment.server.ts index 74e3fd7b34..b4669a9cc7 100644 --- a/apps/webapp/app/v3/models/workerDeployment.server.ts +++ b/apps/webapp/app/v3/models/workerDeployment.server.ts @@ -71,6 +71,7 @@ export async function findCurrentWorkerDeployment( id: true, imageReference: true, version: true, + type: true, worker: { select: { id: true, @@ -88,7 +89,49 @@ export async function findCurrentWorkerDeployment( }, }); - return promotion?.deployment; + if (!promotion) { + return undefined; + } + + if (promotion.deployment.type === "V1") { + // This is a run engine v1 deployment, so return it + return promotion.deployment; + } + + // We need to get the latest run engine v1 deployment + const latestV1Deployment = await prisma.workerDeployment.findFirst({ + where: { + environmentId, + type: "V1", + }, + orderBy: { + id: "desc", + }, + select: { + id: true, + imageReference: true, + version: true, + type: true, + worker: { + select: { + id: true, + friendlyId: true, + version: true, + sdkVersion: true, + cliVersion: true, + supportsLazyAttempts: true, + tasks: true, + engine: true, + }, + }, + }, + }); + + if (!latestV1Deployment) { + return undefined; + } + + return latestV1Deployment; } export async function getCurrentWorkerDeploymentEngineVersion( diff --git a/internal-packages/run-engine/src/engine/db/worker.ts b/internal-packages/run-engine/src/engine/db/worker.ts index 34abf2cd32..701987581d 100644 --- a/internal-packages/run-engine/src/engine/db/worker.ts +++ b/internal-packages/run-engine/src/engine/db/worker.ts @@ -289,10 +289,43 @@ export async function getWorkerFromCurrentlyPromotedDeployment( return null; } + if (promotion.deployment.type === "MANAGED") { + // This is a run engine v2 deployment, so return it + return { + worker: promotion.deployment.worker, + tasks: promotion.deployment.worker.tasks, + queues: promotion.deployment.worker.queues, + deployment: promotion.deployment, + }; + } + + // We need to get the latest run engine v2 deployment + const latestV2Deployment = await prisma.workerDeployment.findFirst({ + where: { + environmentId, + type: "MANAGED", + }, + orderBy: { + id: "desc", + }, + include: { + worker: { + include: { + tasks: true, + queues: true, + }, + }, + }, + }); + + if (!latestV2Deployment?.worker) { + return null; + } + return { - worker: promotion.deployment.worker, - tasks: promotion.deployment.worker.tasks, - queues: promotion.deployment.worker.queues, - deployment: promotion.deployment, + worker: latestV2Deployment.worker, + tasks: latestV2Deployment.worker.tasks, + queues: latestV2Deployment.worker.queues, + deployment: latestV2Deployment, }; } diff --git a/internal-packages/run-engine/src/engine/tests/setup.ts b/internal-packages/run-engine/src/engine/tests/setup.ts index be8b331dbe..a666107f7c 100644 --- a/internal-packages/run-engine/src/engine/tests/setup.ts +++ b/internal-packages/run-engine/src/engine/tests/setup.ts @@ -97,6 +97,7 @@ export async function setupBackgroundWorker( runtimeEnvironmentId: environment.id, version: nextVersion, metadata: {}, + engine: "V2", }, }); @@ -234,6 +235,7 @@ export async function setupBackgroundWorker( projectId: environment.project.id, environmentId: environment.id, workerId: worker.id, + type: "MANAGED", }, }); From 2af4178ed64756f353f2c37252f2a5581832bd8b Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:43:52 +0100 Subject: [PATCH 18/29] Add links to and from deployments (#1921) * link from deployments tasks to filtered runs view * jump to deployment * don't add version links for dev (yet) --- .../v3/DeploymentListPresenter.server.ts | 48 ++++++++++++++++++ .../route.tsx | 9 ++-- .../route.tsx | 50 ++++++++++++++++--- ...projectRef.deployments.$deploymentParam.ts | 2 +- .../route.tsx | 22 +++++++- apps/webapp/app/utils/pathBuilder.ts | 9 ++++ 6 files changed, 129 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index f7dbfbc75c..b044cbe070 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -168,4 +168,52 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; }), }; } + + public async findPageForVersion({ + userId, + projectSlug, + organizationSlug, + environmentSlug, + version, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + environmentSlug: string; + version: string; + }) { + const project = await this.#prismaClient.project.findFirstOrThrow({ + select: { + id: true, + }, + where: { + slug: projectSlug, + organization: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, + }, + }); + + const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId); + if (!environment) { + throw new Error(`Environment not found`); + } + + // Find how many deployments have been made since this version + const deploymentsSinceVersion = await this.#prismaClient.$queryRaw<{ count: BigInt }[]>` + SELECT COUNT(*) as count + FROM ${sqlDatabaseSchema}."WorkerDeployment" + WHERE "projectId" = ${project.id} + AND "environmentId" = ${environment.id} + AND string_to_array(version, '.')::int[] > string_to_array(${version}, '.')::int[] + `; + + const count = Number(deploymentsSinceVersion[0].count); + return Math.floor(count / pageSize) + 1; + } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index ff0ccbed92..1ee6599513 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -28,7 +28,7 @@ import { useUser } from "~/hooks/useUser"; import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; -import { v3DeploymentParams, v3DeploymentsPath } from "~/utils/pathBuilder"; +import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder"; import { capitalizeWord } from "~/utils/string"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -231,16 +231,19 @@ export default function Page() { {deployment.tasks.map((t) => { + const path = v3RunsPath(organization, project, environment, { + tasks: [t.slug], + }); return ( - +
{t.slug}
- {t.filePath} + {t.filePath}
); })} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index a22a6ad9b8..ef8ababe36 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -1,12 +1,11 @@ import { ArrowPathIcon, ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react"; +import { type MetaFunction, Outlet, useLocation, useParams, useNavigate } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PromoteIcon } from "~/assets/icons/PromoteIcon"; import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; import { UserAvatar } from "~/components/UserProfilePhoto"; -import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -53,6 +52,7 @@ import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/path import { createSearchParams } from "~/utils/searchParams"; import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; +import { useEffect } from "react"; export const meta: MetaFunction = () => { return [ @@ -64,6 +64,7 @@ export const meta: MetaFunction = () => { const SearchParams = z.object({ page: z.coerce.number().optional(), + version: z.string().optional(), }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -71,10 +72,29 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const searchParams = createSearchParams(request.url, SearchParams); - const page = searchParams.success ? searchParams.params.get("page") ?? 1 : 1; + + let page = searchParams.success ? Number(searchParams.params.get("page") ?? 1) : 1; + const version = searchParams.success ? searchParams.params.get("version")?.toString() : undefined; + + const presenter = new DeploymentListPresenter(); + + // If we have a version, find its page + if (version) { + try { + page = await presenter.findPageForVersion({ + userId, + organizationSlug, + projectSlug: projectParam, + environmentSlug: envParam, + version, + }); + } catch (error) { + console.error("Error finding page for version", error); + // Carry on, we'll just show the selected page + } + } try { - const presenter = new DeploymentListPresenter(); const result = await presenter.call({ userId, organizationSlug, @@ -83,7 +103,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { page, }); - return typedjson(result); + // If we have a version, find the deployment + const selectedDeployment = version + ? result.deployments.find((d) => d.version === version) + : undefined; + + return typedjson({ ...result, selectedDeployment }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -97,10 +122,23 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { deployments, currentPage, totalPages } = useTypedLoaderData(); + const { deployments, currentPage, totalPages, selectedDeployment } = + useTypedLoaderData(); const hasDeployments = totalPages > 0; const { deploymentParam } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + + // If we have a selected deployment from the version param, show it + useEffect(() => { + if (selectedDeployment && !deploymentParam) { + const searchParams = new URLSearchParams(location.search); + searchParams.delete("version"); + searchParams.set("page", currentPage.toString()); + navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`); + } + }, [selectedDeployment, deploymentParam, location.search]); const currentDeployment = deployments.find((d) => d.isCurrent); diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts b/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts index e4f83a13ad..c05ad19c68 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts @@ -33,7 +33,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return new Response("Not found", { status: 404 }); } - // Redirect to the project's runs page + // Redirect to the project's deployments page return redirect( `/orgs/${project.organization.slug}/projects/${project.slug}/deployments/${validatedParams.deploymentParam}` ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index e5e21e5ae7..ca37c76d12 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -59,6 +59,7 @@ import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import { docsPath, v3BatchPath, + v3DeploymentVersionPath, v3RunDownloadLogsPath, v3RunPath, v3RunSpanPath, @@ -527,7 +528,26 @@ function RunBody({ Version {run.version ? ( - run.version + environment.type === "DEVELOPMENT" ? ( + run.version + ) : ( + + {run.version} + + } + content={"Jump to deployment"} + /> + ) ) : ( Never started diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index dc6b392dcf..6996eff9d7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -395,6 +395,15 @@ export function v3DeploymentPath( return `${v3DeploymentsPath(organization, project, environment)}/${deployment.shortCode}${query}`; } +export function v3DeploymentVersionPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + version: string +) { + return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`; +} + export function v3BillingPath(organization: OrgForPath, message?: string) { return `${organizationPath(organization)}/settings/billing${ message ? `?message=${encodeURIComponent(message)}` : "" From ff602fc150396620558a25163c16f5c35b9ba666 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:55:27 +0100 Subject: [PATCH 19/29] Fix current worker deployment getter (#1924) * only return last v1 deployment in the shared queue consumer * be explicit about only returning managed deployments --- .../app/presenters/v3/TestPresenter.server.ts | 2 +- .../presenters/v3/TestTaskPresenter.server.ts | 2 +- .../v3/marqs/sharedQueueConsumer.server.ts | 5 +- .../app/v3/models/workerDeployment.server.ts | 51 ++++++++++++++----- .../services/triggerScheduledTask.server.ts | 4 +- .../run-engine/src/engine/db/worker.ts | 4 +- 6 files changed, 48 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index 23c181a8cb..af5bb93a7e 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -56,7 +56,7 @@ export class TestPresenter extends BasePresenter { JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id ORDER BY slug ASC;`; } else { - const currentDeployment = await findCurrentWorkerDeployment(envId); + const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId }); return currentDeployment?.worker?.tasks ?? []; } } diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index f58a613a75..2519231afe 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -88,7 +88,7 @@ export class TestTaskPresenter { }: TestTaskOptions): Promise { let task: BackgroundWorkerTaskSlim | null = null; if (environment.type !== "DEVELOPMENT") { - const deployment = await findCurrentWorkerDeployment(environment.id); + const deployment = await findCurrentWorkerDeployment({ environmentId: environment.id }); if (deployment) { task = deployment.worker?.tasks.find((t) => t.slug === taskIdentifier) ?? null; } diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index c13e5062a0..5926002c91 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -612,7 +612,10 @@ export class SharedQueueConsumer { ? await getWorkerDeploymentFromWorkerTask(existingTaskRun.lockedById) : existingTaskRun.lockedToVersionId ? await getWorkerDeploymentFromWorker(existingTaskRun.lockedToVersionId) - : await findCurrentWorkerDeployment(existingTaskRun.runtimeEnvironmentId); + : await findCurrentWorkerDeployment({ + environmentId: existingTaskRun.runtimeEnvironmentId, + type: "V1", + }); }); const worker = deployment?.worker; diff --git a/apps/webapp/app/v3/models/workerDeployment.server.ts b/apps/webapp/app/v3/models/workerDeployment.server.ts index b4669a9cc7..12629b274d 100644 --- a/apps/webapp/app/v3/models/workerDeployment.server.ts +++ b/apps/webapp/app/v3/models/workerDeployment.server.ts @@ -1,5 +1,5 @@ import type { Prettify } from "@trigger.dev/core"; -import { BackgroundWorker, RunEngineVersion, WorkerDeployment } from "@trigger.dev/database"; +import { BackgroundWorker, RunEngineVersion, WorkerDeploymentType } from "@trigger.dev/database"; import { CURRENT_DEPLOYMENT_LABEL, CURRENT_UNMANAGED_DEPLOYMENT_LABEL, @@ -56,10 +56,23 @@ type WorkerDeploymentWithWorkerTasks = Prisma.WorkerDeploymentGetPayload<{ }; }>; -export async function findCurrentWorkerDeployment( - environmentId: string, - label = CURRENT_DEPLOYMENT_LABEL -): Promise { +/** + * Finds the current worker deployment for a given environment. + * + * @param environmentId - The ID of the environment to find the current worker deployment for. + * @param label - The label of the current worker deployment to find. + * @param type - The type of worker deployment to find. If the current deployment is NOT of this type, + * we will return the latest deployment of the given type. + */ +export async function findCurrentWorkerDeployment({ + environmentId, + label = CURRENT_DEPLOYMENT_LABEL, + type, +}: { + environmentId: string; + label?: string; + type?: WorkerDeploymentType; +}): Promise { const promotion = await prisma.workerDeploymentPromotion.findFirst({ where: { environmentId, @@ -93,16 +106,19 @@ export async function findCurrentWorkerDeployment( return undefined; } - if (promotion.deployment.type === "V1") { - // This is a run engine v1 deployment, so return it + if (!type) { return promotion.deployment; } - // We need to get the latest run engine v1 deployment - const latestV1Deployment = await prisma.workerDeployment.findFirst({ + if (promotion.deployment.type === type) { + return promotion.deployment; + } + + // We need to get the latest deployment of the given type + const latestDeployment = await prisma.workerDeployment.findFirst({ where: { environmentId, - type: "V1", + type, }, orderBy: { id: "desc", @@ -127,11 +143,11 @@ export async function findCurrentWorkerDeployment( }, }); - if (!latestV1Deployment) { + if (!latestDeployment) { return undefined; } - return latestV1Deployment; + return latestDeployment; } export async function getCurrentWorkerDeploymentEngineVersion( @@ -162,7 +178,11 @@ export async function getCurrentWorkerDeploymentEngineVersion( export async function findCurrentUnmanagedWorkerDeployment( environmentId: string ): Promise { - return await findCurrentWorkerDeployment(environmentId, CURRENT_UNMANAGED_DEPLOYMENT_LABEL); + return await findCurrentWorkerDeployment({ + environmentId, + label: CURRENT_UNMANAGED_DEPLOYMENT_LABEL, + type: "UNMANAGED", + }); } export async function findCurrentWorkerFromEnvironment( @@ -183,7 +203,10 @@ export async function findCurrentWorkerFromEnvironment( }); return latestDevWorker; } else { - const deployment = await findCurrentWorkerDeployment(environment.id, label); + const deployment = await findCurrentWorkerDeployment({ + environmentId: environment.id, + label, + }); return deployment?.worker ?? null; } } diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts index b2a56f78cc..f2d40725a7 100644 --- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts +++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts @@ -73,7 +73,9 @@ export class TriggerScheduledTaskService extends BaseService { if (instance.environment.type !== "DEVELOPMENT") { // Get the current backgroundWorker for this environment - const currentWorkerDeployment = await findCurrentWorkerDeployment(instance.environment.id); + const currentWorkerDeployment = await findCurrentWorkerDeployment({ + environmentId: instance.environment.id, + }); if (!currentWorkerDeployment) { logger.debug("No current worker deployment found, skipping task trigger", { diff --git a/internal-packages/run-engine/src/engine/db/worker.ts b/internal-packages/run-engine/src/engine/db/worker.ts index 701987581d..8bc2817d63 100644 --- a/internal-packages/run-engine/src/engine/db/worker.ts +++ b/internal-packages/run-engine/src/engine/db/worker.ts @@ -100,7 +100,7 @@ export async function getRunWithBackgroundWorkerTasks( } else { workerWithTasks = workerId ? await getWorkerDeploymentFromWorker(prisma, workerId) - : await getWorkerFromCurrentlyPromotedDeployment(prisma, run.runtimeEnvironmentId); + : await getManagedWorkerFromCurrentlyPromotedDeployment(prisma, run.runtimeEnvironmentId); } if (!workerWithTasks) { @@ -260,7 +260,7 @@ export async function getWorkerById( return { worker, tasks: worker.tasks, queues: worker.queues, deployment: worker.deployment }; } -export async function getWorkerFromCurrentlyPromotedDeployment( +export async function getManagedWorkerFromCurrentlyPromotedDeployment( prisma: PrismaClientOrTransaction, environmentId: string ): Promise { From e1f338d94f53ca177ea3e5c609dfce45d6fbfcf1 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Mon, 14 Apr 2025 21:49:47 +0200 Subject: [PATCH 20/29] Add a docs page for the human-in-the-loop example project (#1919) * Add a docs page for the human-in-the-loop example project * Order guides, example projects and example tasks alphabetically in the docs list --- docs/docs.json | 5 +- .../human-in-the-loop-workflow.mdx | 91 +++++++++++++++++++ docs/guides/introduction.mdx | 31 ++++--- 3 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 docs/guides/example-projects/human-in-the-loop-workflow.mdx diff --git a/docs/docs.json b/docs/docs.json index c3dabb4e76..d4ce73664c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -320,9 +320,10 @@ "pages": [ "guides/example-projects/batch-llm-evaluator", "guides/example-projects/claude-thinking-chatbot", - "guides/example-projects/turborepo-monorepo-prisma", - "guides/example-projects/realtime-fal-ai", + "guides/example-projects/human-in-the-loop-workflow", "guides/example-projects/realtime-csv-importer", + "guides/example-projects/realtime-fal-ai", + "guides/example-projects/turborepo-monorepo-prisma", "guides/example-projects/vercel-ai-sdk-image-generator" ] }, diff --git a/docs/guides/example-projects/human-in-the-loop-workflow.mdx b/docs/guides/example-projects/human-in-the-loop-workflow.mdx new file mode 100644 index 0000000000..6975b1bbe2 --- /dev/null +++ b/docs/guides/example-projects/human-in-the-loop-workflow.mdx @@ -0,0 +1,91 @@ +--- +title: "Human-in-the-loop workflow with ReactFlow and Trigger.dev waitpoint tokens" +sidebarTitle: "Human-in-the-loop workflow" +description: "This example project creates audio summaries of newspaper articles using a human-in-the-loop workflow built with ReactFlow and Trigger.dev waitpoint tokens." +--- + +import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; + +## Overview + +This demo is a full stack example that uses the following: + +- [Next.js](https://nextjs.org/) for the web application +- [ReactFlow](https://reactflow.dev/) for the workflow UI +- [Trigger.dev Realtime](/realtime/overview) to subscribe to task runs and show the real-time status of the workflow steps +- [Trigger.dev waitpoint tokens](/wait-for-token) to create a human-in-the-loop flow with a review step +- [OpenAI API](https://openai.com/api/) to generate article summaries +- [ElevenLabs](https://elevenlabs.io/text-to-speech) to convert text to speech + +## GitHub repo + + + Click here to view the full code for this project in our examples repository on GitHub. You can + fork it and use it as a starting point for your own project. + + +## Video + + + +## Relevant code + +Each node in the workflow corresponds to a Trigger.dev task. The idea is to enable building flows by composition of different tasks. The output of one task serves as input for another. + +- **Trigger.dev task splitting**: + - The [summarizeArticle](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/summarizeArticle.ts) task uses the OpenAI API to generate a summary an article. + - The [convertTextToSpeech](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/convertTextToSpeech.ts) task uses the ElevenLabs API to convert the summary into an audio stream and upload it to an S3 bucket. + - The [reviewSummary](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/reviewSummary.ts) task is a human-in-the-loop step that shows the result and waits for approval of the summary before continuing. + - [articleWorkflow](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/articleWorkflow.ts) is the entrypoint that ties the workflow together and orchestrates the tasks. You might choose to approach the orchestration differently, depending on your use case. +- **ReactFlow Nodes**: there are three types of nodes in this example. All of them are custom ReactFlow nodes. + - The [InputNode](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/InputNode.tsx) is the starting node of the workflow. It triggers the workflow by submitting an article URL. + - The [ActionNode](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/ActionNode.tsx) is a node that shows the status of a task run in Trigger.dev, in real-time using the React hooks for Trigger.dev. + - The [ReviewNode](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/ReviewNode.tsx) is a node that shows the summary result and prompts the user for approval before continuing. It uses the Realtime API to fetch details about the review status. Also, it interacts with the Trigger.dev waitpoint API for completing the waitpoint token using Next.js server actions. +- **Workflow orchestration**: + - The workflow is orchestrated by the [Flow](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/Flow.tsx) component. It lays out the nodes, the connections between them, as well as the mapping to the Trigger.dev tasks. + It also uses the `useRealtimeRunsWithTag` hook to subscribe to task runs associated with the workflow and passes down the run details to the nodes. + +The waitpoint token is created in [a Next.js server action](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/app/actions.ts#L26): + +```ts +const reviewWaitpointToken = await wait.createToken({ + tags: [workflowTag], + timeout: "1h", + idempotencyKey: `review-summary-${workflowTag}`, +}); +``` + +and later completed in another server action in the same file: + +```ts +await wait.completeToken( + { id: tokenId }, + { + approved: true, + approvedAt: new Date(), + approvedBy: user, + } +); +``` + + + + +While the workflow in this example is static and does not allow changing the connections between nodes in the UI, it serves as a good baseline for understanding how to build completely custom workflow builders using Trigger.dev and ReactFlow. + +## Learn more about Trigger.dev Realtime and waitpoint tokens + +To learn more, take a look at the following resources: + +- [Trigger.dev Realtime](/realtime) - learn more about how to subscribe to runs and get real-time updates +- [Realtime streaming](/realtime/streams) - learn more about streaming data from your tasks +- [React hooks](/frontend/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API +- [Waitpoint tokens](/wait-for-token) - learn about waitpoint tokens in Trigger.dev and human-in-the-loop flows diff --git a/docs/guides/introduction.mdx b/docs/guides/introduction.mdx index 5cb1d49b3b..8764e7086a 100644 --- a/docs/guides/introduction.mdx +++ b/docs/guides/introduction.mdx @@ -21,35 +21,36 @@ Get set up fast using our detailed walk-through guides. | Guide | Description | | :----------------------------------------------------------------------------------------- | :------------------------------------------------------------------- | -| [AI Agent: Generate and translate copy](/guides/ai-agents/generate-translate-copy) | Chain prompts to generate and translate content | -| [AI Agent: Route questions](/guides/ai-agents/route-question) | Route questions to different models based on complexity | | [AI Agent: Content moderation](/guides/ai-agents/respond-and-check-content) | Parallel check content while responding to customers | +| [AI Agent: Generate and translate copy](/guides/ai-agents/generate-translate-copy) | Chain prompts to generate and translate content | | [AI Agent: News verification](/guides/ai-agents/verify-news-article) | Orchestrate fact checking of news articles | +| [AI Agent: Route questions](/guides/ai-agents/route-question) | Route questions to different models based on complexity | | [AI Agent: Translation refinement](/guides/ai-agents/translate-and-refine) | Evaluate and refine translations with feedback | | [Prisma](/guides/frameworks/prisma) | How to setup Prisma with Trigger.dev | | [Python image processing](/guides/python/python-image-processing) | Use Python and Pillow to process images | -| [Python web crawler](/guides/python/python-crawl4ai) | Use Python, Crawl4AI and Playwright to create a headless web crawler | | [Python PDF form extractor](/guides/python/python-pdf-form-extractor) | Use Python, PyMuPDF and Trigger.dev to extract data from a PDF form | +| [Python web crawler](/guides/python/python-crawl4ai) | Use Python, Crawl4AI and Playwright to create a headless web crawler | | [Sequin database triggers](/guides/frameworks/sequin) | Trigger tasks from database changes using Sequin | -| [Supabase edge function hello world](/guides/frameworks/supabase-edge-functions-basic) | Trigger tasks from Supabase edge function | +| [Stripe webhooks](/guides/examples/stripe-webhook) | Trigger tasks from incoming Stripe webhook events | | [Supabase database webhooks](/guides/frameworks/supabase-edge-functions-database-webhooks) | Trigger tasks using Supabase database webhooks | +| [Supabase edge function hello world](/guides/frameworks/supabase-edge-functions-basic) | Trigger tasks from Supabase edge function | | [Using webhooks in Next.js](/guides/frameworks/nextjs-webhooks) | Trigger tasks from a webhook in Next.js | | [Using webhooks in Remix](/guides/frameworks/remix-webhooks) | Trigger tasks from a webhook in Remix | -| [Stripe webhooks](/guides/examples/stripe-webhook) | Trigger tasks from incoming Stripe webhook events | ## Example projects Example projects are full projects with example repos you can fork and use. These are a great way of learning how to encorporate Trigger.dev into your project. -| Example project | Description | Framework | GitHub | -| :-------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------- | :-------- | :------------------------------------------------------------------------------------------------------------ | -| [Batch LLM Evaluator](/guides/example-projects/batch-llm-evaluator) | Evaluate multiple LLM models and stream the results to the frontend. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/batch-llm-evaluator) | -| [Claude thinking chatbot](/guides/example-projects/claude-thinking-chatbot) | Use Vercel's AI SDK and Anthropic's Claude 3.7 model to create a thinking chatbot. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/claude-thinking-chatbot) | -| [Turborepo monorepo with Prisma](/guides/example-projects/turborepo-monorepo-prisma) | Use Prisma in a Turborepo monorepo with Trigger.dev. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package) | -| [Realtime Fal.ai image generation](/guides/example-projects/realtime-fal-ai) | Generate an image from a prompt using Fal.ai and show the progress of the task on the frontend using Realtime. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation) | -| [Realtime CSV Importer](/guides/example-projects/realtime-csv-importer) | Upload a CSV file and see the progress of the task streamed to the frontend. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-csv-importer) | -| [Vercel AI SDK image generator](/guides/example-projects/vercel-ai-sdk-image-generator) | Use the Vercel AI SDK to generate images from a prompt. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator) | -| [Python web crawler](/guides/python/python-crawl4ai) | Use Python, Crawl4AI and Playwright to create a headless web crawler with Trigger.dev. | — | [View the repo](https://github.com/triggerdotdev/examples/tree/main/python-crawl4ai) | +| Example project | Description | Framework | GitHub | +| :-------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | :-------- | :------------------------------------------------------------------------------------------------------------ | +| [Batch LLM Evaluator](/guides/example-projects/batch-llm-evaluator) | Evaluate multiple LLM models and stream the results to the frontend. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/batch-llm-evaluator) | +| [Claude thinking chatbot](/guides/example-projects/claude-thinking-chatbot) | Use Vercel's AI SDK and Anthropic's Claude 3.7 model to create a thinking chatbot. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/claude-thinking-chatbot) | +| [Human-in-the-loop workflow](/guides/example-projects/human-in-the-loop-workflow) | Create audio summaries of newspaper articles using a human-in-the-loop workflow built with ReactFlow and Trigger.dev waitpoint tokens. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/article-summary-workflow) | +| [Python web crawler](/guides/python/python-crawl4ai) | Use Python, Crawl4AI and Playwright to create a headless web crawler with Trigger.dev. | — | [View the repo](https://github.com/triggerdotdev/examples/tree/main/python-crawl4ai) | +| [Realtime CSV Importer](/guides/example-projects/realtime-csv-importer) | Upload a CSV file and see the progress of the task streamed to the frontend. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-csv-importer) | +| [Realtime Fal.ai image generation](/guides/example-projects/realtime-fal-ai) | Generate an image from a prompt using Fal.ai and show the progress of the task on the frontend using Realtime. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation) | +| [Turborepo monorepo with Prisma](/guides/example-projects/turborepo-monorepo-prisma) | Use Prisma in a Turborepo monorepo with Trigger.dev. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package) | +| [Vercel AI SDK image generator](/guides/example-projects/vercel-ai-sdk-image-generator) | Use the Vercel AI SDK to generate images from a prompt. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator) | ## Example tasks @@ -66,9 +67,9 @@ Task code you can copy and paste to use in your project. They can all be extende | [LibreOffice PDF conversion](/guides/examples/libreoffice-pdf-conversion) | Convert a document to PDF using LibreOffice. | | [OpenAI with retrying](/guides/examples/open-ai-with-retrying) | Create a reusable OpenAI task with custom retry options. | | [PDF to image](/guides/examples/pdf-to-image) | Use `MuPDF` to turn a PDF into images and save them to Cloudflare R2. | -| [React to PDF](/guides/examples/react-pdf) | Use `react-pdf` to generate a PDF and save it to Cloudflare R2. | | [Puppeteer](/guides/examples/puppeteer) | Use Puppeteer to generate a PDF or scrape a webpage. | | [React email](/guides/examples/react-email) | Send an email using React Email. | +| [React to PDF](/guides/examples/react-pdf) | Use `react-pdf` to generate a PDF and save it to Cloudflare R2. | | [Resend email sequence](/guides/examples/resend-email-sequence) | Send a sequence of emails over several days using Resend with Trigger.dev. | | [Satori](/guides/examples/satori) | Generate OG images using React Satori. | | [Scrape Hacker News](/guides/examples/scrape-hacker-news) | Scrape Hacker News using BrowserBase and Puppeteer, summarize the articles with ChatGPT and send an email of the summary every weekday using Resend. | From 02a8713ae5d592271744fbedf7fa94ddd27834d0 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:08:51 +0100 Subject: [PATCH 21/29] Managed run controller revamp (#1927) * update nypm to support text-based bun lockfiles * fix retry spans * only download debug logs if admin * add nypm changeset * pull out env override logic * use runner env gather helper * handle dev flushing failures gracefully * fix path normalization for init.ts * add logger * add execution heartbeat service * add snapshot poller service * fix poller * add changesets * create socket in constructor * enable strictPropertyInitialization * deprecate dequeue from version * start is not async * dependency injection in prep for tests * add warm start count to all controller logs * add restore count * pull out run execution logic * temp disable pre * add a controller log when starting an execution * refactor execution and squash some bugs * cleanup completed docker containers by default * execution fixes and logging improvements * don't throw afet abort cleanup * poller should use private interval * rename heartbeat service file * rename HeartbeatService to IntervalService * restore old heartbeat service but deprecate it * use the new interval service everywhere * Revert "temp disable pre" This reverts commit e03f4179de6a731c17253b68a6e00bcb7ac1736b. * add changeset * replace all run engine find uniques with find first --- .changeset/tricky-houses-invite.md | 6 + .configs/tsconfig.base.json | 2 +- apps/supervisor/src/env.ts | 1 + apps/supervisor/src/index.ts | 1 + apps/supervisor/src/services/podCleaner.ts | 12 +- apps/supervisor/src/workloadManager/docker.ts | 4 + apps/supervisor/src/workloadManager/types.ts | 1 + apps/supervisor/src/workloadServer/index.ts | 2 +- .../authenticatedSocketConnection.server.ts | 8 +- .../run-engine/src/engine/db/worker.ts | 10 +- .../src/engine/systems/batchSystem.ts | 2 +- .../src/engine/systems/runAttemptSystem.ts | 10 +- .../src/engine/systems/ttlSystem.ts | 2 +- .../src/engine/systems/waitpointSystem.ts | 16 +- packages/cli-v3/e2e/utils.ts | 22 +- packages/cli-v3/src/dev/devSupervisor.ts | 34 +- .../src/entryPoints/dev-run-controller.ts | 16 +- .../src/entryPoints/managed-run-controller.ts | 1691 +---------------- .../src/entryPoints/managed/controller.ts | 552 ++++++ .../cli-v3/src/entryPoints/managed/env.ts | 223 +++ .../src/entryPoints/managed/execution.ts | 923 +++++++++ .../src/entryPoints/managed/heartbeat.ts | 92 + .../cli-v3/src/entryPoints/managed/logger.ts | 52 + .../src/entryPoints/managed/overrides.ts | 29 + .../cli-v3/src/entryPoints/managed/poller.ts | 121 ++ .../cli-v3/src/executions/taskRunProcess.ts | 6 +- packages/core/src/utils.ts | 8 +- packages/core/src/v3/index.ts | 1 + packages/core/src/v3/machines/index.ts | 20 +- .../src/v3/runEngineWorker/supervisor/http.ts | 1 + .../v3/runEngineWorker/supervisor/session.ts | 12 +- .../src/v3/runEngineWorker/workload/http.ts | 1 + packages/core/src/v3/schemas/common.ts | 2 + packages/core/src/v3/utils/heartbeat.ts | 3 + packages/core/src/v3/utils/interval.ts | 95 + 35 files changed, 2213 insertions(+), 1768 deletions(-) create mode 100644 .changeset/tricky-houses-invite.md create mode 100644 packages/cli-v3/src/entryPoints/managed/controller.ts create mode 100644 packages/cli-v3/src/entryPoints/managed/env.ts create mode 100644 packages/cli-v3/src/entryPoints/managed/execution.ts create mode 100644 packages/cli-v3/src/entryPoints/managed/heartbeat.ts create mode 100644 packages/cli-v3/src/entryPoints/managed/logger.ts create mode 100644 packages/cli-v3/src/entryPoints/managed/overrides.ts create mode 100644 packages/cli-v3/src/entryPoints/managed/poller.ts create mode 100644 packages/core/src/v3/utils/interval.ts diff --git a/.changeset/tricky-houses-invite.md b/.changeset/tricky-houses-invite.md new file mode 100644 index 0000000000..e21e7b5818 --- /dev/null +++ b/.changeset/tricky-houses-invite.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Managed run controller performance and reliability improvements diff --git a/.configs/tsconfig.base.json b/.configs/tsconfig.base.json index 3ce4c2db29..2d560d22d0 100644 --- a/.configs/tsconfig.base.json +++ b/.configs/tsconfig.base.json @@ -10,7 +10,7 @@ "strict": true, "alwaysStrict": true, - "strictPropertyInitialization": false, + "strictPropertyInitialization": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noUnusedLocals": false, diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index d7caccbd80..72498075cd 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -27,6 +27,7 @@ const Env = z.object({ RUNNER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().optional(), RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().optional(), RUNNER_ADDITIONAL_ENV_VARS: AdditionalEnvVars, // optional (csv) + RUNNER_DOCKER_AUTOREMOVE: BoolEnv.default(true), // Dequeue settings (provider mode) TRIGGER_DEQUEUE_ENABLED: BoolEnv.default("true"), diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index 350b81e3ff..811ee8746d 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -66,6 +66,7 @@ class ManagedSupervisor { heartbeatIntervalSeconds: env.RUNNER_HEARTBEAT_INTERVAL_SECONDS, snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS, additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS, + dockerAutoremove: env.RUNNER_DOCKER_AUTOREMOVE, } satisfies WorkloadManagerOptions; if (this.isKubernetes) { diff --git a/apps/supervisor/src/services/podCleaner.ts b/apps/supervisor/src/services/podCleaner.ts index e39a98cfbe..56eaaeb88a 100644 --- a/apps/supervisor/src/services/podCleaner.ts +++ b/apps/supervisor/src/services/podCleaner.ts @@ -1,7 +1,7 @@ import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; import { K8sApi } from "../clients/kubernetes.js"; import { createK8sApi } from "../clients/kubernetes.js"; -import { HeartbeatService } from "@trigger.dev/core/v3"; +import { IntervalService } from "@trigger.dev/core/v3"; import { Counter, Gauge, Registry } from "prom-client"; import { register } from "../metrics.js"; @@ -19,7 +19,7 @@ export class PodCleaner { private readonly namespace: string; private readonly batchSize: number; - private readonly deletionHeartbeat: HeartbeatService; + private readonly deletionInterval: IntervalService; // Metrics private readonly register: Registry; @@ -32,10 +32,10 @@ export class PodCleaner { this.namespace = opts.namespace; this.batchSize = opts.batchSize ?? 500; - this.deletionHeartbeat = new HeartbeatService({ + this.deletionInterval = new IntervalService({ intervalMs: opts.intervalMs ?? 10000, leadingEdge: true, - heartbeat: this.deleteCompletedPods.bind(this), + onInterval: this.deleteCompletedPods.bind(this), }); // Initialize metrics @@ -57,11 +57,11 @@ export class PodCleaner { } async start() { - this.deletionHeartbeat.start(); + this.deletionInterval.start(); } async stop() { - this.deletionHeartbeat.stop(); + this.deletionInterval.stop(); } private async deleteCompletedPods() { diff --git a/apps/supervisor/src/workloadManager/docker.ts b/apps/supervisor/src/workloadManager/docker.ts index 9e4ba29594..171e2c0971 100644 --- a/apps/supervisor/src/workloadManager/docker.ts +++ b/apps/supervisor/src/workloadManager/docker.ts @@ -43,6 +43,10 @@ export class DockerWorkloadManager implements WorkloadManager { `--name=${runnerId}`, ]; + if (this.opts.dockerAutoremove) { + runArgs.push("--rm"); + } + if (this.opts.warmStartUrl) { runArgs.push(`--env=TRIGGER_WARM_START_URL=${this.opts.warmStartUrl}`); } diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index a5d7ed3c90..b3cd418f1e 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -10,6 +10,7 @@ export interface WorkloadManagerOptions { heartbeatIntervalSeconds?: number; snapshotPollIntervalSeconds?: number; additionalEnvVars?: Record; + dockerAutoremove?: boolean; } export interface WorkloadManager { diff --git a/apps/supervisor/src/workloadServer/index.ts b/apps/supervisor/src/workloadServer/index.ts index ed90c450c3..2dcf329736 100644 --- a/apps/supervisor/src/workloadServer/index.ts +++ b/apps/supervisor/src/workloadServer/index.ts @@ -452,7 +452,7 @@ export class WorkloadServer extends EventEmitter { logger.debug("runConnected", { ...getSocketMetadata() }); // If there's already a run ID set, we should "disconnect" it from this socket - if (socket.data.runFriendlyId) { + if (socket.data.runFriendlyId && socket.data.runFriendlyId !== friendlyId) { logger.debug("runConnected: disconnecting existing run", { ...getSocketMetadata(), newRunId: friendlyId, diff --git a/apps/webapp/app/v3/authenticatedSocketConnection.server.ts b/apps/webapp/app/v3/authenticatedSocketConnection.server.ts index a6de96b9c9..cd255c800b 100644 --- a/apps/webapp/app/v3/authenticatedSocketConnection.server.ts +++ b/apps/webapp/app/v3/authenticatedSocketConnection.server.ts @@ -1,6 +1,6 @@ import { clientWebsocketMessages, - HeartbeatService, + IntervalService, serverWebsocketMessages, } from "@trigger.dev/core/v3"; import { ZodMessageHandler, ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler"; @@ -19,7 +19,7 @@ export class AuthenticatedSocketConnection { private _sender: ZodMessageSender; private _consumer: DevQueueConsumer; private _messageHandler: ZodMessageHandler; - private _pingService: HeartbeatService; + private _pingService: IntervalService; constructor( public ws: WebSocket, @@ -75,8 +75,8 @@ export class AuthenticatedSocketConnection { // }); }); - this._pingService = new HeartbeatService({ - heartbeat: async () => { + this._pingService = new IntervalService({ + onInterval: async () => { if (ws.readyState !== WebSocket.OPEN) { logger.debug("[AuthenticatedSocketConnection] Websocket not open, skipping ping"); return; diff --git a/internal-packages/run-engine/src/engine/db/worker.ts b/internal-packages/run-engine/src/engine/db/worker.ts index 8bc2817d63..e61e9e8d43 100644 --- a/internal-packages/run-engine/src/engine/db/worker.ts +++ b/internal-packages/run-engine/src/engine/db/worker.ts @@ -193,7 +193,7 @@ export async function getWorkerDeploymentFromWorker( prisma: PrismaClientOrTransaction, workerId: string ): Promise { - const worker = await prisma.backgroundWorker.findUnique({ + const worker = await prisma.backgroundWorker.findFirst({ where: { id: workerId, }, @@ -264,12 +264,10 @@ export async function getManagedWorkerFromCurrentlyPromotedDeployment( prisma: PrismaClientOrTransaction, environmentId: string ): Promise { - const promotion = await prisma.workerDeploymentPromotion.findUnique({ + const promotion = await prisma.workerDeploymentPromotion.findFirst({ where: { - environmentId_label: { - environmentId, - label: CURRENT_DEPLOYMENT_LABEL, - }, + environmentId, + label: CURRENT_DEPLOYMENT_LABEL, }, include: { deployment: { diff --git a/internal-packages/run-engine/src/engine/systems/batchSystem.ts b/internal-packages/run-engine/src/engine/systems/batchSystem.ts index 5f1948a831..8f0a14f4e3 100644 --- a/internal-packages/run-engine/src/engine/systems/batchSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/batchSystem.ts @@ -34,7 +34,7 @@ export class BatchSystem { */ async #tryCompleteBatch({ batchId }: { batchId: string }) { return startSpan(this.$.tracer, "#tryCompleteBatch", async (span) => { - const batch = await this.$.prisma.batchTaskRun.findUnique({ + const batch = await this.$.prisma.batchTaskRun.findFirst({ select: { status: true, runtimeEnvironmentId: true, diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 5ecf5ffd99..76b97c7d60 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -139,12 +139,10 @@ export class RunAttemptSystem { throw new ServiceValidationError("Task run is not locked", 400); } - const queue = await prisma.taskQueue.findUnique({ + const queue = await prisma.taskQueue.findFirst({ where: { - runtimeEnvironmentId_name: { - runtimeEnvironmentId: environment.id, - name: taskRun.queue, - }, + runtimeEnvironmentId: environment.id, + name: taskRun.queue, }, }); @@ -1199,7 +1197,7 @@ export class RunAttemptSystem { async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) { const prisma = tx ?? this.$.prisma; - const taskRun = await prisma.taskRun.findUnique({ + const taskRun = await prisma.taskRun.findFirst({ where: { id: runId, }, diff --git a/internal-packages/run-engine/src/engine/systems/ttlSystem.ts b/internal-packages/run-engine/src/engine/systems/ttlSystem.ts index 12910f4634..f020fe2b3c 100644 --- a/internal-packages/run-engine/src/engine/systems/ttlSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/ttlSystem.ts @@ -33,7 +33,7 @@ export class TtlSystem { } //only expire "PENDING" runs - const run = await prisma.taskRun.findUnique({ where: { id: runId } }); + const run = await prisma.taskRun.findFirst({ where: { id: runId } }); if (!run) { this.$.logger.debug("Could not find enqueued run to expire", { diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index 669fcf0e26..b2eb9e5396 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -159,12 +159,10 @@ export class WaitpointSystem { const prisma = tx ?? this.$.prisma; const existingWaitpoint = idempotencyKey - ? await prisma.waitpoint.findUnique({ + ? await prisma.waitpoint.findFirst({ where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey, - }, + environmentId, + idempotencyKey, }, }) : undefined; @@ -241,12 +239,10 @@ export class WaitpointSystem { tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { const existingWaitpoint = idempotencyKey - ? await this.$.prisma.waitpoint.findUnique({ + ? await this.$.prisma.waitpoint.findFirst({ where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey, - }, + environmentId, + idempotencyKey, }, }) : undefined; diff --git a/packages/cli-v3/e2e/utils.ts b/packages/cli-v3/e2e/utils.ts index be158ef599..73530208c7 100644 --- a/packages/cli-v3/e2e/utils.ts +++ b/packages/cli-v3/e2e/utils.ts @@ -8,6 +8,7 @@ import { TaskRunProcess } from "../src/executions/taskRunProcess.js"; import { createTestHttpServer } from "@epic-web/test-server/http"; import { TestCase, TestCaseRun } from "./fixtures.js"; import { access } from "node:fs/promises"; +import { MachinePreset } from "@trigger.dev/core/v3"; export type PackageManager = "npm" | "pnpm" | "yarn"; @@ -295,6 +296,13 @@ export async function executeTestCaseRun({ }, }); + const machine = { + name: "small-1x", + cpu: 1, + memory: 256, + centsPerMs: 0.0000001, + } satisfies MachinePreset; + try { const taskRunProcess = new TaskRunProcess({ workerManifest: workerManifest!, @@ -314,12 +322,7 @@ export async function executeTestCaseRun({ version: "1.0.0", contentHash, }, - machine: { - name: "small-1x", - cpu: 1, - memory: 256, - centsPerMs: 0.0000001, - }, + machineResources: machine, }).initialize(); const result = await taskRunProcess.execute({ @@ -372,12 +375,7 @@ export async function executeTestCaseRun({ ref: "main", name: "test", }, - machine: { - name: "small-1x", - cpu: 1, - memory: 256, - centsPerMs: 0.0000001, - }, + machine, }, }, messageId: "run_1234", diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index e1445b4600..be00598471 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -49,13 +49,13 @@ export async function startWorkerRuntime(options: WorkerRuntimeOptions): Promise * - Receiving snapshot update pings (via socket) */ class DevSupervisor implements WorkerRuntime { - private config: DevConfigResponseBody; + private config?: DevConfigResponseBody; private disconnectPresence: (() => void) | undefined; private lastManifest?: BuildManifest; private latestWorkerId?: string; /** Receive notifications when runs change state */ - private socket: Socket; + private socket?: Socket; private socketIsReconnecting = false; /** Workers are versions of the code */ @@ -93,7 +93,7 @@ class DevSupervisor implements WorkerRuntime { this.runLimiter = pLimit(maxConcurrentRuns); - this.#createSocket(); + this.socket = this.#createSocket(); //start an SSE connection for presence this.disconnectPresence = await this.#startPresenceConnection(); @@ -105,7 +105,7 @@ class DevSupervisor implements WorkerRuntime { async shutdown(): Promise { this.disconnectPresence?.(); try { - this.socket.close(); + this.socket?.close(); } catch (error) { logger.debug("[DevSupervisor] shutdown, socket failed to close", { error }); } @@ -187,6 +187,10 @@ class DevSupervisor implements WorkerRuntime { * For the latest version we will pull from the main queue, so we don't specify that. */ async #dequeueRuns() { + if (!this.config) { + throw new Error("No config, can't dequeue runs"); + } + if (!this.latestWorkerId) { //try again later logger.debug(`[DevSupervisor] dequeueRuns. No latest worker ID, trying again later`); @@ -409,13 +413,14 @@ class DevSupervisor implements WorkerRuntime { const wsUrl = new URL(this.options.client.apiURL); wsUrl.pathname = "/dev-worker"; - this.socket = io(wsUrl.href, { + const socket = io(wsUrl.href, { transports: ["websocket"], extraHeaders: { Authorization: `Bearer ${this.options.client.accessToken}`, }, }); - this.socket.on("run:notify", async ({ version, run }) => { + + socket.on("run:notify", async ({ version, run }) => { logger.debug("[DevSupervisor] Received run notification", { version, run }); this.options.client.dev.sendDebugLog(run.friendlyId, { @@ -434,10 +439,11 @@ class DevSupervisor implements WorkerRuntime { await controller.getLatestSnapshot(); }); - this.socket.on("connect", () => { + + socket.on("connect", () => { logger.debug("[DevSupervisor] Connected to supervisor"); - if (this.socket.recovered || this.socketIsReconnecting) { + if (socket.recovered || this.socketIsReconnecting) { logger.debug("[DevSupervisor] Socket recovered"); eventBus.emit("socketConnectionReconnected", `Connection was recovered`); } @@ -448,19 +454,21 @@ class DevSupervisor implements WorkerRuntime { controller.resubscribeToRunNotifications(); } }); - this.socket.on("connect_error", (error) => { + + socket.on("connect_error", (error) => { logger.debug("[DevSupervisor] Connection error", { error }); }); - this.socket.on("disconnect", (reason, description) => { + + socket.on("disconnect", (reason, description) => { logger.debug("[DevSupervisor] socket was disconnected", { reason, description, - active: this.socket.active, + active: socket.active, }); if (reason === "io server disconnect") { // the disconnection was initiated by the server, you need to manually reconnect - this.socket.connect(); + socket.connect(); } else { this.socketIsReconnecting = true; eventBus.emit("socketConnectionDisconnected", reason); @@ -472,6 +480,8 @@ class DevSupervisor implements WorkerRuntime { connections: Array.from(this.socketConnections), }); }, 5000); + + return socket; } #subscribeToRunNotifications() { diff --git a/packages/cli-v3/src/entryPoints/dev-run-controller.ts b/packages/cli-v3/src/entryPoints/dev-run-controller.ts index ccfc68e259..f851bc07aa 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-controller.ts @@ -1,7 +1,7 @@ import { CompleteRunAttemptResult, DequeuedMessage, - HeartbeatService, + IntervalService, LogLevel, RunExecutionData, TaskRunExecution, @@ -44,9 +44,9 @@ export class DevRunController { private taskRunProcess?: TaskRunProcess; private readonly worker: BackgroundWorker; private readonly httpClient: CliApiClient; - private readonly runHeartbeat: HeartbeatService; + private readonly runHeartbeat: IntervalService; private readonly heartbeatIntervalSeconds: number; - private readonly snapshotPoller: HeartbeatService; + private readonly snapshotPoller: IntervalService; private readonly snapshotPollIntervalSeconds: number; private state: @@ -78,8 +78,8 @@ export class DevRunController { this.httpClient = opts.httpClient; - this.snapshotPoller = new HeartbeatService({ - heartbeat: async () => { + this.snapshotPoller = new IntervalService({ + onInterval: async () => { if (!this.runFriendlyId) { logger.debug("[DevRunController] Skipping snapshot poll, no run ID"); return; @@ -121,8 +121,8 @@ export class DevRunController { }, }); - this.runHeartbeat = new HeartbeatService({ - heartbeat: async () => { + this.runHeartbeat = new IntervalService({ + onInterval: async () => { if (!this.runFriendlyId || !this.snapshotFriendlyId) { logger.debug("[DevRunController] Skipping heartbeat, no run ID or snapshot ID"); return; @@ -619,7 +619,7 @@ export class DevRunController { version: this.opts.worker.serverWorker?.version, engine: "V2", }, - machine: execution.machine, + machineResources: execution.machine, }).initialize(); logger.debug("executing task run process", { diff --git a/packages/cli-v3/src/entryPoints/managed-run-controller.ts b/packages/cli-v3/src/entryPoints/managed-run-controller.ts index c41b50ad27..4baa701b05 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-controller.ts @@ -1,1687 +1,12 @@ -import { logger } from "../utilities/logger.js"; -import { TaskRunProcess } from "../executions/taskRunProcess.js"; import { env as stdEnv } from "std-env"; -import { z } from "zod"; -import { randomUUID } from "crypto"; import { readJSONFile } from "../utilities/fileSystem.js"; -import { - type CompleteRunAttemptResult, - HeartbeatService, - type RunExecutionData, - SuspendedProcessError, - type TaskRunExecutionMetrics, - type TaskRunExecutionResult, - type TaskRunFailedExecutionResult, - WorkerManifest, -} from "@trigger.dev/core/v3"; -import { - WarmStartClient, - WORKLOAD_HEADERS, - type WorkloadClientToServerEvents, - type WorkloadDebugLogRequestBody, - WorkloadHttpClient, - type WorkloadServerToClientEvents, - type WorkloadRunAttemptStartResponseBody, -} from "@trigger.dev/core/v3/workers"; -import { assertExhaustive } from "../utilities/assertExhaustive.js"; -import { setTimeout as sleep } from "timers/promises"; -import { io, type Socket } from "socket.io-client"; +import { WorkerManifest } from "@trigger.dev/core/v3"; +import { ManagedRunController } from "./managed/controller.js"; -const DateEnv = z - .string() - .transform((val) => new Date(parseInt(val, 10))) - .pipe(z.date()); +const manifest = await readJSONFile("./index.json"); +const workerManifest = WorkerManifest.parse(manifest); -// All IDs are friendly IDs -const Env = z.object({ - // Set at build time - TRIGGER_CONTENT_HASH: z.string(), - TRIGGER_DEPLOYMENT_ID: z.string(), - TRIGGER_DEPLOYMENT_VERSION: z.string(), - TRIGGER_PROJECT_ID: z.string(), - TRIGGER_PROJECT_REF: z.string(), - NODE_ENV: z.string().default("production"), - NODE_EXTRA_CA_CERTS: z.string().optional(), - - // Set at runtime - TRIGGER_WORKLOAD_CONTROLLER_ID: z.string().default(`controller_${randomUUID()}`), - TRIGGER_ENV_ID: z.string(), - TRIGGER_RUN_ID: z.string().optional(), // This is only useful for cold starts - TRIGGER_SNAPSHOT_ID: z.string().optional(), // This is only useful for cold starts - OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), - TRIGGER_WARM_START_URL: z.string().optional(), - TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS: z.coerce.number().default(30_000), - TRIGGER_WARM_START_KEEPALIVE_MS: z.coerce.number().default(300_000), - TRIGGER_MACHINE_CPU: z.string().default("0"), - TRIGGER_MACHINE_MEMORY: z.string().default("0"), - TRIGGER_RUNNER_ID: z.string(), - TRIGGER_METADATA_URL: z.string().optional(), - TRIGGER_PRE_SUSPEND_WAIT_MS: z.coerce.number().default(200), - - // Timeline metrics - TRIGGER_POD_SCHEDULED_AT_MS: DateEnv, - TRIGGER_DEQUEUED_AT_MS: DateEnv, - - // May be overridden - TRIGGER_SUPERVISOR_API_PROTOCOL: z.enum(["http", "https"]), - TRIGGER_SUPERVISOR_API_DOMAIN: z.string(), - TRIGGER_SUPERVISOR_API_PORT: z.coerce.number(), - TRIGGER_WORKER_INSTANCE_NAME: z.string(), - TRIGGER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), - TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().default(5), - TRIGGER_SUCCESS_EXIT_CODE: z.coerce.number().default(0), - TRIGGER_FAILURE_EXIT_CODE: z.coerce.number().default(1), -}); - -const env = Env.parse(stdEnv); - -logger.loggerLevel = "debug"; - -type ManagedRunControllerOptions = { - workerManifest: WorkerManifest; -}; - -type Run = { - friendlyId: string; - attemptNumber?: number | null; -}; - -type Snapshot = { - friendlyId: string; -}; - -type Metadata = { - TRIGGER_SUPERVISOR_API_PROTOCOL: string | undefined; - TRIGGER_SUPERVISOR_API_DOMAIN: string | undefined; - TRIGGER_SUPERVISOR_API_PORT: number | undefined; - TRIGGER_WORKER_INSTANCE_NAME: string | undefined; - TRIGGER_HEARTBEAT_INTERVAL_SECONDS: number | undefined; - TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS: number | undefined; - TRIGGER_SUCCESS_EXIT_CODE: number | undefined; - TRIGGER_FAILURE_EXIT_CODE: number | undefined; - TRIGGER_RUNNER_ID: string | undefined; -}; - -class MetadataClient { - private readonly url: URL; - - constructor(url: string) { - this.url = new URL(url); - } - - async getEnvOverrides(): Promise { - try { - const response = await fetch(new URL("/env", this.url)); - return response.json(); - } catch (error) { - console.error("Failed to fetch metadata", { error }); - return null; - } - } -} - -class ManagedRunController { - private taskRunProcess?: TaskRunProcess; - - private workerManifest: WorkerManifest; - - private readonly httpClient: WorkloadHttpClient; - private readonly warmStartClient: WarmStartClient | undefined; - private readonly metadataClient?: MetadataClient; - - private socket: Socket; - - private readonly runHeartbeat: HeartbeatService; - private heartbeatIntervalSeconds: number; - - private readonly snapshotPoller: HeartbeatService; - private snapshotPollIntervalSeconds: number; - - private workerApiUrl: string; - private workerInstanceName: string; - - private runnerId: string; - - private successExitCode = env.TRIGGER_SUCCESS_EXIT_CODE; - private failureExitCode = env.TRIGGER_FAILURE_EXIT_CODE; - - private state: - | { - phase: "RUN"; - run: Run; - snapshot: Snapshot; - } - | { - phase: "IDLE" | "WARM_START"; - } = { phase: "IDLE" }; - - constructor(opts: ManagedRunControllerOptions) { - this.workerManifest = opts.workerManifest; - - this.runnerId = env.TRIGGER_RUNNER_ID; - - this.workerApiUrl = `${env.TRIGGER_SUPERVISOR_API_PROTOCOL}://${env.TRIGGER_SUPERVISOR_API_DOMAIN}:${env.TRIGGER_SUPERVISOR_API_PORT}`; - this.workerInstanceName = env.TRIGGER_WORKER_INSTANCE_NAME; - - this.httpClient = new WorkloadHttpClient({ - workerApiUrl: this.workerApiUrl, - runnerId: this.runnerId, - deploymentId: env.TRIGGER_DEPLOYMENT_ID, - deploymentVersion: env.TRIGGER_DEPLOYMENT_VERSION, - projectRef: env.TRIGGER_PROJECT_REF, - }); - - const properties = { - ...env, - TRIGGER_POD_SCHEDULED_AT_MS: env.TRIGGER_POD_SCHEDULED_AT_MS.toISOString(), - TRIGGER_DEQUEUED_AT_MS: env.TRIGGER_DEQUEUED_AT_MS.toISOString(), - }; - - this.sendDebugLog({ - runId: env.TRIGGER_RUN_ID, - message: "Creating run controller", - properties, - }); - - this.heartbeatIntervalSeconds = env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS; - this.snapshotPollIntervalSeconds = env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS; - - if (env.TRIGGER_METADATA_URL) { - this.metadataClient = new MetadataClient(env.TRIGGER_METADATA_URL); - } - - if (env.TRIGGER_WARM_START_URL) { - this.warmStartClient = new WarmStartClient({ - apiUrl: new URL(env.TRIGGER_WARM_START_URL), - controllerId: env.TRIGGER_WORKLOAD_CONTROLLER_ID, - deploymentId: env.TRIGGER_DEPLOYMENT_ID, - deploymentVersion: env.TRIGGER_DEPLOYMENT_VERSION, - machineCpu: env.TRIGGER_MACHINE_CPU, - machineMemory: env.TRIGGER_MACHINE_MEMORY, - }); - } - - this.snapshotPoller = new HeartbeatService({ - heartbeat: async () => { - if (!this.runFriendlyId) { - this.sendDebugLog({ - runId: env.TRIGGER_RUN_ID, - message: "Skipping snapshot poll, no run ID", - }); - return; - } - - this.sendDebugLog({ - runId: env.TRIGGER_RUN_ID, - message: "Polling for latest snapshot", - }); - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: `snapshot poll: started`, - properties: { - snapshotId: this.snapshotFriendlyId, - }, - }); - - const response = await this.httpClient.getRunExecutionData(this.runFriendlyId); - - if (!response.success) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Snapshot poll failed", - properties: { - error: response.error, - }, - }); - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: `snapshot poll: failed`, - properties: { - snapshotId: this.snapshotFriendlyId, - error: response.error, - }, - }); - - return; - } - - await this.handleSnapshotChange(response.data.execution); - }, - intervalMs: this.snapshotPollIntervalSeconds * 1000, - leadingEdge: false, - onError: async (error) => { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Failed to poll for snapshot", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - }, - }); - - this.runHeartbeat = new HeartbeatService({ - heartbeat: async () => { - if (!this.runFriendlyId || !this.snapshotFriendlyId) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Skipping heartbeat, no run ID or snapshot ID", - }); - return; - } - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "heartbeat: started", - }); - - const response = await this.httpClient.heartbeatRun( - this.runFriendlyId, - this.snapshotFriendlyId - ); - - if (!response.success) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "heartbeat: failed", - properties: { - error: response.error, - }, - }); - } - }, - intervalMs: this.heartbeatIntervalSeconds * 1000, - leadingEdge: false, - onError: async (error) => { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Failed to send heartbeat", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - }, - }); - - process.on("SIGTERM", async () => { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Received SIGTERM, stopping worker", - }); - await this.stop(); - }); - } - - private enterRunPhase(run: Run, snapshot: Snapshot) { - this.onExitRunPhase(run); - this.state = { phase: "RUN", run, snapshot }; - - this.runHeartbeat.start(); - this.snapshotPoller.start(); - } - - private enterWarmStartPhase() { - this.onExitRunPhase(); - this.state = { phase: "WARM_START" }; - } - - // This should only be used when we're already executing a run. Attempt number changes are not allowed. - private updateRunPhase(run: Run, snapshot: Snapshot) { - if (this.state.phase !== "RUN") { - this.sendDebugLog({ - runId: run.friendlyId, - message: `updateRunPhase: Invalid phase for updating snapshot: ${this.state.phase}`, - properties: { - currentPhase: this.state.phase, - snapshotId: snapshot.friendlyId, - }, - }); - - throw new Error(`Invalid phase for updating snapshot: ${this.state.phase}`); - } - - if (this.state.run.friendlyId !== run.friendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: `updateRunPhase: Mismatched run IDs`, - properties: { - currentRunId: this.state.run.friendlyId, - newRunId: run.friendlyId, - currentSnapshotId: this.state.snapshot.friendlyId, - newSnapshotId: snapshot.friendlyId, - }, - }); - - throw new Error("Mismatched run IDs"); - } - - if (this.state.snapshot.friendlyId === snapshot.friendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "updateRunPhase: Snapshot not changed", - properties: { run: run.friendlyId, snapshot: snapshot.friendlyId }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: `updateRunPhase: Snapshot not changed`, - properties: { - snapshotId: snapshot.friendlyId, - }, - }); - - return; - } - - if (this.state.run.attemptNumber !== run.attemptNumber) { - this.sendDebugLog({ - runId: run.friendlyId, - message: `updateRunPhase: Attempt number changed`, - properties: { - oldAttemptNumber: this.state.run.attemptNumber ?? undefined, - newAttemptNumber: run.attemptNumber ?? undefined, - }, - }); - throw new Error("Attempt number changed"); - } - - this.state = { - phase: "RUN", - run: { - friendlyId: run.friendlyId, - attemptNumber: run.attemptNumber, - }, - snapshot: { - friendlyId: snapshot.friendlyId, - }, - }; - } - - private onExitRunPhase(newRun: Run | undefined = undefined) { - // We're not in a run phase, nothing to do - if (this.state.phase !== "RUN") { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "onExitRunPhase: Not in run phase, skipping", - properties: { phase: this.state.phase }, - }); - return; - } - - // This is still the same run, so we're not exiting the phase - if (newRun?.friendlyId === this.state.run.friendlyId) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "onExitRunPhase: Same run, skipping", - properties: { newRun: newRun?.friendlyId }, - }); - return; - } - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "onExitRunPhase: Exiting run phase", - properties: { newRun: newRun?.friendlyId }, - }); - - this.runHeartbeat.stop(); - this.snapshotPoller.stop(); - - const { run, snapshot } = this.state; - - this.unsubscribeFromRunNotifications({ run, snapshot }); - } - - private subscribeToRunNotifications({ run, snapshot }: { run: Run; snapshot: Snapshot }) { - this.socket.emit("run:start", { - version: "1", - run: { - friendlyId: run.friendlyId, - }, - snapshot: { - friendlyId: snapshot.friendlyId, - }, - }); - } - - private unsubscribeFromRunNotifications({ run, snapshot }: { run: Run; snapshot: Snapshot }) { - this.socket.emit("run:stop", { - version: "1", - run: { - friendlyId: run.friendlyId, - }, - snapshot: { - friendlyId: snapshot.friendlyId, - }, - }); - } - - private get runFriendlyId() { - if (this.state.phase !== "RUN") { - return undefined; - } - - return this.state.run.friendlyId; - } - - private get snapshotFriendlyId() { - if (this.state.phase !== "RUN") { - return; - } - - return this.state.snapshot.friendlyId; - } - - private handleSnapshotChangeLock = false; - - private async handleSnapshotChange({ - run, - snapshot, - completedWaitpoints, - }: Pick) { - if (this.handleSnapshotChangeLock) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "handleSnapshotChange: already in progress", - }); - return; - } - - this.handleSnapshotChangeLock = true; - - try { - if (!this.snapshotFriendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "handleSnapshotChange: Missing snapshot ID", - properties: { - newSnapshotId: snapshot.friendlyId, - newSnapshotStatus: snapshot.executionStatus, - }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: "snapshot change: missing snapshot ID", - properties: { - newSnapshotId: snapshot.friendlyId, - newSnapshotStatus: snapshot.executionStatus, - }, - }); - - return; - } - - if (this.snapshotFriendlyId === snapshot.friendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "handleSnapshotChange: snapshot not changed, skipping", - properties: { snapshot: snapshot.friendlyId }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: "snapshot change: skipping, no change", - properties: { - snapshotId: this.snapshotFriendlyId, - snapshotStatus: snapshot.executionStatus, - }, - }); - - return; - } - - this.sendDebugLog({ - runId: run.friendlyId, - message: `snapshot change: ${snapshot.executionStatus}`, - properties: { - oldSnapshotId: this.snapshotFriendlyId, - newSnapshotId: snapshot.friendlyId, - completedWaitpoints: completedWaitpoints.length, - }, - }); - - try { - this.updateRunPhase(run, snapshot); - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "snapshot change: failed to update run phase", - properties: { - currentPhase: this.state.phase, - error: error instanceof Error ? error.message : String(error), - }, - }); - - this.waitForNextRun(); - return; - } - - switch (snapshot.executionStatus) { - case "PENDING_CANCEL": { - try { - await this.cancelAttempt(run.friendlyId); - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "snapshot change: failed to cancel attempt", - properties: { - error: error instanceof Error ? error.message : String(error), - }, - }); - - this.waitForNextRun(); - return; - } - - return; - } - case "FINISHED": { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run is finished, will wait for next run", - }); - - if (this.activeRunExecution) { - // Let's pretend we've just suspended the run. This will kill the process and should automatically wait for the next run. - // We still explicitly call waitForNextRun() afterwards in case of race conditions. Locks will prevent this from causing issues. - await this.taskRunProcess?.suspend(); - } - - this.waitForNextRun(); - - return; - } - case "QUEUED_EXECUTING": - case "EXECUTING_WITH_WAITPOINTS": { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run is executing with waitpoints", - properties: { snapshot: snapshot.friendlyId }, - }); - - try { - // This should never throw. It should also never fail the run. - await this.taskRunProcess?.cleanup(false); - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Failed to cleanup task run process", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - } - - if (snapshot.friendlyId !== this.snapshotFriendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Snapshot changed after cleanup, abort", - properties: { - oldSnapshotId: snapshot.friendlyId, - newSnapshotId: this.snapshotFriendlyId, - }, - }); - return; - } - - await sleep(env.TRIGGER_PRE_SUSPEND_WAIT_MS); - - if (snapshot.friendlyId !== this.snapshotFriendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Snapshot changed after suspend threshold, abort", - properties: { - oldSnapshotId: snapshot.friendlyId, - newSnapshotId: this.snapshotFriendlyId, - }, - }); - return; - } - - if (!this.runFriendlyId || !this.snapshotFriendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: - "handleSnapshotChange: Missing run ID or snapshot ID after suspension, abort", - properties: { - runId: this.runFriendlyId, - snapshotId: this.snapshotFriendlyId, - }, - }); - return; - } - - const suspendResult = await this.httpClient.suspendRun( - this.runFriendlyId, - this.snapshotFriendlyId - ); - - if (!suspendResult.success) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Failed to suspend run, staying alive 🎶", - properties: { - error: suspendResult.error, - }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: "checkpoint: suspend request failed", - properties: { - snapshotId: snapshot.friendlyId, - error: suspendResult.error, - }, - }); - - return; - } - - if (!suspendResult.data.ok) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "checkpoint: failed to suspend run", - properties: { - snapshotId: snapshot.friendlyId, - error: suspendResult.data.error, - }, - }); - - return; - } - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Suspending, any day now 🚬", - properties: { ok: suspendResult.data.ok }, - }); - return; - } - case "SUSPENDED": { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run was suspended, kill the process and wait for more runs", - properties: { run: run.friendlyId, snapshot: snapshot.friendlyId }, - }); - - // This will kill the process and fail the execution with a SuspendedProcessError - await this.taskRunProcess?.suspend(); - - return; - } - case "PENDING_EXECUTING": { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run is pending execution", - properties: { run: run.friendlyId, snapshot: snapshot.friendlyId }, - }); - - if (completedWaitpoints.length === 0) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "No waitpoints to complete, nothing to do", - }); - return; - } - - // There are waitpoints to complete so we've been restored after being suspended - - // Short delay to give websocket time to reconnect - await sleep(100); - - // Env may have changed after restore - await this.processEnvOverrides(); - - // We need to let the platform know we're ready to continue - const continuationResult = await this.httpClient.continueRunExecution( - run.friendlyId, - snapshot.friendlyId - ); - - if (!continuationResult.success) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "failed to continue execution", - properties: { - error: continuationResult.error, - }, - }); - - this.waitForNextRun(); - return; - } - - return; - } - case "EXECUTING": { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run is now executing", - properties: { run: run.friendlyId, snapshot: snapshot.friendlyId }, - }); - - if (completedWaitpoints.length === 0) { - return; - } - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Processing completed waitpoints", - properties: { completedWaitpoints: completedWaitpoints.length }, - }); - - if (!this.taskRunProcess) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "No task run process, ignoring completed waitpoints", - properties: { completedWaitpoints: completedWaitpoints.length }, - }); - return; - } - - for (const waitpoint of completedWaitpoints) { - this.taskRunProcess.waitpointCompleted(waitpoint); - } - - return; - } - case "RUN_CREATED": - case "QUEUED": { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Status change not handled", - properties: { status: snapshot.executionStatus }, - }); - return; - } - default: { - assertExhaustive(snapshot.executionStatus); - } - } - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "snapshot change: unexpected error", - properties: { - snapshotId: snapshot.friendlyId, - error: error instanceof Error ? error.message : String(error), - }, - }); - } finally { - this.handleSnapshotChangeLock = false; - } - } - - private async processEnvOverrides() { - if (!this.metadataClient) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "No metadata client, skipping env overrides", - }); - return; - } - - const overrides = await this.metadataClient.getEnvOverrides(); - - if (!overrides) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "No env overrides, skipping", - }); - return; - } - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Processing env overrides", - properties: { ...overrides }, - }); - - if (overrides.TRIGGER_SUCCESS_EXIT_CODE) { - this.successExitCode = overrides.TRIGGER_SUCCESS_EXIT_CODE; - } - - if (overrides.TRIGGER_FAILURE_EXIT_CODE) { - this.failureExitCode = overrides.TRIGGER_FAILURE_EXIT_CODE; - } - - if (overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS) { - this.heartbeatIntervalSeconds = overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS; - this.runHeartbeat.updateInterval(this.heartbeatIntervalSeconds * 1000); - } - - if (overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS) { - this.snapshotPollIntervalSeconds = overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS; - this.snapshotPoller.updateInterval(this.snapshotPollIntervalSeconds * 1000); - } - - if (overrides.TRIGGER_WORKER_INSTANCE_NAME) { - this.workerInstanceName = overrides.TRIGGER_WORKER_INSTANCE_NAME; - } - - if ( - overrides.TRIGGER_SUPERVISOR_API_PROTOCOL || - overrides.TRIGGER_SUPERVISOR_API_DOMAIN || - overrides.TRIGGER_SUPERVISOR_API_PORT - ) { - const protocol = - overrides.TRIGGER_SUPERVISOR_API_PROTOCOL ?? env.TRIGGER_SUPERVISOR_API_PROTOCOL; - const domain = overrides.TRIGGER_SUPERVISOR_API_DOMAIN ?? env.TRIGGER_SUPERVISOR_API_DOMAIN; - const port = overrides.TRIGGER_SUPERVISOR_API_PORT ?? env.TRIGGER_SUPERVISOR_API_PORT; - - this.workerApiUrl = `${protocol}://${domain}:${port}`; - - this.httpClient.updateApiUrl(this.workerApiUrl); - } - - if (overrides.TRIGGER_RUNNER_ID) { - this.runnerId = overrides.TRIGGER_RUNNER_ID; - this.httpClient.updateRunnerId(this.runnerId); - } - } - - private activeRunExecution: Promise | null = null; - - private async startAndExecuteRunAttempt({ - runFriendlyId, - snapshotFriendlyId, - dequeuedAt, - podScheduledAt, - isWarmStart, - skipLockCheckForImmediateRetry: skipLockCheck, - }: { - runFriendlyId: string; - snapshotFriendlyId: string; - dequeuedAt?: Date; - podScheduledAt?: Date; - isWarmStart?: boolean; - skipLockCheckForImmediateRetry?: boolean; - }) { - if (!skipLockCheck && this.activeRunExecution) { - this.sendDebugLog({ - runId: runFriendlyId, - message: "startAndExecuteRunAttempt: already in progress", - }); - return; - } - - const execution = async () => { - if (!this.socket) { - this.sendDebugLog({ - runId: runFriendlyId, - message: "Starting run without socket connection", - }); - } - - this.subscribeToRunNotifications({ - run: { friendlyId: runFriendlyId }, - snapshot: { friendlyId: snapshotFriendlyId }, - }); - - const attemptStartedAt = Date.now(); - - const start = await this.httpClient.startRunAttempt(runFriendlyId, snapshotFriendlyId, { - isWarmStart, - }); - - if (!start.success) { - this.sendDebugLog({ - runId: runFriendlyId, - message: "Failed to start run", - properties: { error: start.error }, - }); - - this.sendDebugLog({ - runId: runFriendlyId, - message: "failed to start run attempt", - properties: { - error: start.error, - }, - }); - - this.waitForNextRun(); - return; - } - - const attemptDuration = Date.now() - attemptStartedAt; - - const { run, snapshot, execution, envVars } = start.data; - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Started run", - properties: { snapshot: snapshot.friendlyId }, - }); - - this.enterRunPhase(run, snapshot); - - const metrics = [ - { - name: "start", - event: "create_attempt", - timestamp: attemptStartedAt, - duration: attemptDuration, - }, - ] - .concat( - dequeuedAt - ? [ - { - name: "start", - event: "dequeue", - timestamp: dequeuedAt.getTime(), - duration: 0, - }, - ] - : [] - ) - .concat( - podScheduledAt - ? [ - { - name: "start", - event: "pod_scheduled", - timestamp: podScheduledAt.getTime(), - duration: 0, - }, - ] - : [] - ) satisfies TaskRunExecutionMetrics; - - const taskRunEnv = { - ...gatherProcessEnv(), - ...envVars, - }; - - try { - return await this.executeRun({ - run, - snapshot, - envVars: taskRunEnv, - execution, - metrics, - isWarmStart, - }); - } catch (error) { - if (error instanceof SuspendedProcessError) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run was suspended and task run process was killed, waiting for next run", - properties: { run: run.friendlyId, snapshot: snapshot.friendlyId }, - }); - - this.waitForNextRun(); - return; - } - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Error while executing attempt", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Submitting attempt completion", - properties: { - snapshotId: snapshot.friendlyId, - updatedSnapshotId: this.snapshotFriendlyId, - }, - }); - - const completion = { - id: execution.run.id, - ok: false, - retry: undefined, - error: TaskRunProcess.parseExecuteError(error), - } satisfies TaskRunFailedExecutionResult; - - const completionResult = await this.httpClient.completeRunAttempt( - run.friendlyId, - // FIXME: if the snapshot has changed since starting the run, this won't be accurate - // ..but we probably shouldn't fetch the latest snapshot either because we may be in an "unhealthy" state while the next runner has already taken over - this.snapshotFriendlyId ?? snapshot.friendlyId, - { completion } - ); - - if (!completionResult.success) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Failed to submit completion after error", - properties: { error: completionResult.error }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: "completion: failed to submit after error", - properties: { - error: completionResult.error, - }, - }); - - this.waitForNextRun(); - return; - } - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Attempt completion submitted after error", - properties: { - attemptStatus: completionResult.data.result.attemptStatus, - runId: completionResult.data.result.run.friendlyId, - snapshotId: completionResult.data.result.snapshot.friendlyId, - }, - }); - - try { - await this.handleCompletionResult(completion, completionResult.data.result); - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Failed to handle completion result after error", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - - this.waitForNextRun(); - return; - } - } - }; - - this.activeRunExecution = execution(); - - try { - await this.activeRunExecution; - } catch (error) { - this.sendDebugLog({ - runId: runFriendlyId, - message: "startAndExecuteRunAttempt: unexpected error", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - } finally { - this.activeRunExecution = null; - } - } - - private waitForNextRunLock = false; - - /** This will kill the child process before spinning up a new one. It will never throw, - * but may exit the process on any errors or when no runs are available after the - * configured duration. */ - private async waitForNextRun() { - if (this.waitForNextRunLock) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: already in progress", - }); - return; - } - - this.waitForNextRunLock = true; - const previousRunId = this.runFriendlyId; - - try { - // If there's a run execution in progress, we need to kill it and wait for it to finish - if (this.activeRunExecution) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: waiting for existing run execution to finish", - }); - await this.activeRunExecution; - } - - // Just for good measure - await this.taskRunProcess?.kill("SIGKILL"); - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: waiting for next run", - }); - - this.enterWarmStartPhase(); - - if (!this.warmStartClient) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: warm starts disabled, shutting down", - }); - this.exitProcess(this.successExitCode); - } - - if (this.taskRunProcess) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: eagerly recreating task run process with options", - }); - this.taskRunProcess = new TaskRunProcess({ - ...this.taskRunProcess.options, - isWarmStart: true, - }).initialize(); - } else { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: no existing task run process, so we can't eagerly recreate it", - }); - } - - // Check the service is up and get additional warm start config - const connect = await this.warmStartClient.connect(); - - if (!connect.success) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: failed to connect to warm start service", - properties: { - warmStartUrl: env.TRIGGER_WARM_START_URL, - error: connect.error, - }, - }); - this.exitProcess(this.successExitCode); - } - - const connectionTimeoutMs = - connect.data.connectionTimeoutMs ?? env.TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS; - const keepaliveMs = connect.data.keepaliveMs ?? env.TRIGGER_WARM_START_KEEPALIVE_MS; - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: connected to warm start service", - properties: { - connectionTimeoutMs, - keepaliveMs, - }, - }); - - if (previousRunId) { - this.sendDebugLog({ - runId: previousRunId, - message: "warm start: received config", - properties: { - connectionTimeoutMs, - keepaliveMs, - }, - }); - } - - if (!connectionTimeoutMs || !keepaliveMs) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: warm starts disabled after connect", - properties: { - connectionTimeoutMs, - keepaliveMs, - }, - }); - this.exitProcess(this.successExitCode); - } - - const nextRun = await this.warmStartClient.warmStart({ - workerInstanceName: this.workerInstanceName, - connectionTimeoutMs, - keepaliveMs, - }); - - if (!nextRun) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: warm start failed, shutting down", - }); - this.exitProcess(this.successExitCode); - } - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: got next run", - properties: { nextRun: nextRun.run.friendlyId }, - }); - - this.startAndExecuteRunAttempt({ - runFriendlyId: nextRun.run.friendlyId, - snapshotFriendlyId: nextRun.snapshot.friendlyId, - dequeuedAt: nextRun.dequeuedAt, - isWarmStart: true, - }).finally(() => {}); - return; - } catch (error) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "waitForNextRun: unexpected error", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - this.exitProcess(this.failureExitCode); - } finally { - this.waitForNextRunLock = false; - } - } - - private exitProcess(code?: number): never { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Exiting process", - properties: { code }, - }); - if (this.taskRunProcess?.isPreparedForNextRun) { - this.taskRunProcess.forceExit(); - } - process.exit(code); - } - - createSocket() { - const wsUrl = new URL("/workload", this.workerApiUrl); - - this.socket = io(wsUrl.href, { - transports: ["websocket"], - extraHeaders: { - [WORKLOAD_HEADERS.DEPLOYMENT_ID]: env.TRIGGER_DEPLOYMENT_ID, - [WORKLOAD_HEADERS.RUNNER_ID]: env.TRIGGER_RUNNER_ID, - }, - }); - this.socket.on("run:notify", async ({ version, run }) => { - this.sendDebugLog({ - runId: run.friendlyId, - message: "run:notify received by runner", - properties: { version, runId: run.friendlyId }, - }); - - if (!this.runFriendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "run:notify: ignoring notification, no local run ID", - properties: { - currentRunId: this.runFriendlyId, - currentSnapshotId: this.snapshotFriendlyId, - }, - }); - return; - } - - if (run.friendlyId !== this.runFriendlyId) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "run:notify: ignoring notification for different run", - properties: { - currentRunId: this.runFriendlyId, - currentSnapshotId: this.snapshotFriendlyId, - notificationRunId: run.friendlyId, - }, - }); - return; - } - - // Reset the (fallback) snapshot poll interval so we don't do unnecessary work - this.snapshotPoller.resetCurrentInterval(); - - const latestSnapshot = await this.httpClient.getRunExecutionData(this.runFriendlyId); - - if (!latestSnapshot.success) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "run:notify: failed to get latest snapshot data", - properties: { - currentRunId: this.runFriendlyId, - currentSnapshotId: this.snapshotFriendlyId, - error: latestSnapshot.error, - }, - }); - return; - } - - await this.handleSnapshotChange(latestSnapshot.data.execution); - }); - this.socket.on("connect", () => { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Connected to supervisor", - }); - - // This should handle the case where we reconnect after being restored - if (this.state.phase === "RUN") { - const { run, snapshot } = this.state; - this.subscribeToRunNotifications({ run, snapshot }); - } - }); - this.socket.on("connect_error", (error) => { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Connection error", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - }); - this.socket.on("disconnect", (reason, description) => { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Disconnected from supervisor", - properties: { reason, description: description?.toString() }, - }); - }); - } - - private async executeRun({ - run, - snapshot, - envVars, - execution, - metrics, - isWarmStart, - }: WorkloadRunAttemptStartResponseBody & { - metrics?: TaskRunExecutionMetrics; - isWarmStart?: boolean; - }) { - this.snapshotPoller.start(); - - if (!this.taskRunProcess || !this.taskRunProcess.isPreparedForNextRun) { - this.taskRunProcess = new TaskRunProcess({ - workerManifest: this.workerManifest, - env: envVars, - serverWorker: { - id: "unmanaged", - contentHash: env.TRIGGER_CONTENT_HASH, - version: env.TRIGGER_DEPLOYMENT_VERSION, - engine: "V2", - }, - machine: execution.machine, - isWarmStart, - }).initialize(); - } - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "executing task run process", - properties: { - attemptId: execution.attempt.id, - runId: execution.run.id, - }, - }); - - const completion = await this.taskRunProcess.execute( - { - payload: { - execution, - traceContext: execution.run.traceContext ?? {}, - metrics, - }, - messageId: run.friendlyId, - env: envVars, - }, - isWarmStart - ); - - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Completed run", - properties: { completion: completion.ok }, - }); - - try { - // The execution has finished, so we can cleanup the task run process. Killing it should be safe. - await this.taskRunProcess.cleanup(true); - } catch (error) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Failed to cleanup task run process, submitting completion anyway", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - } - - if (!this.runFriendlyId || !this.snapshotFriendlyId) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "executeRun: Missing run ID or snapshot ID after execution", - properties: { - runId: this.runFriendlyId, - snapshotId: this.snapshotFriendlyId, - }, - }); - - this.waitForNextRun(); - return; - } - - const completionResult = await this.httpClient.completeRunAttempt( - this.runFriendlyId, - this.snapshotFriendlyId, - { - completion, - } - ); - - if (!completionResult.success) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "completion: failed to submit", - properties: { - error: completionResult.error, - }, - }); - - this.sendDebugLog({ - runId: run.friendlyId, - message: "completion: failed to submit", - properties: { - error: completionResult.error, - }, - }); - - this.waitForNextRun(); - return; - } - - this.sendDebugLog({ - runId: run.friendlyId, - message: "Attempt completion submitted", - properties: { - attemptStatus: completionResult.data.result.attemptStatus, - runId: completionResult.data.result.run.friendlyId, - snapshotId: completionResult.data.result.snapshot.friendlyId, - }, - }); - - try { - await this.handleCompletionResult(completion, completionResult.data.result); - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Failed to handle completion result", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - - this.waitForNextRun(); - return; - } - } - - private async handleCompletionResult( - completion: TaskRunExecutionResult, - result: CompleteRunAttemptResult - ) { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Handling completion result", - properties: { - completion: completion.ok, - attemptStatus: result.attemptStatus, - snapshotId: result.snapshot.friendlyId, - runId: result.run.friendlyId, - }, - }); - - const { attemptStatus, snapshot: completionSnapshot, run } = result; - - try { - this.updateRunPhase(run, completionSnapshot); - } catch (error) { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Failed to update run phase after completion", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - - this.waitForNextRun(); - return; - } - - if (attemptStatus === "RUN_FINISHED") { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run finished", - }); - - this.waitForNextRun(); - return; - } - - if (attemptStatus === "RUN_PENDING_CANCEL") { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Run pending cancel", - }); - return; - } - - if (attemptStatus === "RETRY_QUEUED") { - this.sendDebugLog({ - runId: run.friendlyId, - message: "Retry queued", - }); - - this.waitForNextRun(); - return; - } - - if (attemptStatus === "RETRY_IMMEDIATELY") { - if (completion.ok) { - throw new Error("Should retry but completion OK."); - } - - if (!completion.retry) { - throw new Error("Should retry but missing retry params."); - } - - await sleep(completion.retry.delay); - - if (!this.snapshotFriendlyId) { - throw new Error("Missing snapshot ID after retry"); - } - - this.startAndExecuteRunAttempt({ - runFriendlyId: run.friendlyId, - snapshotFriendlyId: this.snapshotFriendlyId, - skipLockCheckForImmediateRetry: true, - isWarmStart: true, - }).finally(() => {}); - return; - } - - assertExhaustive(attemptStatus); - } - - sendDebugLog({ - runId, - message, - date, - properties, - }: { - runId?: string; - message: string; - date?: Date; - properties?: WorkloadDebugLogRequestBody["properties"]; - }) { - if (!runId) { - runId = this.runFriendlyId; - } - - if (!runId) { - runId = env.TRIGGER_RUN_ID; - } - - if (!runId) { - return; - } - - const mergedProperties = { - ...properties, - runId, - runnerId: this.runnerId, - workerName: this.workerInstanceName, - }; - - console.log(message, mergedProperties); - - this.httpClient.sendDebugLog(runId, { - message, - time: date ?? new Date(), - properties: mergedProperties, - }); - } - - async cancelAttempt(runId: string) { - this.sendDebugLog({ - runId, - message: "cancelling attempt", - properties: { runId }, - }); - - await this.taskRunProcess?.cancel(); - } - - async start() { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Starting up", - }); - - // Websocket notifications are only an optimisation so we don't need to wait for a successful connection - this.createSocket(); - - // If we have run and snapshot IDs, we can start an attempt immediately - if (env.TRIGGER_RUN_ID && env.TRIGGER_SNAPSHOT_ID) { - this.startAndExecuteRunAttempt({ - runFriendlyId: env.TRIGGER_RUN_ID, - snapshotFriendlyId: env.TRIGGER_SNAPSHOT_ID, - dequeuedAt: env.TRIGGER_DEQUEUED_AT_MS, - podScheduledAt: env.TRIGGER_POD_SCHEDULED_AT_MS, - }).finally(() => {}); - return; - } - - // ..otherwise we need to wait for a run - this.waitForNextRun(); - return; - } - - async stop() { - this.sendDebugLog({ - runId: this.runFriendlyId, - message: "Shutting down", - }); - - if (this.taskRunProcess) { - await this.taskRunProcess.cleanup(true); - } - - this.runHeartbeat.stop(); - this.snapshotPoller.stop(); - - this.socket.close(); - } -} - -const workerManifest = await loadWorkerManifest(); - -const prodWorker = new ManagedRunController({ workerManifest }); -await prodWorker.start(); - -function gatherProcessEnv(): Record { - const $env = { - NODE_ENV: env.NODE_ENV, - NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS, - OTEL_EXPORTER_OTLP_ENDPOINT: env.OTEL_EXPORTER_OTLP_ENDPOINT, - }; - - // Filter out undefined values - return Object.fromEntries( - Object.entries($env).filter(([key, value]) => value !== undefined) - ) as Record; -} - -async function loadWorkerManifest() { - const manifest = await readJSONFile("./index.json"); - return WorkerManifest.parse(manifest); -} +new ManagedRunController({ + workerManifest, + env: stdEnv, +}).start(); diff --git a/packages/cli-v3/src/entryPoints/managed/controller.ts b/packages/cli-v3/src/entryPoints/managed/controller.ts new file mode 100644 index 0000000000..35fec13932 --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/controller.ts @@ -0,0 +1,552 @@ +import { WorkerManifest } from "@trigger.dev/core/v3"; +import { + WarmStartClient, + WORKLOAD_HEADERS, + type WorkloadClientToServerEvents, + WorkloadHttpClient, + type WorkloadServerToClientEvents, +} from "@trigger.dev/core/v3/workers"; +import { io, type Socket } from "socket.io-client"; +import { RunnerEnv } from "./env.js"; +import { RunLogger, SendDebugLogOptions } from "./logger.js"; +import { EnvObject } from "std-env"; +import { RunExecution } from "./execution.js"; +import { tryCatch } from "@trigger.dev/core/utils"; + +type ManagedRunControllerOptions = { + workerManifest: WorkerManifest; + env: EnvObject; +}; + +type SupervisorSocket = Socket; + +export class ManagedRunController { + private readonly env: RunnerEnv; + private readonly workerManifest: WorkerManifest; + private readonly httpClient: WorkloadHttpClient; + private readonly warmStartClient: WarmStartClient | undefined; + private socket: SupervisorSocket; + private readonly logger: RunLogger; + + private warmStartCount = 0; + private restoreCount = 0; + + private currentExecution: RunExecution | null = null; + + constructor(opts: ManagedRunControllerOptions) { + const env = new RunnerEnv(opts.env); + this.env = env; + + this.workerManifest = opts.workerManifest; + + this.httpClient = new WorkloadHttpClient({ + workerApiUrl: this.workerApiUrl, + runnerId: this.runnerId, + deploymentId: env.TRIGGER_DEPLOYMENT_ID, + deploymentVersion: env.TRIGGER_DEPLOYMENT_VERSION, + projectRef: env.TRIGGER_PROJECT_REF, + }); + + this.logger = new RunLogger({ + httpClient: this.httpClient, + env, + }); + + const properties = { + ...env.raw, + TRIGGER_POD_SCHEDULED_AT_MS: env.TRIGGER_POD_SCHEDULED_AT_MS.toISOString(), + TRIGGER_DEQUEUED_AT_MS: env.TRIGGER_DEQUEUED_AT_MS.toISOString(), + }; + + this.sendDebugLog({ + runId: env.TRIGGER_RUN_ID, + message: "Creating run controller", + properties, + }); + + if (env.TRIGGER_WARM_START_URL) { + this.warmStartClient = new WarmStartClient({ + apiUrl: new URL(env.TRIGGER_WARM_START_URL), + controllerId: env.TRIGGER_WORKLOAD_CONTROLLER_ID, + deploymentId: env.TRIGGER_DEPLOYMENT_ID, + deploymentVersion: env.TRIGGER_DEPLOYMENT_VERSION, + machineCpu: env.TRIGGER_MACHINE_CPU, + machineMemory: env.TRIGGER_MACHINE_MEMORY, + }); + } + + // Websocket notifications are only an optimisation so we don't need to wait for a successful connection + this.socket = this.createSupervisorSocket(); + + process.on("SIGTERM", async () => { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Received SIGTERM, stopping worker", + }); + await this.stop(); + }); + } + + get metrics() { + return { + warmStartCount: this.warmStartCount, + restoreCount: this.restoreCount, + }; + } + + get runnerId() { + return this.env.TRIGGER_RUNNER_ID; + } + + get successExitCode() { + return this.env.TRIGGER_SUCCESS_EXIT_CODE; + } + + get failureExitCode() { + return this.env.TRIGGER_FAILURE_EXIT_CODE; + } + + get workerApiUrl() { + return this.env.TRIGGER_SUPERVISOR_API_URL; + } + + get workerInstanceName() { + return this.env.TRIGGER_WORKER_INSTANCE_NAME; + } + + private subscribeToRunNotifications(runFriendlyId: string, snapshotFriendlyId: string) { + this.socket.emit("run:start", { + version: "1", + run: { + friendlyId: runFriendlyId, + }, + snapshot: { + friendlyId: snapshotFriendlyId, + }, + }); + } + + private unsubscribeFromRunNotifications(runFriendlyId: string, snapshotFriendlyId: string) { + this.socket.emit("run:stop", { + version: "1", + run: { + friendlyId: runFriendlyId, + }, + snapshot: { + friendlyId: snapshotFriendlyId, + }, + }); + } + + private get runFriendlyId() { + return this.currentExecution?.runFriendlyId; + } + + private get snapshotFriendlyId() { + return this.currentExecution?.currentSnapshotFriendlyId; + } + + private lockedRunExecution: Promise | null = null; + + private async startRunExecution({ + runFriendlyId, + snapshotFriendlyId, + dequeuedAt, + podScheduledAt, + isWarmStart, + previousRunId, + }: { + runFriendlyId: string; + snapshotFriendlyId: string; + dequeuedAt?: Date; + podScheduledAt?: Date; + isWarmStart?: boolean; + previousRunId?: string; + }) { + this.sendDebugLog({ + runId: runFriendlyId, + message: "startAndExecuteRunAttempt()", + properties: { previousRunId }, + }); + + if (this.lockedRunExecution) { + this.sendDebugLog({ + runId: runFriendlyId, + message: "startAndExecuteRunAttempt: execution already locked", + }); + return; + } + + const execution = async () => { + if (!this.currentExecution || !this.currentExecution.isPreparedForNextRun) { + this.currentExecution = new RunExecution({ + workerManifest: this.workerManifest, + env: this.env, + httpClient: this.httpClient, + logger: this.logger, + }); + } + + // Subscribe to run notifications + this.subscribeToRunNotifications(runFriendlyId, snapshotFriendlyId); + + // We're prepared for the next run so we can start executing + await this.currentExecution.execute({ + runFriendlyId, + snapshotFriendlyId, + dequeuedAt, + podScheduledAt, + isWarmStart, + }); + }; + + this.lockedRunExecution = execution(); + + const [error] = await tryCatch(this.lockedRunExecution); + + if (error) { + this.sendDebugLog({ + runId: runFriendlyId, + message: "Error during execution", + properties: { error: error.message }, + }); + } + + const metrics = this.currentExecution?.metrics; + + if (metrics?.restoreCount) { + this.restoreCount += metrics.restoreCount; + } + + this.lockedRunExecution = null; + this.unsubscribeFromRunNotifications(runFriendlyId, snapshotFriendlyId); + this.waitForNextRun(); + } + + private waitForNextRunLock = false; + + /** + * This will eagerly create a new run execution. It will never throw, but may exit + * the process on any errors or when no runs are available after the configured duration. + */ + private async waitForNextRun() { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun()", + }); + + if (this.waitForNextRunLock) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: already in progress, skipping", + }); + return; + } + + if (this.lockedRunExecution) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: execution locked, skipping", + }); + return; + } + + this.waitForNextRunLock = true; + + try { + if (!this.warmStartClient) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: warm starts disabled, shutting down", + }); + this.exitProcess(this.successExitCode); + } + + const previousRunId = this.runFriendlyId; + + if (this.currentExecution?.taskRunEnv) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: eagerly recreating task run process", + }); + + const previousTaskRunEnv = this.currentExecution.taskRunEnv; + + this.currentExecution = new RunExecution({ + workerManifest: this.workerManifest, + env: this.env, + httpClient: this.httpClient, + logger: this.logger, + }).prepareForExecution({ + taskRunEnv: previousTaskRunEnv, + }); + } + + // Check the service is up and get additional warm start config + const connect = await this.warmStartClient.connect(); + + if (!connect.success) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: failed to connect to warm start service", + properties: { + warmStartUrl: this.env.TRIGGER_WARM_START_URL, + error: connect.error, + }, + }); + this.exitProcess(this.successExitCode); + } + + const connectionTimeoutMs = + connect.data.connectionTimeoutMs ?? this.env.TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS; + const keepaliveMs = connect.data.keepaliveMs ?? this.env.TRIGGER_WARM_START_KEEPALIVE_MS; + + const warmStartConfig = { + connectionTimeoutMs, + keepaliveMs, + }; + + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: connected to warm start service", + properties: warmStartConfig, + }); + + if (!connectionTimeoutMs || !keepaliveMs) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: warm starts disabled after connect", + properties: warmStartConfig, + }); + this.exitProcess(this.successExitCode); + } + + const nextRun = await this.warmStartClient.warmStart({ + workerInstanceName: this.workerInstanceName, + connectionTimeoutMs, + keepaliveMs, + }); + + if (!nextRun) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: warm start failed, shutting down", + properties: warmStartConfig, + }); + this.exitProcess(this.successExitCode); + } + + this.warmStartCount++; + + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: got next run", + properties: { + ...warmStartConfig, + nextRunId: nextRun.run.friendlyId, + }, + }); + + this.startRunExecution({ + runFriendlyId: nextRun.run.friendlyId, + snapshotFriendlyId: nextRun.snapshot.friendlyId, + dequeuedAt: nextRun.dequeuedAt, + isWarmStart: true, + previousRunId, + }).finally(() => {}); + } catch (error) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: unexpected error", + properties: { error: error instanceof Error ? error.message : String(error) }, + }); + this.exitProcess(this.failureExitCode); + } finally { + this.waitForNextRunLock = false; + } + } + + private exitProcess(code?: number): never { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Exiting process", + properties: { code }, + }); + + this.currentExecution?.exit(); + + process.exit(code); + } + + createSupervisorSocket(): SupervisorSocket { + const wsUrl = new URL("/workload", this.workerApiUrl); + + const socket = io(wsUrl.href, { + transports: ["websocket"], + extraHeaders: { + [WORKLOAD_HEADERS.DEPLOYMENT_ID]: this.env.TRIGGER_DEPLOYMENT_ID, + [WORKLOAD_HEADERS.RUNNER_ID]: this.env.TRIGGER_RUNNER_ID, + }, + }) satisfies SupervisorSocket; + + socket.on("run:notify", async ({ version, run }) => { + this.sendDebugLog({ + runId: run.friendlyId, + message: "run:notify received by runner", + properties: { version, runId: run.friendlyId }, + }); + + if (!this.runFriendlyId) { + this.sendDebugLog({ + runId: run.friendlyId, + message: "run:notify: ignoring notification, no local run ID", + properties: { + currentRunId: this.runFriendlyId, + currentSnapshotId: this.snapshotFriendlyId, + }, + }); + return; + } + + if (run.friendlyId !== this.runFriendlyId) { + this.sendDebugLog({ + runId: run.friendlyId, + message: "run:notify: ignoring notification for different run", + properties: { + currentRunId: this.runFriendlyId, + currentSnapshotId: this.snapshotFriendlyId, + notificationRunId: run.friendlyId, + }, + }); + return; + } + + const latestSnapshot = await this.httpClient.getRunExecutionData(this.runFriendlyId); + + if (!latestSnapshot.success) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "run:notify: failed to get latest snapshot data", + properties: { + currentRunId: this.runFriendlyId, + currentSnapshotId: this.snapshotFriendlyId, + error: latestSnapshot.error, + }, + }); + return; + } + + const runExecutionData = latestSnapshot.data.execution; + + if (!this.currentExecution) { + this.sendDebugLog({ + runId: runExecutionData.run.friendlyId, + message: "handleSnapshotChange: no current execution", + }); + return; + } + + const [error] = await tryCatch(this.currentExecution.handleSnapshotChange(runExecutionData)); + + if (error) { + this.sendDebugLog({ + runId: runExecutionData.run.friendlyId, + message: "handleSnapshotChange: unexpected error", + properties: { error: error.message }, + }); + } + }); + + socket.on("connect", () => { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Socket connected to supervisor", + }); + + // This should handle the case where we reconnect after being restored + if ( + this.runFriendlyId && + this.snapshotFriendlyId && + this.runFriendlyId !== this.env.TRIGGER_RUN_ID + ) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Subscribing to notifications for in-progress run", + }); + this.subscribeToRunNotifications(this.runFriendlyId, this.snapshotFriendlyId); + } + }); + + socket.on("connect_error", (error) => { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Socket connection error", + properties: { error: error instanceof Error ? error.message : String(error) }, + }); + }); + + socket.on("disconnect", (reason, description) => { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Socket disconnected from supervisor", + properties: { reason, description: description?.toString() }, + }); + }); + + return socket; + } + + async cancelAttempt(runId: string) { + this.sendDebugLog({ + runId, + message: "cancelling attempt", + properties: { runId }, + }); + + await this.currentExecution?.cancel(); + } + + start() { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Starting up", + }); + + // If we have run and snapshot IDs, we can start an attempt immediately + if (this.env.TRIGGER_RUN_ID && this.env.TRIGGER_SNAPSHOT_ID) { + this.startRunExecution({ + runFriendlyId: this.env.TRIGGER_RUN_ID, + snapshotFriendlyId: this.env.TRIGGER_SNAPSHOT_ID, + dequeuedAt: this.env.TRIGGER_DEQUEUED_AT_MS, + podScheduledAt: this.env.TRIGGER_POD_SCHEDULED_AT_MS, + }).finally(() => {}); + return; + } + + // ..otherwise we need to wait for a run + this.waitForNextRun(); + return; + } + + async stop() { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "Shutting down", + }); + + await this.currentExecution?.cancel(); + this.socket.close(); + } + + sendDebugLog(opts: SendDebugLogOptions) { + this.logger.sendDebugLog({ + ...opts, + message: `[controller] ${opts.message}`, + properties: { + ...opts.properties, + runnerWarmStartCount: this.warmStartCount, + runnerRestoreCount: this.restoreCount, + }, + }); + } +} diff --git a/packages/cli-v3/src/entryPoints/managed/env.ts b/packages/cli-v3/src/entryPoints/managed/env.ts new file mode 100644 index 0000000000..1355f68d82 --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/env.ts @@ -0,0 +1,223 @@ +import { randomUUID } from "node:crypto"; +import { Metadata } from "./overrides.js"; +import { z } from "zod"; +import { EnvObject } from "std-env"; + +const DateEnv = z + .string() + .transform((val) => new Date(parseInt(val, 10))) + .pipe(z.date()); + +// All IDs are friendly IDs +const Env = z.object({ + // Set at build time + TRIGGER_CONTENT_HASH: z.string(), + TRIGGER_DEPLOYMENT_ID: z.string(), + TRIGGER_DEPLOYMENT_VERSION: z.string(), + TRIGGER_PROJECT_ID: z.string(), + TRIGGER_PROJECT_REF: z.string(), + NODE_ENV: z.string().default("production"), + NODE_EXTRA_CA_CERTS: z.string().optional(), + + // Set at runtime + TRIGGER_WORKLOAD_CONTROLLER_ID: z.string().default(`controller_${randomUUID()}`), + TRIGGER_ENV_ID: z.string(), + TRIGGER_RUN_ID: z.string().optional(), // This is only useful for cold starts + TRIGGER_SNAPSHOT_ID: z.string().optional(), // This is only useful for cold starts + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), + TRIGGER_WARM_START_URL: z.string().optional(), + TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS: z.coerce.number().default(30_000), + TRIGGER_WARM_START_KEEPALIVE_MS: z.coerce.number().default(300_000), + TRIGGER_MACHINE_CPU: z.string().default("0"), + TRIGGER_MACHINE_MEMORY: z.string().default("0"), + TRIGGER_RUNNER_ID: z.string(), + TRIGGER_METADATA_URL: z.string().optional(), + TRIGGER_PRE_SUSPEND_WAIT_MS: z.coerce.number().default(200), + + // Timeline metrics + TRIGGER_POD_SCHEDULED_AT_MS: DateEnv, + TRIGGER_DEQUEUED_AT_MS: DateEnv, + + // May be overridden + TRIGGER_SUPERVISOR_API_PROTOCOL: z.enum(["http", "https"]), + TRIGGER_SUPERVISOR_API_DOMAIN: z.string(), + TRIGGER_SUPERVISOR_API_PORT: z.coerce.number(), + TRIGGER_WORKER_INSTANCE_NAME: z.string(), + TRIGGER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), + TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().default(5), + TRIGGER_SUCCESS_EXIT_CODE: z.coerce.number().default(0), + TRIGGER_FAILURE_EXIT_CODE: z.coerce.number().default(1), +}); + +type Env = z.infer; + +export class RunnerEnv { + private env: Env; + public readonly initial: Env; + + constructor(env: EnvObject) { + this.env = Env.parse(env); + this.initial = { ...this.env }; + } + + get raw() { + return this.env; + } + + // Base environment variables + get NODE_ENV() { + return this.env.NODE_ENV; + } + get NODE_EXTRA_CA_CERTS() { + return this.env.NODE_EXTRA_CA_CERTS; + } + get OTEL_EXPORTER_OTLP_ENDPOINT() { + return this.env.OTEL_EXPORTER_OTLP_ENDPOINT; + } + get TRIGGER_CONTENT_HASH() { + return this.env.TRIGGER_CONTENT_HASH; + } + get TRIGGER_DEPLOYMENT_ID() { + return this.env.TRIGGER_DEPLOYMENT_ID; + } + get TRIGGER_DEPLOYMENT_VERSION() { + return this.env.TRIGGER_DEPLOYMENT_VERSION; + } + get TRIGGER_PROJECT_ID() { + return this.env.TRIGGER_PROJECT_ID; + } + get TRIGGER_PROJECT_REF() { + return this.env.TRIGGER_PROJECT_REF; + } + get TRIGGER_WORKLOAD_CONTROLLER_ID() { + return this.env.TRIGGER_WORKLOAD_CONTROLLER_ID; + } + get TRIGGER_ENV_ID() { + return this.env.TRIGGER_ENV_ID; + } + get TRIGGER_RUN_ID() { + return this.env.TRIGGER_RUN_ID; + } + get TRIGGER_SNAPSHOT_ID() { + return this.env.TRIGGER_SNAPSHOT_ID; + } + get TRIGGER_WARM_START_URL() { + return this.env.TRIGGER_WARM_START_URL; + } + get TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS() { + return this.env.TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS; + } + get TRIGGER_WARM_START_KEEPALIVE_MS() { + return this.env.TRIGGER_WARM_START_KEEPALIVE_MS; + } + get TRIGGER_MACHINE_CPU() { + return this.env.TRIGGER_MACHINE_CPU; + } + get TRIGGER_MACHINE_MEMORY() { + return this.env.TRIGGER_MACHINE_MEMORY; + } + get TRIGGER_METADATA_URL() { + return this.env.TRIGGER_METADATA_URL; + } + get TRIGGER_PRE_SUSPEND_WAIT_MS() { + return this.env.TRIGGER_PRE_SUSPEND_WAIT_MS; + } + get TRIGGER_POD_SCHEDULED_AT_MS() { + return this.env.TRIGGER_POD_SCHEDULED_AT_MS; + } + get TRIGGER_DEQUEUED_AT_MS() { + return this.env.TRIGGER_DEQUEUED_AT_MS; + } + + // Overridable values + get TRIGGER_SUCCESS_EXIT_CODE() { + return this.env.TRIGGER_SUCCESS_EXIT_CODE; + } + get TRIGGER_FAILURE_EXIT_CODE() { + return this.env.TRIGGER_FAILURE_EXIT_CODE; + } + get TRIGGER_HEARTBEAT_INTERVAL_SECONDS() { + return this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS; + } + get TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS() { + return this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS; + } + get TRIGGER_WORKER_INSTANCE_NAME() { + return this.env.TRIGGER_WORKER_INSTANCE_NAME; + } + get TRIGGER_RUNNER_ID() { + return this.env.TRIGGER_RUNNER_ID; + } + + get TRIGGER_SUPERVISOR_API_PROTOCOL() { + return this.env.TRIGGER_SUPERVISOR_API_PROTOCOL; + } + + get TRIGGER_SUPERVISOR_API_DOMAIN() { + return this.env.TRIGGER_SUPERVISOR_API_DOMAIN; + } + + get TRIGGER_SUPERVISOR_API_PORT() { + return this.env.TRIGGER_SUPERVISOR_API_PORT; + } + + get TRIGGER_SUPERVISOR_API_URL() { + return `${this.TRIGGER_SUPERVISOR_API_PROTOCOL}://${this.TRIGGER_SUPERVISOR_API_DOMAIN}:${this.TRIGGER_SUPERVISOR_API_PORT}`; + } + + /** Overrides existing env vars with new values */ + override(overrides: Metadata) { + if (overrides.TRIGGER_SUCCESS_EXIT_CODE) { + this.env.TRIGGER_SUCCESS_EXIT_CODE = overrides.TRIGGER_SUCCESS_EXIT_CODE; + } + + if (overrides.TRIGGER_FAILURE_EXIT_CODE) { + this.env.TRIGGER_FAILURE_EXIT_CODE = overrides.TRIGGER_FAILURE_EXIT_CODE; + } + + if (overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS) { + this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS = overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS; + } + + if (overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS) { + this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS = + overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS; + } + + if (overrides.TRIGGER_WORKER_INSTANCE_NAME) { + this.env.TRIGGER_WORKER_INSTANCE_NAME = overrides.TRIGGER_WORKER_INSTANCE_NAME; + } + + if (overrides.TRIGGER_SUPERVISOR_API_PROTOCOL) { + this.env.TRIGGER_SUPERVISOR_API_PROTOCOL = overrides.TRIGGER_SUPERVISOR_API_PROTOCOL as + | "http" + | "https"; + } + + if (overrides.TRIGGER_SUPERVISOR_API_DOMAIN) { + this.env.TRIGGER_SUPERVISOR_API_DOMAIN = overrides.TRIGGER_SUPERVISOR_API_DOMAIN; + } + + if (overrides.TRIGGER_SUPERVISOR_API_PORT) { + this.env.TRIGGER_SUPERVISOR_API_PORT = overrides.TRIGGER_SUPERVISOR_API_PORT; + } + + if (overrides.TRIGGER_RUNNER_ID) { + this.env.TRIGGER_RUNNER_ID = overrides.TRIGGER_RUNNER_ID; + } + } + + // Helper method to get process env for task runs + gatherProcessEnv(): Record { + const $env = { + NODE_ENV: this.NODE_ENV, + NODE_EXTRA_CA_CERTS: this.NODE_EXTRA_CA_CERTS, + OTEL_EXPORTER_OTLP_ENDPOINT: this.OTEL_EXPORTER_OTLP_ENDPOINT, + }; + + // Filter out undefined values + return Object.fromEntries( + Object.entries($env).filter(([key, value]) => value !== undefined) + ) as Record; + } +} diff --git a/packages/cli-v3/src/entryPoints/managed/execution.ts b/packages/cli-v3/src/entryPoints/managed/execution.ts new file mode 100644 index 0000000000..bfefeee27f --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/execution.ts @@ -0,0 +1,923 @@ +import { + type CompleteRunAttemptResult, + type RunExecutionData, + SuspendedProcessError, + type TaskRunExecutionMetrics, + type TaskRunExecutionResult, + TaskRunExecutionRetry, + type TaskRunFailedExecutionResult, + WorkerManifest, +} from "@trigger.dev/core/v3"; +import { type WorkloadRunAttemptStartResponseBody } from "@trigger.dev/core/v3/workers"; +import { TaskRunProcess } from "../../executions/taskRunProcess.js"; +import { RunLogger, SendDebugLogOptions } from "./logger.js"; +import { RunnerEnv } from "./env.js"; +import { WorkloadHttpClient } from "@trigger.dev/core/v3/workers"; +import { setTimeout as sleep } from "timers/promises"; +import { RunExecutionHeartbeat } from "./heartbeat.js"; +import { RunExecutionSnapshotPoller } from "./poller.js"; +import { assertExhaustive, tryCatch } from "@trigger.dev/core/utils"; +import { MetadataClient } from "./overrides.js"; +import { randomBytes } from "node:crypto"; + +class ExecutionAbortError extends Error { + constructor(message: string) { + super(message); + this.name = "ExecutionAbortError"; + } +} + +type RunExecutionOptions = { + workerManifest: WorkerManifest; + env: RunnerEnv; + httpClient: WorkloadHttpClient; + logger: RunLogger; +}; + +type RunExecutionPrepareOptions = { + taskRunEnv: Record; +}; + +type RunExecutionRunOptions = { + runFriendlyId: string; + snapshotFriendlyId: string; + dequeuedAt?: Date; + podScheduledAt?: Date; + isWarmStart?: boolean; +}; + +export class RunExecution { + private id: string; + private executionAbortController: AbortController; + + private _runFriendlyId?: string; + private currentSnapshotId?: string; + private currentTaskRunEnv?: Record; + + private dequeuedAt?: Date; + private podScheduledAt?: Date; + private readonly workerManifest: WorkerManifest; + private readonly env: RunnerEnv; + private readonly httpClient: WorkloadHttpClient; + private readonly logger: RunLogger; + private restoreCount: number; + + private taskRunProcess?: TaskRunProcess; + private runHeartbeat?: RunExecutionHeartbeat; + private snapshotPoller?: RunExecutionSnapshotPoller; + + constructor(opts: RunExecutionOptions) { + this.id = randomBytes(4).toString("hex"); + this.workerManifest = opts.workerManifest; + this.env = opts.env; + this.httpClient = opts.httpClient; + this.logger = opts.logger; + + this.restoreCount = 0; + this.executionAbortController = new AbortController(); + } + + /** + * Prepares the execution with task run environment variables. + * This should be called before executing, typically after a successful run to prepare for the next one. + */ + public prepareForExecution(opts: RunExecutionPrepareOptions): this { + if (this.taskRunProcess) { + throw new Error("prepareForExecution called after process was already created"); + } + + if (this.isPreparedForNextRun) { + throw new Error("prepareForExecution called after execution was already prepared"); + } + + this.taskRunProcess = this.createTaskRunProcess({ + envVars: opts.taskRunEnv, + isWarmStart: true, + }); + + return this; + } + + private createTaskRunProcess({ + envVars, + isWarmStart, + }: { + envVars: Record; + isWarmStart?: boolean; + }) { + return new TaskRunProcess({ + workerManifest: this.workerManifest, + env: { + ...envVars, + ...this.env.gatherProcessEnv(), + }, + serverWorker: { + id: "managed", + contentHash: this.env.TRIGGER_CONTENT_HASH, + version: this.env.TRIGGER_DEPLOYMENT_VERSION, + engine: "V2", + }, + machineResources: { + cpu: Number(this.env.TRIGGER_MACHINE_CPU), + memory: Number(this.env.TRIGGER_MACHINE_MEMORY), + }, + isWarmStart, + }).initialize(); + } + + /** + * Returns true if the execution has been prepared with task run env. + */ + get isPreparedForNextRun(): boolean { + return !!this.taskRunProcess?.isPreparedForNextRun; + } + + /** + * Called by the RunController when it receives a websocket notification + * or when the snapshot poller detects a change + */ + public async handleSnapshotChange(runData: RunExecutionData): Promise { + const { run, snapshot, completedWaitpoints } = runData; + + const snapshotMetadata = { + incomingRunId: run.friendlyId, + incomingSnapshotId: snapshot.friendlyId, + completedWaitpoints: completedWaitpoints.length, + }; + + // Ensure we have run details + if (!this.runFriendlyId || !this.currentSnapshotId) { + this.sendDebugLog( + "handleSnapshotChange: missing run or snapshot ID", + snapshotMetadata, + run.friendlyId + ); + return; + } + + // Ensure the run ID matches + if (run.friendlyId !== this.runFriendlyId) { + // Send debug log to both runs + this.sendDebugLog("handleSnapshotChange: mismatched run IDs", snapshotMetadata); + this.sendDebugLog( + "handleSnapshotChange: mismatched run IDs", + snapshotMetadata, + run.friendlyId + ); + return; + } + + this.sendDebugLog(`enqueued snapshot change: ${snapshot.executionStatus}`, snapshotMetadata); + + this.snapshotChangeQueue.push(runData); + await this.processSnapshotChangeQueue(); + } + + private snapshotChangeQueue: RunExecutionData[] = []; + private snapshotChangeQueueLock = false; + + private async processSnapshotChangeQueue() { + if (this.snapshotChangeQueueLock) { + return; + } + + this.snapshotChangeQueueLock = true; + while (this.snapshotChangeQueue.length > 0) { + const runData = this.snapshotChangeQueue.shift(); + + if (!runData) { + continue; + } + + const [error] = await tryCatch(this.processSnapshotChange(runData)); + + if (error) { + this.sendDebugLog("Failed to process snapshot change", { error: error.message }); + } + } + this.snapshotChangeQueueLock = false; + } + + private async processSnapshotChange(runData: RunExecutionData): Promise { + const { run, snapshot, completedWaitpoints } = runData; + + const snapshotMetadata = { + incomingSnapshotId: snapshot.friendlyId, + completedWaitpoints: completedWaitpoints.length, + }; + + // Check if the incoming snapshot is newer than the current one + if (!this.currentSnapshotId || snapshot.friendlyId < this.currentSnapshotId) { + this.sendDebugLog( + "handleSnapshotChange: received older snapshot, skipping", + snapshotMetadata + ); + return; + } + + if (snapshot.friendlyId === this.currentSnapshotId) { + this.sendDebugLog("handleSnapshotChange: snapshot not changed", snapshotMetadata); + return; + } + + this.sendDebugLog(`snapshot change: ${snapshot.executionStatus}`, snapshotMetadata); + + // Reset the snapshot poll interval so we don't do unnecessary work + this.snapshotPoller?.resetCurrentInterval(); + + // Update internal state + this.currentSnapshotId = snapshot.friendlyId; + + // Update services + this.runHeartbeat?.updateSnapshotId(snapshot.friendlyId); + this.snapshotPoller?.updateSnapshotId(snapshot.friendlyId); + + switch (snapshot.executionStatus) { + case "PENDING_CANCEL": { + const [error] = await tryCatch(this.cancel()); + + if (error) { + this.sendDebugLog("snapshot change: failed to cancel attempt", { + ...snapshotMetadata, + error: error.message, + }); + } + + this.abortExecution(); + return; + } + case "FINISHED": { + this.sendDebugLog("Run is finished", snapshotMetadata); + + // Pretend we've just suspended the run. This will kill the process without failing the run. + await this.taskRunProcess?.suspend(); + return; + } + case "QUEUED_EXECUTING": + case "EXECUTING_WITH_WAITPOINTS": { + this.sendDebugLog("Run is executing with waitpoints", snapshotMetadata); + + const [error] = await tryCatch(this.taskRunProcess?.cleanup(false)); + + if (error) { + this.sendDebugLog("Failed to cleanup task run process, carrying on", { + ...snapshotMetadata, + error: error.message, + }); + } + + if (snapshot.friendlyId !== this.currentSnapshotId) { + this.sendDebugLog("Snapshot changed after cleanup, abort", snapshotMetadata); + + this.abortExecution(); + return; + } + + await sleep(this.env.TRIGGER_PRE_SUSPEND_WAIT_MS); + + if (snapshot.friendlyId !== this.currentSnapshotId) { + this.sendDebugLog("Snapshot changed after suspend threshold, abort", snapshotMetadata); + + this.abortExecution(); + return; + } + + if (!this.runFriendlyId || !this.currentSnapshotId) { + this.sendDebugLog( + "handleSnapshotChange: Missing run ID or snapshot ID after suspension, abort", + snapshotMetadata + ); + + this.abortExecution(); + return; + } + + const suspendResult = await this.httpClient.suspendRun( + this.runFriendlyId, + this.currentSnapshotId + ); + + if (!suspendResult.success) { + this.sendDebugLog("Failed to suspend run, staying alive 🎶", { + ...snapshotMetadata, + error: suspendResult.error, + }); + + this.sendDebugLog("checkpoint: suspend request failed", { + ...snapshotMetadata, + error: suspendResult.error, + }); + + // This is fine, we'll wait for the next status change + return; + } + + if (!suspendResult.data.ok) { + this.sendDebugLog("checkpoint: failed to suspend run", { + snapshotId: this.currentSnapshotId, + error: suspendResult.data.error, + }); + + // This is fine, we'll wait for the next status change + return; + } + + this.sendDebugLog("Suspending, any day now 🚬", snapshotMetadata); + + // Wait for next status change + return; + } + case "SUSPENDED": { + this.sendDebugLog("Run was suspended, kill the process", snapshotMetadata); + + // This will kill the process and fail the execution with a SuspendedProcessError + await this.taskRunProcess?.suspend(); + + return; + } + case "PENDING_EXECUTING": { + this.sendDebugLog("Run is pending execution", snapshotMetadata); + + if (completedWaitpoints.length === 0) { + this.sendDebugLog("No waitpoints to complete, nothing to do", snapshotMetadata); + return; + } + + const [error] = await tryCatch(this.restore()); + + if (error) { + this.sendDebugLog("Failed to restore execution", { + ...snapshotMetadata, + error: error.message, + }); + + this.abortExecution(); + return; + } + + return; + } + case "EXECUTING": { + this.sendDebugLog("Run is now executing", snapshotMetadata); + + if (completedWaitpoints.length === 0) { + return; + } + + this.sendDebugLog("Processing completed waitpoints", snapshotMetadata); + + if (!this.taskRunProcess) { + this.sendDebugLog("No task run process, ignoring completed waitpoints", snapshotMetadata); + + this.abortExecution(); + return; + } + + for (const waitpoint of completedWaitpoints) { + this.taskRunProcess.waitpointCompleted(waitpoint); + } + + return; + } + case "RUN_CREATED": + case "QUEUED": { + this.sendDebugLog("Invalid status change", snapshotMetadata); + + this.abortExecution(); + return; + } + default: { + assertExhaustive(snapshot.executionStatus); + } + } + } + + private async startAttempt({ + isWarmStart, + }: { + isWarmStart?: boolean; + }): Promise { + if (!this.runFriendlyId || !this.currentSnapshotId) { + throw new Error("Cannot start attempt: missing run or snapshot ID"); + } + + this.sendDebugLog("Starting attempt"); + + const attemptStartedAt = Date.now(); + + // Check for abort before each major async operation + if (this.executionAbortController.signal.aborted) { + throw new ExecutionAbortError("Execution aborted before start"); + } + + const start = await this.httpClient.startRunAttempt( + this.runFriendlyId, + this.currentSnapshotId, + { isWarmStart } + ); + + if (this.executionAbortController.signal.aborted) { + throw new ExecutionAbortError("Execution aborted after start"); + } + + if (!start.success) { + throw new Error(`Start API call failed: ${start.error}`); + } + + // A snapshot was just created, so update the snapshot ID + this.currentSnapshotId = start.data.snapshot.friendlyId; + + const metrics = this.measureExecutionMetrics({ + attemptCreatedAt: attemptStartedAt, + dequeuedAt: this.dequeuedAt?.getTime(), + podScheduledAt: this.podScheduledAt?.getTime(), + }); + + this.sendDebugLog("Started attempt"); + + return { ...start.data, metrics }; + } + + /** + * Executes the run. This will return when the execution is complete and we should warm start. + * When this returns, the child process will have been cleaned up. + */ + public async execute(runOpts: RunExecutionRunOptions): Promise { + // Setup initial state + this.runFriendlyId = runOpts.runFriendlyId; + this.currentSnapshotId = runOpts.snapshotFriendlyId; + this.dequeuedAt = runOpts.dequeuedAt; + this.podScheduledAt = runOpts.podScheduledAt; + + // Create and start services + this.runHeartbeat = new RunExecutionHeartbeat({ + runFriendlyId: this.runFriendlyId, + snapshotFriendlyId: this.currentSnapshotId, + httpClient: this.httpClient, + logger: this.logger, + heartbeatIntervalSeconds: this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS, + }); + this.snapshotPoller = new RunExecutionSnapshotPoller({ + runFriendlyId: this.runFriendlyId, + snapshotFriendlyId: this.currentSnapshotId, + httpClient: this.httpClient, + logger: this.logger, + snapshotPollIntervalSeconds: this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS, + handleSnapshotChange: this.handleSnapshotChange.bind(this), + }); + + this.runHeartbeat.start(); + this.snapshotPoller.start(); + + const [startError, start] = await tryCatch( + this.startAttempt({ isWarmStart: runOpts.isWarmStart }) + ); + + if (startError) { + this.sendDebugLog("Failed to start attempt", { error: startError.message }); + + this.stopServices(); + return; + } + + const [executeError] = await tryCatch(this.executeRunWrapper(start)); + + if (executeError) { + this.sendDebugLog("Failed to execute run", { error: executeError.message }); + + this.stopServices(); + return; + } + + this.stopServices(); + } + + private async executeRunWrapper({ + run, + snapshot, + envVars, + execution, + metrics, + isWarmStart, + }: WorkloadRunAttemptStartResponseBody & { + metrics: TaskRunExecutionMetrics; + isWarmStart?: boolean; + }) { + this.currentTaskRunEnv = envVars; + + const [executeError] = await tryCatch( + this.executeRun({ + run, + snapshot, + envVars, + execution, + metrics, + isWarmStart, + }) + ); + + this.sendDebugLog("Run execution completed", { error: executeError?.message }); + + if (!executeError) { + this.stopServices(); + return; + } + + if (executeError instanceof SuspendedProcessError) { + this.sendDebugLog("Run was suspended", { + run: run.friendlyId, + snapshot: snapshot.friendlyId, + error: executeError.message, + }); + + return; + } + + if (executeError instanceof ExecutionAbortError) { + this.sendDebugLog("Run was interrupted", { + run: run.friendlyId, + snapshot: snapshot.friendlyId, + error: executeError.message, + }); + + return; + } + + this.sendDebugLog("Error while executing attempt", { + error: executeError.message, + runId: run.friendlyId, + snapshotId: snapshot.friendlyId, + }); + + const completion = { + id: execution.run.id, + ok: false, + retry: undefined, + error: TaskRunProcess.parseExecuteError(executeError), + } satisfies TaskRunFailedExecutionResult; + + const [completeError] = await tryCatch(this.complete({ completion })); + + if (completeError) { + this.sendDebugLog("Failed to complete run", { error: completeError.message }); + } + + this.stopServices(); + } + + private async executeRun({ + run, + snapshot, + envVars, + execution, + metrics, + isWarmStart, + }: WorkloadRunAttemptStartResponseBody & { + metrics: TaskRunExecutionMetrics; + isWarmStart?: boolean; + }) { + // To skip this step and eagerly create the task run process, run prepareForExecution first + if (!this.taskRunProcess || !this.isPreparedForNextRun) { + this.taskRunProcess = this.createTaskRunProcess({ envVars, isWarmStart }); + } + + this.sendDebugLog("executing task run process", { runId: execution.run.id }); + + // Set up an abort handler that will cleanup the task run process + this.executionAbortController.signal.addEventListener("abort", async () => { + this.sendDebugLog("Execution aborted during task run, cleaning up process", { + runId: execution.run.id, + }); + + await this.taskRunProcess?.cleanup(true); + }); + + const completion = await this.taskRunProcess.execute( + { + payload: { + execution, + traceContext: execution.run.traceContext ?? {}, + metrics, + }, + messageId: run.friendlyId, + env: envVars, + }, + isWarmStart + ); + + // If we get here, the task completed normally + this.sendDebugLog("Completed run attempt", { attemptSuccess: completion.ok }); + + // The execution has finished, so we can cleanup the task run process. Killing it should be safe. + const [error] = await tryCatch(this.taskRunProcess.cleanup(true)); + + if (error) { + this.sendDebugLog("Failed to cleanup task run process, submitting completion anyway", { + error: error.message, + }); + } + + const [completionError] = await tryCatch(this.complete({ completion })); + + if (completionError) { + this.sendDebugLog("Failed to complete run", { error: completionError.message }); + } + } + + /** + * Cancels the current execution. + */ + public async cancel(): Promise { + this.sendDebugLog("cancelling attempt", { runId: this.runFriendlyId }); + + await this.taskRunProcess?.cancel(); + } + + public exit() { + if (this.isPreparedForNextRun) { + this.taskRunProcess?.forceExit(); + } + } + + private async complete({ completion }: { completion: TaskRunExecutionResult }): Promise { + if (!this.runFriendlyId || !this.currentSnapshotId) { + throw new Error("Cannot complete run: missing run or snapshot ID"); + } + + const completionResult = await this.httpClient.completeRunAttempt( + this.runFriendlyId, + this.currentSnapshotId, + { completion } + ); + + if (!completionResult.success) { + throw new Error(`failed to submit completion: ${completionResult.error}`); + } + + await this.handleCompletionResult({ + completion, + result: completionResult.data.result, + }); + } + + private async handleCompletionResult({ + completion, + result, + }: { + completion: TaskRunExecutionResult; + result: CompleteRunAttemptResult; + }) { + this.sendDebugLog("Handling completion result", { + attemptSuccess: completion.ok, + attemptStatus: result.attemptStatus, + snapshotId: result.snapshot.friendlyId, + runId: result.run.friendlyId, + }); + + // Update our snapshot ID to match the completion result + // This ensures any subsequent API calls use the correct snapshot + this.currentSnapshotId = result.snapshot.friendlyId; + + const { attemptStatus } = result; + + if (attemptStatus === "RUN_FINISHED") { + this.sendDebugLog("Run finished"); + + return; + } + + if (attemptStatus === "RUN_PENDING_CANCEL") { + this.sendDebugLog("Run pending cancel"); + return; + } + + if (attemptStatus === "RETRY_QUEUED") { + this.sendDebugLog("Retry queued"); + + return; + } + + if (attemptStatus === "RETRY_IMMEDIATELY") { + if (completion.ok) { + throw new Error("Should retry but completion OK."); + } + + if (!completion.retry) { + throw new Error("Should retry but missing retry params."); + } + + await this.retryImmediately({ retryOpts: completion.retry }); + return; + } + + assertExhaustive(attemptStatus); + } + + private measureExecutionMetrics({ + attemptCreatedAt, + dequeuedAt, + podScheduledAt, + }: { + attemptCreatedAt: number; + dequeuedAt?: number; + podScheduledAt?: number; + }): TaskRunExecutionMetrics { + const metrics: TaskRunExecutionMetrics = [ + { + name: "start", + event: "create_attempt", + timestamp: attemptCreatedAt, + duration: Date.now() - attemptCreatedAt, + }, + ]; + + if (dequeuedAt) { + metrics.push({ + name: "start", + event: "dequeue", + timestamp: dequeuedAt, + duration: 0, + }); + } + + if (podScheduledAt) { + metrics.push({ + name: "start", + event: "pod_scheduled", + timestamp: podScheduledAt, + duration: 0, + }); + } + + return metrics; + } + + private async retryImmediately({ retryOpts }: { retryOpts: TaskRunExecutionRetry }) { + this.sendDebugLog("Retrying run immediately", { + timestamp: retryOpts.timestamp, + delay: retryOpts.delay, + }); + + const delay = retryOpts.timestamp - Date.now(); + + if (delay > 0) { + // Wait for retry delay to pass + await sleep(delay); + } + + // Start and execute next attempt + const [startError, start] = await tryCatch(this.startAttempt({ isWarmStart: true })); + + if (startError) { + this.sendDebugLog("Failed to start attempt for retry", { error: startError.message }); + + this.stopServices(); + return; + } + + const [executeError] = await tryCatch(this.executeRunWrapper({ ...start, isWarmStart: true })); + + if (executeError) { + this.sendDebugLog("Failed to execute run for retry", { error: executeError.message }); + + this.stopServices(); + return; + } + + this.stopServices(); + } + + /** + * Restores a suspended execution from PENDING_EXECUTING + */ + private async restore(): Promise { + this.sendDebugLog("Restoring execution"); + + if (!this.runFriendlyId || !this.currentSnapshotId) { + throw new Error("Cannot restore: missing run or snapshot ID"); + } + + // Short delay to give websocket time to reconnect + await sleep(100); + + // Process any env overrides + await this.processEnvOverrides(); + + const continuationResult = await this.httpClient.continueRunExecution( + this.runFriendlyId, + this.currentSnapshotId + ); + + if (!continuationResult.success) { + throw new Error(continuationResult.error); + } + + // Track restore count + this.restoreCount++; + } + + /** + * Processes env overrides from the metadata service. Generally called when we're resuming from a suspended state. + */ + private async processEnvOverrides() { + if (!this.env.TRIGGER_METADATA_URL) { + this.sendDebugLog("No metadata URL, skipping env overrides"); + return; + } + + const metadataClient = new MetadataClient(this.env.TRIGGER_METADATA_URL); + const overrides = await metadataClient.getEnvOverrides(); + + if (!overrides) { + this.sendDebugLog("No env overrides, skipping"); + return; + } + + this.sendDebugLog("Processing env overrides", overrides); + + // Override the env with the new values + this.env.override(overrides); + + // Update services with new values + if (overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS) { + this.runHeartbeat?.updateInterval(this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS * 1000); + } + if (overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS) { + this.snapshotPoller?.updateInterval(this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS * 1000); + } + if ( + overrides.TRIGGER_SUPERVISOR_API_PROTOCOL || + overrides.TRIGGER_SUPERVISOR_API_DOMAIN || + overrides.TRIGGER_SUPERVISOR_API_PORT + ) { + this.httpClient.updateApiUrl(this.env.TRIGGER_SUPERVISOR_API_URL); + } + if (overrides.TRIGGER_RUNNER_ID) { + this.httpClient.updateRunnerId(this.env.TRIGGER_RUNNER_ID); + } + } + + sendDebugLog( + message: string, + properties?: SendDebugLogOptions["properties"], + runIdOverride?: string + ) { + this.logger.sendDebugLog({ + runId: runIdOverride ?? this.runFriendlyId, + message: `[execution] ${message}`, + properties: { + ...properties, + runId: this.runFriendlyId, + snapshotId: this.currentSnapshotId, + executionId: this.id, + executionRestoreCount: this.restoreCount, + }, + }); + } + + // Ensure we can only set this once + private set runFriendlyId(id: string) { + if (this._runFriendlyId) { + throw new Error("Run ID already set"); + } + + this._runFriendlyId = id; + } + + public get runFriendlyId(): string | undefined { + return this._runFriendlyId; + } + + public get currentSnapshotFriendlyId(): string | undefined { + return this.currentSnapshotId; + } + + public get taskRunEnv(): Record | undefined { + return this.currentTaskRunEnv; + } + + public get metrics() { + return { + restoreCount: this.restoreCount, + }; + } + + get isAborted() { + return this.executionAbortController.signal.aborted; + } + + private abortExecution() { + if (this.isAborted) { + this.sendDebugLog("Execution already aborted"); + return; + } + + this.executionAbortController.abort(); + this.stopServices(); + } + + private stopServices() { + this.runHeartbeat?.stop(); + this.snapshotPoller?.stop(); + } +} diff --git a/packages/cli-v3/src/entryPoints/managed/heartbeat.ts b/packages/cli-v3/src/entryPoints/managed/heartbeat.ts new file mode 100644 index 0000000000..3b3c820c91 --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/heartbeat.ts @@ -0,0 +1,92 @@ +import { IntervalService } from "@trigger.dev/core/v3"; +import { WorkloadHttpClient } from "@trigger.dev/core/v3/runEngineWorker"; +import { RunLogger } from "./logger.js"; + +export type RunExecutionHeartbeatOptions = { + runFriendlyId: string; + snapshotFriendlyId: string; + httpClient: WorkloadHttpClient; + logger: RunLogger; + heartbeatIntervalSeconds: number; +}; + +export class RunExecutionHeartbeat { + private readonly runFriendlyId: string; + private snapshotFriendlyId: string; + + private readonly httpClient: WorkloadHttpClient; + private readonly logger: RunLogger; + private readonly heartbeatIntervalMs: number; + private readonly heartbeat: IntervalService; + + constructor(opts: RunExecutionHeartbeatOptions) { + this.runFriendlyId = opts.runFriendlyId; + this.snapshotFriendlyId = opts.snapshotFriendlyId; + this.httpClient = opts.httpClient; + this.logger = opts.logger; + this.heartbeatIntervalMs = opts.heartbeatIntervalSeconds * 1000; + + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "RunExecutionHeartbeat", + properties: { + runFriendlyId: this.runFriendlyId, + snapshotFriendlyId: this.snapshotFriendlyId, + heartbeatIntervalSeconds: opts.heartbeatIntervalSeconds, + }, + }); + + this.heartbeat = new IntervalService({ + onInterval: async () => { + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "heartbeat: started", + }); + + const response = await this.httpClient.heartbeatRun( + this.runFriendlyId, + this.snapshotFriendlyId + ); + + if (!response.success) { + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "heartbeat: failed", + properties: { + error: response.error, + }, + }); + } + }, + intervalMs: this.heartbeatIntervalMs, + leadingEdge: false, + onError: async (error) => { + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "Failed to send heartbeat", + properties: { error: error instanceof Error ? error.message : String(error) }, + }); + }, + }); + } + + resetCurrentInterval() { + this.heartbeat.resetCurrentInterval(); + } + + updateSnapshotId(snapshotFriendlyId: string) { + this.snapshotFriendlyId = snapshotFriendlyId; + } + + updateInterval(intervalMs: number) { + this.heartbeat.updateInterval(intervalMs); + } + + start() { + this.heartbeat.start(); + } + + stop() { + this.heartbeat.stop(); + } +} diff --git a/packages/cli-v3/src/entryPoints/managed/logger.ts b/packages/cli-v3/src/entryPoints/managed/logger.ts new file mode 100644 index 0000000000..3a7a045476 --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/logger.ts @@ -0,0 +1,52 @@ +import { + WorkloadDebugLogRequestBody, + WorkloadHttpClient, +} from "@trigger.dev/core/v3/runEngineWorker"; +import { RunnerEnv } from "./env.js"; + +export type SendDebugLogOptions = { + runId?: string; + message: string; + date?: Date; + properties?: WorkloadDebugLogRequestBody["properties"]; +}; + +export type RunLoggerOptions = { + httpClient: WorkloadHttpClient; + env: RunnerEnv; +}; + +export class RunLogger { + private readonly httpClient: WorkloadHttpClient; + private readonly env: RunnerEnv; + + constructor(private readonly opts: RunLoggerOptions) { + this.httpClient = opts.httpClient; + this.env = opts.env; + } + + sendDebugLog({ runId, message, date, properties }: SendDebugLogOptions) { + if (!runId) { + runId = this.env.TRIGGER_RUN_ID; + } + + if (!runId) { + return; + } + + const mergedProperties = { + ...properties, + runId, + runnerId: this.env.TRIGGER_RUNNER_ID, + workerName: this.env.TRIGGER_WORKER_INSTANCE_NAME, + }; + + console.log(message, mergedProperties); + + this.httpClient.sendDebugLog(runId, { + message, + time: date ?? new Date(), + properties: mergedProperties, + }); + } +} diff --git a/packages/cli-v3/src/entryPoints/managed/overrides.ts b/packages/cli-v3/src/entryPoints/managed/overrides.ts new file mode 100644 index 0000000000..872b5ad0b3 --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/overrides.ts @@ -0,0 +1,29 @@ +export type Metadata = { + TRIGGER_SUPERVISOR_API_PROTOCOL: string | undefined; + TRIGGER_SUPERVISOR_API_DOMAIN: string | undefined; + TRIGGER_SUPERVISOR_API_PORT: number | undefined; + TRIGGER_WORKER_INSTANCE_NAME: string | undefined; + TRIGGER_HEARTBEAT_INTERVAL_SECONDS: number | undefined; + TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS: number | undefined; + TRIGGER_SUCCESS_EXIT_CODE: number | undefined; + TRIGGER_FAILURE_EXIT_CODE: number | undefined; + TRIGGER_RUNNER_ID: string | undefined; +}; + +export class MetadataClient { + private readonly url: URL; + + constructor(url: string) { + this.url = new URL(url); + } + + async getEnvOverrides(): Promise { + try { + const response = await fetch(new URL("/env", this.url)); + return response.json(); + } catch (error) { + console.error("Failed to fetch metadata", { error }); + return null; + } + } +} diff --git a/packages/cli-v3/src/entryPoints/managed/poller.ts b/packages/cli-v3/src/entryPoints/managed/poller.ts new file mode 100644 index 0000000000..2decd401ee --- /dev/null +++ b/packages/cli-v3/src/entryPoints/managed/poller.ts @@ -0,0 +1,121 @@ +import { WorkloadHttpClient } from "@trigger.dev/core/v3/runEngineWorker"; +import { RunLogger } from "./logger.js"; +import { IntervalService, RunExecutionData } from "@trigger.dev/core/v3"; + +export type RunExecutionSnapshotPollerOptions = { + runFriendlyId: string; + snapshotFriendlyId: string; + httpClient: WorkloadHttpClient; + logger: RunLogger; + snapshotPollIntervalSeconds: number; + handleSnapshotChange: (execution: RunExecutionData) => Promise; +}; + +export class RunExecutionSnapshotPoller { + private runFriendlyId: string; + private snapshotFriendlyId: string; + + private readonly httpClient: WorkloadHttpClient; + private readonly logger: RunLogger; + private readonly snapshotPollIntervalMs: number; + private readonly handleSnapshotChange: (runData: RunExecutionData) => Promise; + private readonly poller: IntervalService; + + constructor(opts: RunExecutionSnapshotPollerOptions) { + this.runFriendlyId = opts.runFriendlyId; + this.snapshotFriendlyId = opts.snapshotFriendlyId; + this.httpClient = opts.httpClient; + this.logger = opts.logger; + this.snapshotPollIntervalMs = opts.snapshotPollIntervalSeconds * 1000; + this.handleSnapshotChange = opts.handleSnapshotChange; + + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "RunExecutionSnapshotPoller", + properties: { + runFriendlyId: this.runFriendlyId, + snapshotFriendlyId: this.snapshotFriendlyId, + snapshotPollIntervalSeconds: opts.snapshotPollIntervalSeconds, + }, + }); + + this.poller = new IntervalService({ + onInterval: async () => { + if (!this.runFriendlyId) { + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "Skipping snapshot poll, no run ID", + }); + return; + } + + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "Polling for latest snapshot", + }); + + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: `snapshot poll: started`, + properties: { + snapshotId: this.snapshotFriendlyId, + }, + }); + + const response = await this.httpClient.getRunExecutionData(this.runFriendlyId); + + if (!response.success) { + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "Snapshot poll failed", + properties: { + error: response.error, + }, + }); + + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: `snapshot poll: failed`, + properties: { + snapshotId: this.snapshotFriendlyId, + error: response.error, + }, + }); + + return; + } + + await this.handleSnapshotChange(response.data.execution); + }, + intervalMs: this.snapshotPollIntervalMs, + leadingEdge: false, + onError: async (error) => { + this.logger.sendDebugLog({ + runId: this.runFriendlyId, + message: "Failed to poll for snapshot", + properties: { error: error instanceof Error ? error.message : String(error) }, + }); + }, + }); + } + + resetCurrentInterval() { + this.poller.resetCurrentInterval(); + } + + updateSnapshotId(snapshotFriendlyId: string) { + this.snapshotFriendlyId = snapshotFriendlyId; + } + + updateInterval(intervalMs: number) { + this.poller.updateInterval(intervalMs); + } + + start() { + this.poller.start(); + } + + stop() { + this.poller.stop(); + } +} diff --git a/packages/cli-v3/src/executions/taskRunProcess.ts b/packages/cli-v3/src/executions/taskRunProcess.ts index abe7c93389..96f68f0f42 100644 --- a/packages/cli-v3/src/executions/taskRunProcess.ts +++ b/packages/cli-v3/src/executions/taskRunProcess.ts @@ -1,7 +1,7 @@ import { CompletedWaitpoint, ExecutorToWorkerMessageCatalog, - MachinePreset, + MachinePresetResources, ServerBackgroundWorker, TaskRunErrorCodes, TaskRunExecution, @@ -50,7 +50,7 @@ export type TaskRunProcessOptions = { workerManifest: WorkerManifest; serverWorker: ServerBackgroundWorker; env: Record; - machine: MachinePreset; + machineResources: MachinePresetResources; isWarmStart?: boolean; cwd?: string; }; @@ -125,7 +125,7 @@ export class TaskRunProcess { } initialize() { - const { env: $env, workerManifest, cwd, machine } = this.options; + const { env: $env, workerManifest, cwd, machineResources: machine } = this.options; const maxOldSpaceSize = nodeOptionsWithMaxOldSpaceSize(undefined, machine); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 1fb82cc714..4a214a4536 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,7 +2,13 @@ export function assertExhaustive(x: never): never { throw new Error("Unexpected object: " + x); } -export async function tryCatch(promise: Promise): Promise<[null, T] | [E, null]> { +export async function tryCatch( + promise: Promise | undefined +): Promise<[null, T] | [E, null]> { + if (!promise) { + return [null, undefined as T]; + } + try { const data = await promise; return [null, data]; diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 1f1f4d3076..8877393dca 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -67,6 +67,7 @@ export { } from "./utils/ioSerialization.js"; export * from "./utils/imageRef.js"; +export * from "./utils/interval.js"; export * from "./utils/heartbeat.js"; export * from "./config.js"; diff --git a/packages/core/src/v3/machines/index.ts b/packages/core/src/v3/machines/index.ts index 771f4345a4..e5dcb097dc 100644 --- a/packages/core/src/v3/machines/index.ts +++ b/packages/core/src/v3/machines/index.ts @@ -1,14 +1,17 @@ -import { MachinePreset } from "../schemas/common.js"; +import { MachinePresetResources } from "../schemas/common.js"; /** * Returns a value to be used for `--max-old-space-size`. It is in MiB. * Setting this correctly means V8 spends more times running Garbage Collection (GC). * It won't eliminate crashes but it will help avoid them. - * @param {MachinePreset} machine - The machine preset configuration containing memory specifications + * @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications * @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations) * @returns {number} The calculated max old space size in MiB */ -export function maxOldSpaceSizeForMachine(machine: MachinePreset, overhead: number = 0.2): number { +export function maxOldSpaceSizeForMachine( + machine: MachinePresetResources, + overhead: number = 0.2 +): number { return Math.round(machine.memory * 1_024 * (1 - overhead)); } @@ -16,24 +19,27 @@ export function maxOldSpaceSizeForMachine(machine: MachinePreset, overhead: numb * Returns a flag to be used for `--max-old-space-size`. It is in MiB. * Setting this correctly means V8 spends more times running Garbage Collection (GC). * It won't eliminate crashes but it will help avoid them. - * @param {MachinePreset} machine - The machine preset configuration containing memory specifications + * @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications * @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations) * @returns {string} The calculated max old space size flag */ -export function maxOldSpaceSizeFlag(machine: MachinePreset, overhead: number = 0.2): string { +export function maxOldSpaceSizeFlag( + machine: MachinePresetResources, + overhead: number = 0.2 +): string { return `--max-old-space-size=${maxOldSpaceSizeForMachine(machine, overhead)}`; } /** * Takes the existing NODE_OPTIONS value, removes any existing max-old-space-size flag, and adds a new one. * @param {string | undefined} existingOptions - The existing NODE_OPTIONS value - * @param {MachinePreset} machine - The machine preset configuration containing memory specifications + * @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications * @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations) * @returns {string} The updated NODE_OPTIONS value with the new max-old-space-size flag */ export function nodeOptionsWithMaxOldSpaceSize( existingOptions: string | undefined, - machine: MachinePreset, + machine: MachinePresetResources, overhead: number = 0.2 ): string { let options = existingOptions ?? ""; diff --git a/packages/core/src/v3/runEngineWorker/supervisor/http.ts b/packages/core/src/v3/runEngineWorker/supervisor/http.ts index 8814c84c35..4f899e4f22 100644 --- a/packages/core/src/v3/runEngineWorker/supervisor/http.ts +++ b/packages/core/src/v3/runEngineWorker/supervisor/http.ts @@ -81,6 +81,7 @@ export class SupervisorHttpClient { ); } + /** @deprecated Not currently used */ async dequeueFromVersion(deploymentId: string, maxRunCount = 1, runnerId?: string) { return wrapZodFetch( WorkerApiDequeueResponseBody, diff --git a/packages/core/src/v3/runEngineWorker/supervisor/session.ts b/packages/core/src/v3/runEngineWorker/supervisor/session.ts index 8dd90a3b98..747e1dae5e 100644 --- a/packages/core/src/v3/runEngineWorker/supervisor/session.ts +++ b/packages/core/src/v3/runEngineWorker/supervisor/session.ts @@ -8,7 +8,7 @@ import { VERSION } from "../../../version.js"; import { io, Socket } from "socket.io-client"; import { WorkerClientToServerEvents, WorkerServerToClientEvents } from "../types.js"; import { getDefaultWorkerHeaders } from "./util.js"; -import { HeartbeatService } from "../../utils/heartbeat.js"; +import { IntervalService } from "../../utils/interval.js"; type SupervisorSessionOptions = SupervisorClientCommonOptions & { queueConsumerEnabled?: boolean; @@ -29,7 +29,7 @@ export class SupervisorSession extends EventEmitter { private readonly queueConsumerEnabled: boolean; private readonly queueConsumer: RunQueueConsumer; - private readonly heartbeatService: HeartbeatService; + private readonly heartbeat: IntervalService; private readonly heartbeatIntervalSeconds: number; constructor(private opts: SupervisorSessionOptions) { @@ -50,8 +50,8 @@ export class SupervisorSession extends EventEmitter { // TODO: This should be dynamic and set by (or at least overridden by) the platform this.heartbeatIntervalSeconds = opts.heartbeatIntervalSeconds || 30; - this.heartbeatService = new HeartbeatService({ - heartbeat: async () => { + this.heartbeat = new IntervalService({ + onInterval: async () => { console.debug("[SupervisorSession] Sending heartbeat"); const body = this.getHeartbeatBody(); @@ -182,7 +182,7 @@ export class SupervisorSession extends EventEmitter { if (this.queueConsumerEnabled) { console.log("[SupervisorSession] Queue consumer enabled"); this.queueConsumer.start(); - this.heartbeatService.start(); + this.heartbeat.start(); } else { console.warn("[SupervisorSession] Queue consumer disabled"); } @@ -196,7 +196,7 @@ export class SupervisorSession extends EventEmitter { } async stop() { - this.heartbeatService.stop(); + this.heartbeat.stop(); this.runNotificationsSocket?.disconnect(); } diff --git a/packages/core/src/v3/runEngineWorker/workload/http.ts b/packages/core/src/v3/runEngineWorker/workload/http.ts index 9d97896f09..9dde07d35d 100644 --- a/packages/core/src/v3/runEngineWorker/workload/http.ts +++ b/packages/core/src/v3/runEngineWorker/workload/http.ts @@ -165,6 +165,7 @@ export class WorkloadHttpClient { } } + /** @deprecated Not currently used */ async dequeue() { return wrapZodFetch( WorkloadDequeueFromVersionResponseBody, diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 030dd4dcee..a4d37409a2 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -123,6 +123,8 @@ export const MachinePreset = z.object({ export type MachinePreset = z.infer; +export type MachinePresetResources = Pick; + export const TaskRunBuiltInError = z.object({ type: z.literal("BUILT_IN_ERROR"), name: z.string(), diff --git a/packages/core/src/v3/utils/heartbeat.ts b/packages/core/src/v3/utils/heartbeat.ts index 0684bd73c5..c9bb0d97ed 100644 --- a/packages/core/src/v3/utils/heartbeat.ts +++ b/packages/core/src/v3/utils/heartbeat.ts @@ -5,6 +5,9 @@ type HeartbeatServiceOptions = { onError?: (error: unknown) => Promise; }; +/** + * @deprecated Use IntervalService instead + */ export class HeartbeatService { private _heartbeat: () => Promise; private _intervalMs: number; diff --git a/packages/core/src/v3/utils/interval.ts b/packages/core/src/v3/utils/interval.ts new file mode 100644 index 0000000000..59fd0a94cb --- /dev/null +++ b/packages/core/src/v3/utils/interval.ts @@ -0,0 +1,95 @@ +type IntervalServiceOptions = { + onInterval: () => Promise; + onError?: (error: unknown) => Promise; + intervalMs?: number; + leadingEdge?: boolean; +}; + +export class IntervalService { + private _onInterval: () => Promise; + private _onError?: (error: unknown) => Promise; + + private _intervalMs: number; + private _nextInterval: NodeJS.Timeout | undefined; + private _leadingEdge: boolean; + private _isEnabled: boolean; + + constructor(opts: IntervalServiceOptions) { + this._onInterval = opts.onInterval; + this._onError = opts.onError; + + this._intervalMs = opts.intervalMs ?? 45_000; + this._nextInterval = undefined; + this._leadingEdge = opts.leadingEdge ?? false; + this._isEnabled = false; + } + + start() { + if (this._isEnabled) { + return; + } + + this._isEnabled = true; + + if (this._leadingEdge) { + this.#doInterval(); + } else { + this.#scheduleNextInterval(); + } + } + + stop() { + if (!this._isEnabled) { + return; + } + + this._isEnabled = false; + this.#clearNextInterval(); + } + + resetCurrentInterval() { + if (!this._isEnabled) { + return; + } + + this.#clearNextInterval(); + this.#scheduleNextInterval(); + } + + updateInterval(intervalMs: number) { + this._intervalMs = intervalMs; + this.resetCurrentInterval(); + } + + #doInterval = async () => { + this.#clearNextInterval(); + + if (!this._isEnabled) { + return; + } + + try { + await this._onInterval(); + } catch (error) { + if (this._onError) { + try { + await this._onError(error); + } catch (error) { + console.error("Error during interval error handler", error); + } + } + } + + this.#scheduleNextInterval(); + }; + + #clearNextInterval() { + if (this._nextInterval) { + clearTimeout(this._nextInterval); + } + } + + #scheduleNextInterval() { + this._nextInterval = setTimeout(this.#doInterval, this._intervalMs); + } +} From 4efdf64e58ba47c0a8b356e0356256d904165eff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:19:04 +0100 Subject: [PATCH 22/29] Release 4.0.0-v4-beta.2 (#1928) * chore: Update version for release (v4-beta) * Release 4.0.0-v4-beta.2 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- .changeset/pre.json | 1 + packages/build/CHANGELOG.md | 7 +++++++ packages/build/package.json | 4 ++-- packages/cli-v3/CHANGELOG.md | 9 +++++++++ packages/cli-v3/package.json | 6 +++--- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- packages/python/CHANGELOG.md | 9 +++++++++ packages/python/package.json | 12 ++++++------ packages/react-hooks/CHANGELOG.md | 7 +++++++ packages/react-hooks/package.json | 4 ++-- packages/redis-worker/CHANGELOG.md | 7 +++++++ packages/redis-worker/package.json | 4 ++-- packages/rsc/CHANGELOG.md | 7 +++++++ packages/rsc/package.json | 6 +++--- packages/trigger-sdk/CHANGELOG.md | 7 +++++++ packages/trigger-sdk/package.json | 4 ++-- pnpm-lock.yaml | 22 +++++++++++----------- 18 files changed, 92 insertions(+), 32 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 3863ea8ac2..77c5e4675a 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -27,6 +27,7 @@ "red-wasps-cover", "shiny-kiwis-beam", "smart-coins-hammer", + "tricky-houses-invite", "weak-jobs-hide" ] } diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index 5b917475d4..1c7f5b1fad 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/build +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index 1c08f57e42..c3603df759 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/build", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -69,7 +69,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.2", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index 0a236e6dbd..e348b2a688 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,14 @@ # trigger.dev +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Managed run controller performance and reliability improvements ([#1927](https://github.com/triggerdotdev/trigger.dev/pull/1927)) +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + - `@trigger.dev/build@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 88b706a904..33d05f0ca9 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "A Command-Line Interface for Trigger.dev (v3) projects", "type": "module", "license": "MIT", @@ -89,8 +89,8 @@ "@opentelemetry/sdk-trace-base": "1.25.1", "@opentelemetry/sdk-trace-node": "1.25.1", "@opentelemetry/semantic-conventions": "1.25.1", - "@trigger.dev/build": "workspace:4.0.0-v4-beta.1", - "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/build": "workspace:4.0.0-v4-beta.2", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.2", "c12": "^1.11.1", "chalk": "^5.2.0", "chokidar": "^3.6.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index df27f07bc6..5c5f469cc6 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # internal-platform +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Managed run controller performance and reliability improvements ([#1927](https://github.com/triggerdotdev/trigger.dev/pull/1927)) + ## 4.0.0-v4-beta.1 ## 4.0.0-v4-beta.0 diff --git a/packages/core/package.json b/packages/core/package.json index f05d4abe01..b88f34dd51 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "publishConfig": { diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index 7788b36f14..fe13a044d2 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + - `@trigger.dev/build@4.0.0-v4-beta.2` + - `@trigger.dev/sdk@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index ba33d490bc..84c9fed371 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/python", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "Python runtime and build extension for Trigger.dev", "license": "MIT", "publishConfig": { @@ -45,7 +45,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.2", "tinyexec": "^0.3.2" }, "devDependencies": { @@ -56,12 +56,12 @@ "tsx": "4.17.0", "esbuild": "^0.23.0", "@arethetypeswrong/cli": "^0.15.4", - "@trigger.dev/build": "workspace:4.0.0-v4-beta.1", - "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.1" + "@trigger.dev/build": "workspace:4.0.0-v4-beta.2", + "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.2" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.1", - "@trigger.dev/build": "workspace:^4.0.0-v4-beta.1" + "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.2", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.2" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index 3bfb48ccd8..79dc224f6a 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react-hooks +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index f31815d7de..5bafe4631f 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react-hooks", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "trigger.dev react hooks", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.2", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index f911463564..b8abedc9f0 100644 --- a/packages/redis-worker/CHANGELOG.md +++ b/packages/redis-worker/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/redis-worker +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index b5149f0b9d..5573876d71 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/redis-worker", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "Redis worker for trigger.dev", "license": "MIT", "publishConfig": { @@ -23,7 +23,7 @@ "test": "vitest --sequence.concurrent=false --no-file-parallelism" }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.2", "lodash.omit": "^4.5.0", "nanoid": "^5.0.7", "p-limit": "^6.2.0", diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md index 5e92fbd92a..ed63229502 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index 03f53625ac..b200a4cfca 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/rsc", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "trigger.dev rsc", "license": "MIT", "publishConfig": { @@ -37,14 +37,14 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.2", "mlly": "^1.7.1", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.4", - "@trigger.dev/build": "workspace:^4.0.0-v4-beta.1", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.2", "@types/node": "^20.14.14", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index 961cc607fc..e7fe4fe194 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sdk +## 4.0.0-v4-beta.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.2` + ## 4.0.0-v4-beta.1 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 29384d57f1..3f9413ebda 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "4.0.0-v4-beta.1", + "version": "4.0.0-v4-beta.2", "description": "trigger.dev Node.JS SDK", "license": "MIT", "publishConfig": { @@ -52,7 +52,7 @@ "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.52.1", "@opentelemetry/semantic-conventions": "1.25.1", - "@trigger.dev/core": "workspace:4.0.0-v4-beta.1", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.2", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 850239c5a5..c08e679366 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1080,7 +1080,7 @@ importers: packages/build: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../core pkg-types: specifier: ^1.1.3 @@ -1156,10 +1156,10 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../build '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../core c12: specifier: ^1.11.1 @@ -1485,7 +1485,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1495,10 +1495,10 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../trigger-sdk '@types/node': specifier: 20.14.14 @@ -1522,7 +1522,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.1 + specifier: workspace:^4.0.0-v4-beta.2 version: link:../core react: specifier: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1556,7 +1556,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../core lodash.omit: specifier: ^4.5.0 @@ -1602,7 +1602,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.1 + specifier: workspace:^4.0.0-v4-beta.2 version: link:../core mlly: specifier: ^1.7.1 @@ -1618,7 +1618,7 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:^4.0.0-v4-beta.1 + specifier: workspace:^4.0.0-v4-beta.2 version: link:../build '@types/node': specifier: ^20.14.14 @@ -1651,7 +1651,7 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.1 + specifier: workspace:4.0.0-v4-beta.2 version: link:../core chalk: specifier: ^5.2.0 From 8b7d0901652fbc2ef8342f936e9b6c2c4d06537a Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:01:09 +0100 Subject: [PATCH 23/29] Remove batch ID carryover for non-batch waits (#1930) * add failing test case * do not carry over previous batch id when blocking with waitpoint * delete irrelevant test --- .../src/engine/systems/waitpointSystem.ts | 3 +- .../engine/tests/batchTriggerAndWait.test.ts | 226 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index b2eb9e5396..2d4d6b3381 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -422,7 +422,8 @@ export class WaitpointSystem { environmentType: snapshot.environmentType, projectId: snapshot.projectId, organizationId: snapshot.organizationId, - batchId: batch?.id ?? snapshot.batchId ?? undefined, + // Do NOT carry over the batchId from the previous snapshot + batchId: batch?.id, workerId, runnerId, }); diff --git a/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts b/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts index 4af44861c8..072785d6df 100644 --- a/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts +++ b/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts @@ -362,4 +362,230 @@ describe("RunEngine batchTriggerAndWait", () => { engine.quit(); } }); + + containerTest( + "batch ID should not carry over to triggerAndWait", + async ({ prisma, redisOptions }) => { + //create environment + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 20, + }, + queue: { + redis: redisOptions, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const parentTask = "parent-task"; + const batchChildTask = "batch-child-task"; + const triggerAndWaitChildTask = "trigger-and-wait-child-task"; + + //create background worker + await setupBackgroundWorker(engine, authenticatedEnvironment, [ + parentTask, + batchChildTask, + triggerAndWaitChildTask, + ]); + + //create a batch + const batch = await prisma.batchTaskRun.create({ + data: { + friendlyId: generateFriendlyId("batch"), + runtimeEnvironmentId: authenticatedEnvironment.id, + }, + }); + + //trigger the parent run + const parentRun = await engine.trigger( + { + number: 1, + friendlyId: "run_p1234", + environment: authenticatedEnvironment, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queue: `task/${parentTask}`, + isTest: false, + tags: [], + }, + prisma + ); + + //dequeue parent + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: parentRun.masterQueue, + maxRunCount: 10, + }); + + //create an attempt + const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(initialExecutionData); + const attemptResult = await engine.startRunAttempt({ + runId: parentRun.id, + snapshotId: initialExecutionData.snapshot.id, + }); + + //block using the batch + await engine.blockRunWithCreatedBatch({ + runId: parentRun.id, + batchId: batch.id, + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + const afterBlockedByBatch = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(afterBlockedByBatch); + expect(afterBlockedByBatch.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + expect(afterBlockedByBatch.batch?.id).toBe(batch.id); + + //create a batch child + const batchChild = await engine.trigger( + { + number: 1, + friendlyId: "run_c1234", + environment: authenticatedEnvironment, + taskIdentifier: batchChildTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queue: `task/${batchChildTask}`, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + batch: { id: batch.id, index: 0 }, + }, + prisma + ); + + const parentAfterBatchChild = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentAfterBatchChild); + expect(parentAfterBatchChild.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + expect(parentAfterBatchChild.batch?.id).toBe(batch.id); + + await engine.unblockRunForCreatedBatch({ + runId: parentRun.id, + batchId: batch.id, + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + + //dequeue and start the batch child + const dequeuedBatchChild = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: batchChild.masterQueue, + maxRunCount: 1, + }); + + expect(dequeuedBatchChild.length).toBe(1); + + const batchChildAttempt = await engine.startRunAttempt({ + runId: batchChild.id, + snapshotId: dequeuedBatchChild[0].snapshot.id, + }); + + //complete the batch child + await engine.completeRunAttempt({ + runId: batchChildAttempt.run.id, + snapshotId: batchChildAttempt.snapshot.id, + completion: { + id: batchChild.id, + ok: true, + output: '{"foo":"bar"}', + outputType: "application/json", + }, + }); + + await setTimeout(500); + + const runWaitpointsAfterBatchChild = await prisma.taskRunWaitpoint.findMany({ + where: { + taskRunId: parentRun.id, + }, + include: { + waitpoint: true, + }, + }); + expect(runWaitpointsAfterBatchChild.length).toBe(0); + + //parent snapshot + const parentExecutionDataAfterBatchChildComplete = await engine.getRunExecutionData({ + runId: parentRun.id, + }); + assertNonNullable(parentExecutionDataAfterBatchChildComplete); + expect(parentExecutionDataAfterBatchChildComplete.snapshot.executionStatus).toBe( + "EXECUTING" + ); + expect(parentExecutionDataAfterBatchChildComplete.batch?.id).toBe(batch.id); + expect(parentExecutionDataAfterBatchChildComplete.completedWaitpoints.length).toBe(2); + + //now triggerAndWait + const triggerAndWaitChildRun = await engine.trigger( + { + number: 1, + friendlyId: "run_c123456", + environment: authenticatedEnvironment, + taskIdentifier: triggerAndWaitChildTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t123456", + spanId: "s123456", + masterQueue: "main", + queue: `task/${triggerAndWaitChildTask}`, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + }, + prisma + ); + + //check that the parent's execution data doesn't have a batch ID + const parentAfterTriggerAndWait = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentAfterTriggerAndWait); + expect(parentAfterTriggerAndWait.snapshot.executionStatus).toBe( + "EXECUTING_WITH_WAITPOINTS" + ); + expect(parentAfterTriggerAndWait.batch).toBeUndefined(); + } finally { + engine.quit(); + } + } + ); }); From f6f628e29b7171ed8cfd8393e214b6ca5647641a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 13:31:22 +0100 Subject: [PATCH 24/29] Delete project (#1913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Delete project - Don’t schedule tasks if the project is deleted - Delete queues from the master queues - Add the old delete project UI back in * Mark the project as deleted last * Fix for overriding local variable * Added a todo for deleting env queues * Remove todo --- .../route.tsx | 108 +++++++++++++++++- .../app/services/deleteProject.server.ts | 27 ++++- apps/webapp/app/v3/marqs/index.server.ts | 32 ++++++ .../services/triggerScheduledTask.server.ts | 10 ++ .../run-engine/src/engine/index.ts | 18 ++- .../run-engine/src/run-queue/index.ts | 33 ++++++ 6 files changed, 223 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index d078b8b3e4..24d9cbe670 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -27,9 +27,11 @@ import * as Property from "~/components/primitives/PropertyTable"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { useProject } from "~/hooks/useProject"; -import { redirectWithSuccessMessage } from "~/models/message.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { DeleteProjectService } from "~/services/deleteProject.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { v3ProjectPath } from "~/utils/pathBuilder"; +import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -49,6 +51,27 @@ export function createSchema( action: z.literal("rename"), projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), }), + z.object({ + action: z.literal("delete"), + projectSlug: z.string().superRefine((slug, ctx) => { + if (constraints.getSlugMatch === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + const { isMatch, projectSlug } = constraints.getSlugMatch(slug); + if (isMatch) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The slug must match ${projectSlug}`, + }); + } + }), + }), ]); } @@ -97,6 +120,27 @@ export const action: ActionFunction = async ({ request, params }) => { `Project renamed to ${submission.value.projectName}` ); } + case "delete": { + const deleteProjectService = new DeleteProjectService(); + try { + await deleteProjectService.call({ projectSlug: projectParam, userId }); + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } catch (error: unknown) { + logger.error("Project could not be deleted", { + error: error instanceof Error ? error.message : JSON.stringify(error), + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } + } } } catch (error: any) { return json({ errors: { body: error.message } }, { status: 400 }); @@ -124,6 +168,25 @@ export default function Page() { navigation.formData?.get("action") === "rename" && (navigation.state === "submitting" || navigation.state === "loading"); + const [deleteForm, { projectSlug }] = useForm({ + id: "delete-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldValidate: "onInput", + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema({ + getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), + }), + }); + }, + }); + + const isDeleteLoading = + navigation.formData?.get("action") === "delete" && + (navigation.state === "submitting" || navigation.state === "loading"); + return ( @@ -194,6 +257,47 @@ export default function Page() { />
+ +
+ Danger zone +
+ +
+ + + + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete project + + } + /> +
+ +
diff --git a/apps/webapp/app/services/deleteProject.server.ts b/apps/webapp/app/services/deleteProject.server.ts index 9cfc71674f..8af67330a2 100644 --- a/apps/webapp/app/services/deleteProject.server.ts +++ b/apps/webapp/app/services/deleteProject.server.ts @@ -1,6 +1,7 @@ import { PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { logger } from "./logger.server"; +import { marqs } from "~/v3/marqs/index.server"; +import { engine } from "~/v3/runEngine.server"; type Options = ({ projectId: string } | { projectSlug: string }) & { userId: string; @@ -34,7 +35,29 @@ export class DeleteProjectService { return; } - //mark the project as deleted + // Remove queues from MARQS + for (const environment of project.environments) { + await marqs?.removeEnvironmentQueuesFromMasterQueue(project.organization.id, environment.id); + } + + // Delete all queues from the RunEngine 2 prod master queues + const workerGroups = await this.#prismaClient.workerInstanceGroup.findMany({ + select: { + masterQueue: true, + }, + }); + const engineMasterQueues = workerGroups.map((group) => group.masterQueue); + for (const masterQueue of engineMasterQueues) { + await engine.removeEnvironmentQueuesFromMasterQueue({ + masterQueue, + organizationId: project.organization.id, + projectId: project.id, + }); + } + + // Mark the project as deleted (do this last because it makes it impossible to try again) + // - This disables all API keys + // - This disables all schedules from being scheduled await this.#prismaClient.project.update({ where: { id: project.id, diff --git a/apps/webapp/app/v3/marqs/index.server.ts b/apps/webapp/app/v3/marqs/index.server.ts index b85fef788b..98e1996484 100644 --- a/apps/webapp/app/v3/marqs/index.server.ts +++ b/apps/webapp/app/v3/marqs/index.server.ts @@ -193,6 +193,38 @@ export class MarQS { return this.redis.scard(this.keys.envReserveConcurrencyKey(env.id)); } + public async removeEnvironmentQueuesFromMasterQueue(orgId: string, environmentId: string) { + const sharedQueue = this.keys.sharedQueueKey(); + const queuePattern = this.keys.queueKey(orgId, environmentId, "*"); + + // Use scanStream to find all matching members + const stream = this.redis.zscanStream(sharedQueue, { + match: queuePattern, + count: 100, + }); + + return new Promise((resolve, reject) => { + const matchingQueues: string[] = []; + + stream.on("data", (resultKeys) => { + // zscanStream returns [member1, score1, member2, score2, ...] + // We only want the members (even indices) + for (let i = 0; i < resultKeys.length; i += 2) { + matchingQueues.push(resultKeys[i]); + } + }); + + stream.on("end", async () => { + if (matchingQueues.length > 0) { + await this.redis.zrem(sharedQueue, matchingQueues); + } + resolve(); + }); + + stream.on("error", (err) => reject(err)); + }); + } + public async enqueueMessage( env: AuthenticatedEnvironment, queue: string, diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts index f2d40725a7..2965052b02 100644 --- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts +++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts @@ -43,6 +43,16 @@ export class TriggerScheduledTaskService extends BaseService { return; } + if (instance.environment.project.deletedAt) { + logger.debug("Project is deleted, disabling schedule", { + instanceId, + scheduleId: instance.taskSchedule.friendlyId, + projectId: instance.environment.project.id, + }); + + return; + } + try { let shouldTrigger = true; diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 21993d809e..b4b4cb3564 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -378,7 +378,7 @@ export class RunEngine { } } else { // For deployed runs, we add the env/worker id as the secondary master queue - let secondaryMasterQueue = this.#environmentMasterQueueKey(environment.id); + secondaryMasterQueue = this.#environmentMasterQueueKey(environment.id); if (lockedToVersionId) { secondaryMasterQueue = this.#backgroundWorkerQueueKey(lockedToVersionId); } @@ -775,6 +775,22 @@ export class RunEngine { return this.runQueue.currentConcurrencyOfQueues(environment, queues); } + async removeEnvironmentQueuesFromMasterQueue({ + masterQueue, + organizationId, + projectId, + }: { + masterQueue: string; + organizationId: string; + projectId: string; + }) { + return this.runQueue.removeEnvironmentQueuesFromMasterQueue( + masterQueue, + organizationId, + projectId + ); + } + /** * This creates a DATETIME waitpoint, that will be completed automatically when the specified date is reached. * If you pass an `idempotencyKey`, the waitpoint will be created only if it doesn't already exist. diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index a5aacd957f..6dc4a3b76e 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -683,6 +683,39 @@ export class RunQueue { ); } + public async removeEnvironmentQueuesFromMasterQueue( + masterQueue: string, + organizationId: string, + projectId: string + ) { + // Use scanStream to find all matching members + const stream = this.redis.zscanStream(masterQueue, { + match: this.keys.queueKey(organizationId, projectId, "*", "*"), + count: 100, + }); + + return new Promise((resolve, reject) => { + const matchingQueues: string[] = []; + + stream.on("data", (resultKeys) => { + // zscanStream returns [member1, score1, member2, score2, ...] + // We only want the members (even indices) + for (let i = 0; i < resultKeys.length; i += 2) { + matchingQueues.push(resultKeys[i]); + } + }); + + stream.on("end", async () => { + if (matchingQueues.length > 0) { + await this.redis.zrem(masterQueue, matchingQueues); + } + resolve(); + }); + + stream.on("error", (err) => reject(err)); + }); + } + async quit() { await this.subscriber.unsubscribe(); await this.subscriber.quit(); From 78f8411ba063f6cff3ebf76995c99bb92daad038 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 16 Apr 2025 14:34:20 +0100 Subject: [PATCH 25/29] Improve usage flushing (#1931) * add flush to global usage api * enable controller debug logs * initialize usage manager after env overrides * add previous run id to more debug logs * add changeset --- .changeset/sour-mirrors-accept.md | 6 +++ .../src/entryPoints/managed-run-controller.ts | 3 ++ .../src/entryPoints/managed-run-worker.ts | 42 ++++++++++++------- .../src/entryPoints/managed/controller.ts | 14 +++---- packages/core/src/v3/usage/api.ts | 4 ++ packages/core/src/v3/usage/devUsageManager.ts | 2 + .../core/src/v3/usage/noopUsageManager.ts | 4 ++ packages/core/src/v3/usage/types.ts | 1 + 8 files changed, 55 insertions(+), 21 deletions(-) create mode 100644 .changeset/sour-mirrors-accept.md diff --git a/.changeset/sour-mirrors-accept.md b/.changeset/sour-mirrors-accept.md new file mode 100644 index 0000000000..34084228ca --- /dev/null +++ b/.changeset/sour-mirrors-accept.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Improve usage flushing diff --git a/packages/cli-v3/src/entryPoints/managed-run-controller.ts b/packages/cli-v3/src/entryPoints/managed-run-controller.ts index 4baa701b05..212dc6a19c 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-controller.ts @@ -2,6 +2,9 @@ import { env as stdEnv } from "std-env"; import { readJSONFile } from "../utilities/fileSystem.js"; import { WorkerManifest } from "@trigger.dev/core/v3"; import { ManagedRunController } from "./managed/controller.js"; +import { logger } from "../utilities/logger.js"; + +logger.loggerLevel = "debug"; const manifest = await readJSONFile("./index.json"); const workerManifest = WorkerManifest.parse(manifest); diff --git a/packages/cli-v3/src/entryPoints/managed-run-worker.ts b/packages/cli-v3/src/entryPoints/managed-run-worker.ts index 56f81c2aac..b9a085809d 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-worker.ts @@ -98,9 +98,6 @@ process.on("uncaughtException", function (error, origin) { } }); -const usageIntervalMs = getEnvVar("USAGE_HEARTBEAT_INTERVAL_MS"); -const usageEventUrl = getEnvVar("USAGE_EVENT_URL"); -const triggerJWT = getEnvVar("TRIGGER_JWT"); const heartbeatIntervalMs = getEnvVar("HEARTBEAT_INTERVAL_MS"); const standardLocalsManager = new StandardLocalsManager(); @@ -112,17 +109,8 @@ lifecycleHooks.setGlobalLifecycleHooksManager(standardLifecycleHooksManager); const standardRunTimelineMetricsManager = new StandardRunTimelineMetricsManager(); runTimelineMetrics.setGlobalManager(standardRunTimelineMetricsManager); -const devUsageManager = new DevUsageManager(); -const prodUsageManager = new ProdUsageManager(devUsageManager, { - heartbeatIntervalMs: usageIntervalMs ? parseInt(usageIntervalMs, 10) : undefined, - url: usageEventUrl, - jwt: triggerJWT, -}); - -usage.setGlobalUsageManager(prodUsageManager); -timeout.setGlobalManager(new UsageTimeoutManager(devUsageManager)); - resourceCatalog.setGlobalResourceCatalog(new StandardResourceCatalog()); + const durableClock = new DurableClock(); clock.setGlobalClock(durableClock); const runMetadataManager = new StandardMetadataManager( @@ -258,6 +246,12 @@ const zodIpc = new ZodIpcConnection({ }); } + initializeUsageManager({ + usageIntervalMs: getEnvVar("USAGE_HEARTBEAT_INTERVAL_MS"), + usageEventUrl: getEnvVar("USAGE_EVENT_URL"), + triggerJWT: getEnvVar("TRIGGER_JWT"), + }); + standardRunTimelineMetricsManager.registerMetricsFromExecution(metrics); console.log(`[${new Date().toISOString()}] Received EXECUTE_TASK_RUN`, execution); @@ -509,7 +503,7 @@ async function flushAll(timeoutInMs: number = 10_000) { async function flushUsage(timeoutInMs: number = 10_000) { const now = performance.now(); - await Promise.race([prodUsageManager.flush(), setTimeout(timeoutInMs)]); + await Promise.race([usage.flush(), setTimeout(timeoutInMs)]); const duration = performance.now() - now; @@ -551,6 +545,26 @@ async function flushMetadata(timeoutInMs: number = 10_000) { }; } +function initializeUsageManager({ + usageIntervalMs, + usageEventUrl, + triggerJWT, +}: { + usageIntervalMs?: string; + usageEventUrl?: string; + triggerJWT?: string; +}) { + const devUsageManager = new DevUsageManager(); + const prodUsageManager = new ProdUsageManager(devUsageManager, { + heartbeatIntervalMs: usageIntervalMs ? parseInt(usageIntervalMs, 10) : undefined, + url: usageEventUrl, + jwt: triggerJWT, + }); + + usage.setGlobalUsageManager(prodUsageManager); + timeout.setGlobalManager(new UsageTimeoutManager(devUsageManager)); +} + const managedWorkerRuntime = new ManagedRuntimeManager(zodIpc, true); runtime.setGlobalRuntimeManager(managedWorkerRuntime); diff --git a/packages/cli-v3/src/entryPoints/managed/controller.ts b/packages/cli-v3/src/entryPoints/managed/controller.ts index 35fec13932..d6685e8c84 100644 --- a/packages/cli-v3/src/entryPoints/managed/controller.ts +++ b/packages/cli-v3/src/entryPoints/managed/controller.ts @@ -253,6 +253,8 @@ export class ManagedRunController { this.waitForNextRunLock = true; + const previousRunId = this.runFriendlyId; + try { if (!this.warmStartClient) { this.sendDebugLog({ @@ -262,8 +264,6 @@ export class ManagedRunController { this.exitProcess(this.successExitCode); } - const previousRunId = this.runFriendlyId; - if (this.currentExecution?.taskRunEnv) { this.sendDebugLog({ runId: this.runFriendlyId, @@ -307,14 +307,14 @@ export class ManagedRunController { }; this.sendDebugLog({ - runId: this.runFriendlyId, + runId: previousRunId, message: "waitForNextRun: connected to warm start service", properties: warmStartConfig, }); if (!connectionTimeoutMs || !keepaliveMs) { this.sendDebugLog({ - runId: this.runFriendlyId, + runId: previousRunId, message: "waitForNextRun: warm starts disabled after connect", properties: warmStartConfig, }); @@ -329,7 +329,7 @@ export class ManagedRunController { if (!nextRun) { this.sendDebugLog({ - runId: this.runFriendlyId, + runId: previousRunId, message: "waitForNextRun: warm start failed, shutting down", properties: warmStartConfig, }); @@ -339,7 +339,7 @@ export class ManagedRunController { this.warmStartCount++; this.sendDebugLog({ - runId: this.runFriendlyId, + runId: previousRunId, message: "waitForNextRun: got next run", properties: { ...warmStartConfig, @@ -356,7 +356,7 @@ export class ManagedRunController { }).finally(() => {}); } catch (error) { this.sendDebugLog({ - runId: this.runFriendlyId, + runId: previousRunId, message: "waitForNextRun: unexpected error", properties: { error: error instanceof Error ? error.message : String(error) }, }); diff --git a/packages/core/src/v3/usage/api.ts b/packages/core/src/v3/usage/api.ts index f476b23bfa..338cc8cf80 100644 --- a/packages/core/src/v3/usage/api.ts +++ b/packages/core/src/v3/usage/api.ts @@ -44,6 +44,10 @@ export class UsageAPI implements UsageManager { return this.#getUsageManager().sample(); } + public flush(): Promise { + return this.#getUsageManager().flush(); + } + #getUsageManager(): UsageManager { return getGlobal(API_NAME) ?? NOOP_USAGE_MANAGER; } diff --git a/packages/core/src/v3/usage/devUsageManager.ts b/packages/core/src/v3/usage/devUsageManager.ts index d4579813a3..fea5d2fa97 100644 --- a/packages/core/src/v3/usage/devUsageManager.ts +++ b/packages/core/src/v3/usage/devUsageManager.ts @@ -48,6 +48,8 @@ export class DevUsageManager implements UsageManager { disable(): void {} + async flush(): Promise {} + sample(): UsageSample | undefined { return this._firstMeasurement?.sample(); } diff --git a/packages/core/src/v3/usage/noopUsageManager.ts b/packages/core/src/v3/usage/noopUsageManager.ts index 38b42198e2..4369fd3ca7 100644 --- a/packages/core/src/v3/usage/noopUsageManager.ts +++ b/packages/core/src/v3/usage/noopUsageManager.ts @@ -5,6 +5,10 @@ export class NoopUsageManager implements UsageManager { // Noop } + async flush(): Promise { + // Noop + } + start(): UsageMeasurement { return { sample: () => ({ cpuTime: 0, wallTime: 0 }), diff --git a/packages/core/src/v3/usage/types.ts b/packages/core/src/v3/usage/types.ts index 363fe84f66..5b763cc499 100644 --- a/packages/core/src/v3/usage/types.ts +++ b/packages/core/src/v3/usage/types.ts @@ -13,4 +13,5 @@ export interface UsageManager { stop(measurement: UsageMeasurement): UsageSample; sample(): UsageSample | undefined; pauseAsync(cb: () => Promise): Promise; + flush(): Promise; } From 6ba85b5b12596e419bf0dfb2e27466f96aebcb43 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 18:39:35 +0100 Subject: [PATCH 26/29] =?UTF-8?q?For=20secret=20env=20vars,=20don=E2=80=99?= =?UTF-8?q?t=20return=20the=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rojects.$projectRef.envvars.$slug.$name.ts | 2 + ...i.v1.projects.$projectRef.envvars.$slug.ts | 8 +++- .../environmentVariablesRepository.server.ts | 37 ++++++++++++++++++- .../app/v3/environmentVariables/repository.ts | 15 +++++++- packages/core/src/v3/apiClient/index.ts | 5 ++- packages/core/src/v3/schemas/api.ts | 17 ++++++++- packages/trigger-sdk/src/v3/envvars.ts | 15 +++++--- 7 files changed, 86 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 07ae0e4823..f7de559b28 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -132,6 +132,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } return json({ + name: environmentVariable.key, value: environmentVariable.value, + isSecret: environmentVariable.isSecret, }); } diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index a22a2a1eaa..dde19c38e6 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -82,5 +82,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const variables = await repository.getEnvironment(environment.project.id, environment.id); - return json(variables.map((variable) => ({ name: variable.key, value: variable.value }))); + return json( + variables.map((variable) => ({ + name: variable.key, + value: variable.value, + isSecret: variable.isSecret, + })) + ); } diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 1552217acb..ae749d4f12 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -15,6 +15,7 @@ import { DeleteEnvironmentVariable, DeleteEnvironmentVariableValue, EnvironmentVariable, + EnvironmentVariableWithSecret, ProjectEnvironmentVariable, Repository, Result, @@ -509,7 +510,10 @@ export class EnvironmentVariablesRepository implements Repository { return results; } - async getEnvironment(projectId: string, environmentId: string): Promise { + async getEnvironment( + projectId: string, + environmentId: string + ): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -531,7 +535,36 @@ export class EnvironmentVariablesRepository implements Repository { return []; } - return this.getEnvironmentVariables(projectId, environmentId); + // Get the keys of all secret variables + const secretValues = await this.prismaClient.environmentVariableValue.findMany({ + where: { + environmentId, + isSecret: true, + }, + select: { + variable: { + select: { + key: true, + }, + }, + }, + }); + const secretVarKeys = secretValues.map((r) => r.variable.key); + + const variables = await this.getEnvironmentVariables(projectId, environmentId); + + // Filter out secret variables if includeSecrets is false + return variables.map((v) => { + if (secretVarKeys.includes(v.key)) { + return { + key: v.key, + value: "", + isSecret: true, + }; + } + + return { key: v.key, value: v.value, isSecret: false }; + }); } async #getSecretEnvironmentVariables( diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index 5d814bec06..98f68cd4f5 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -79,12 +79,25 @@ export type EnvironmentVariable = { value: string; }; +export type EnvironmentVariableWithSecret = EnvironmentVariable & { + isSecret: boolean; +}; + export interface Repository { create(projectId: string, options: CreateEnvironmentVariables): Promise; edit(projectId: string, options: EditEnvironmentVariable): Promise; editValue(projectId: string, options: EditEnvironmentVariableValue): Promise; getProject(projectId: string): Promise; - getEnvironment(projectId: string, environmentId: string): Promise; + /** + * Get the environment variables for a given environment, it does NOT return values for secret variables + */ + getEnvironment( + projectId: string, + environmentId: string + ): Promise; + /** + * Return all env vars, including secret variables with values. Should only be used for executing tasks. + */ getEnvironmentVariables(projectId: string, environmentId: string): Promise; delete(projectId: string, options: DeleteEnvironmentVariable): Promise; deleteValue(projectId: string, options: DeleteEnvironmentVariableValue): Promise; diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index fd41554e5f..f86fef448f 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -17,6 +17,7 @@ import { DeletedScheduleObject, EnvironmentVariableResponseBody, EnvironmentVariableValue, + EnvironmentVariableWithSecret, EnvironmentVariables, ListQueueOptions, ListRunResponseItem, @@ -549,7 +550,7 @@ export class ApiClient { listEnvVars(projectRef: string, slug: string, requestOptions?: ZodFetchOptions) { return zodfetch( - EnvironmentVariables, + z.array(EnvironmentVariableWithSecret), `${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}`, { method: "GET", @@ -579,7 +580,7 @@ export class ApiClient { retrieveEnvVar(projectRef: string, slug: string, key: string, requestOptions?: ZodFetchOptions) { return zodfetch( - EnvironmentVariableValue, + EnvironmentVariableWithSecret, `${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}/${key}`, { method: "GET", diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 6f071ffa6d..608f6c3e83 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -806,11 +806,26 @@ export const EnvironmentVariable = z.object({ name: z.string(), value: z.string(), }); - export const EnvironmentVariables = z.array(EnvironmentVariable); export type EnvironmentVariables = z.infer; +export const EnvironmentVariableWithSecret = z.object({ + /** The name of the env var, e.g. `DATABASE_URL` */ + name: z.string(), + /** The value of the env var. If it's a secret, this will be a redacted value, not the real value. */ + value: z.string(), + /** + * Whether the env var is a secret or not. + * When you create env vars you can mark them as secrets. + * + * You can't view the value of a secret env var after setting it initially. + * For a secret env var, the value will be redacted. + */ + isSecret: z.boolean(), +}); +export type EnvironmentVariableWithSecret = z.infer; + export const UpdateMetadataRequestBody = FlushedRunMetadata; export type UpdateMetadataRequestBody = z.infer; diff --git a/packages/trigger-sdk/src/v3/envvars.ts b/packages/trigger-sdk/src/v3/envvars.ts index 90034ef2c1..9f0c64d180 100644 --- a/packages/trigger-sdk/src/v3/envvars.ts +++ b/packages/trigger-sdk/src/v3/envvars.ts @@ -4,6 +4,7 @@ import type { CreateEnvironmentVariableParams, EnvironmentVariableResponseBody, EnvironmentVariableValue, + EnvironmentVariableWithSecret, EnvironmentVariables, ImportEnvironmentVariablesParams, UpdateEnvironmentVariableParams, @@ -84,13 +85,15 @@ export function list( projectRef: string, slug: string, requestOptions?: ApiRequestOptions -): ApiPromise; -export function list(requestOptions?: ApiRequestOptions): ApiPromise; +): ApiPromise; +export function list( + requestOptions?: ApiRequestOptions +): ApiPromise; export function list( projectRefOrRequestOptions?: string | ApiRequestOptions, slug?: string, requestOptions?: ApiRequestOptions -): ApiPromise { +): ApiPromise { const $projectRef = !isRequestOptions(projectRefOrRequestOptions) ? projectRefOrRequestOptions : taskContext.ctx?.project.ref; @@ -188,17 +191,17 @@ export function retrieve( slug: string, name: string, requestOptions?: ApiRequestOptions -): ApiPromise; +): ApiPromise; export function retrieve( name: string, requestOptions?: ApiRequestOptions -): ApiPromise; +): ApiPromise; export function retrieve( projectRefOrName: string, slugOrRequestOptions?: string | ApiRequestOptions, name?: string, requestOptions?: ApiRequestOptions -): ApiPromise { +): ApiPromise { let $projectRef: string; let $slug: string; let $name: string; From 6d508cab5be824fa0650e0fa084379148d1ad0f0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 18:43:32 +0100 Subject: [PATCH 27/29] Added a new env var repository function for getting secrets with redactions --- ...rojects.$projectRef.envvars.$slug.$name.ts | 5 +- ...i.v1.projects.$projectRef.envvars.$slug.ts | 5 +- .../environmentVariablesRepository.server.ts | 50 ++++++++++--------- .../app/v3/environmentVariables/repository.ts | 6 ++- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index f7de559b28..2b63697990 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -123,7 +123,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const repository = new EnvironmentVariablesRepository(); - const variables = await repository.getEnvironment(environment.project.id, environment.id); + const variables = await repository.getEnvironmentWithRedactedSecrets( + environment.project.id, + environment.id + ); const environmentVariable = variables.find((v) => v.key === parsedParams.data.name); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index dde19c38e6..eae0e586e7 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -80,7 +80,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const repository = new EnvironmentVariablesRepository(); - const variables = await repository.getEnvironment(environment.project.id, environment.id); + const variables = await repository.getEnvironmentWithRedactedSecrets( + environment.project.id, + environment.id + ); return json( variables.map((variable) => ({ diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index ae749d4f12..fdcbdc7e08 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -510,30 +510,11 @@ export class EnvironmentVariablesRepository implements Repository { return results; } - async getEnvironment( + async getEnvironmentWithRedactedSecrets( projectId: string, environmentId: string ): Promise { - const project = await this.prismaClient.project.findFirst({ - where: { - id: projectId, - deletedAt: null, - }, - select: { - environments: { - select: { - id: true, - }, - where: { - id: environmentId, - }, - }, - }, - }); - - if (!project || project.environments.length === 0) { - return []; - } + const variables = await this.getEnvironment(projectId, environmentId); // Get the keys of all secret variables const secretValues = await this.prismaClient.environmentVariableValue.findMany({ @@ -551,8 +532,6 @@ export class EnvironmentVariablesRepository implements Repository { }); const secretVarKeys = secretValues.map((r) => r.variable.key); - const variables = await this.getEnvironmentVariables(projectId, environmentId); - // Filter out secret variables if includeSecrets is false return variables.map((v) => { if (secretVarKeys.includes(v.key)) { @@ -567,6 +546,31 @@ export class EnvironmentVariablesRepository implements Repository { }); } + async getEnvironment(projectId: string, environmentId: string): Promise { + const project = await this.prismaClient.project.findFirst({ + where: { + id: projectId, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + where: { + id: environmentId, + }, + }, + }, + }); + + if (!project || project.environments.length === 0) { + return []; + } + + return this.getEnvironmentVariables(projectId, environmentId); + } + async #getSecretEnvironmentVariables( projectId: string, environmentId: string diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index 98f68cd4f5..521e22f7a2 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -91,10 +91,14 @@ export interface Repository { /** * Get the environment variables for a given environment, it does NOT return values for secret variables */ - getEnvironment( + getEnvironmentWithRedactedSecrets( projectId: string, environmentId: string ): Promise; + /** + * Get the environment variables for a given environment + */ + getEnvironment(projectId: string, environmentId: string): Promise; /** * Return all env vars, including secret variables with values. Should only be used for executing tasks. */ From 512dbb6278339915a1e22ab439d867ece38d52b2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 17 Apr 2025 11:14:16 +0100 Subject: [PATCH 28/29] Test task for env vars --- references/hello-world/src/trigger/envvars.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 references/hello-world/src/trigger/envvars.ts diff --git a/references/hello-world/src/trigger/envvars.ts b/references/hello-world/src/trigger/envvars.ts new file mode 100644 index 0000000000..3a6bd700d8 --- /dev/null +++ b/references/hello-world/src/trigger/envvars.ts @@ -0,0 +1,48 @@ +import { envvars, logger, task } from "@trigger.dev/sdk"; +import assert from "node:assert"; + +export const secretEnvVar = task({ + id: "secret-env-var", + retry: { + maxAttempts: 1, + }, + run: async (_, { ctx }) => { + logger.log("ctx", { ctx }); + + logger.log("process.env", process.env); + + //list them + const vars = await envvars.list(ctx.project.ref, ctx.environment.slug); + logger.log("envVars", { vars }); + + //get non secret env var + const nonSecretEnvVar = vars.find((v) => !v.isSecret); + assert.equal(nonSecretEnvVar?.isSecret, false); + assert.notEqual(nonSecretEnvVar?.value, ""); + + //retrieve the non secret env var + const retrievedNonSecret = await envvars.retrieve( + ctx.project.ref, + ctx.environment.slug, + nonSecretEnvVar!.name + ); + logger.log("retrievedNonSecret", { retrievedNonSecret }); + assert.equal(retrievedNonSecret?.isSecret, false); + assert.equal(retrievedNonSecret?.value, nonSecretEnvVar!.value); + + //get secret env var + const secretEnvVar = vars.find((v) => v.isSecret); + assert.equal(secretEnvVar?.isSecret, true); + assert.equal(secretEnvVar?.value, ""); + + //retrieve the secret env var + const retrievedSecret = await envvars.retrieve( + ctx.project.ref, + ctx.environment.slug, + secretEnvVar!.name + ); + logger.log("retrievedSecret", { retrievedSecret }); + assert.equal(retrievedSecret?.isSecret, true); + assert.equal(retrievedSecret?.value, ""); + }, +}); From 8ace735dc4afaa6be5f3757e25ad9616888f622b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 17 Apr 2025 11:25:53 +0100 Subject: [PATCH 29/29] Delete heartbeat file, merge mess up --- .../src/entryPoints/managed/heartbeat.ts | 92 ------------------- 1 file changed, 92 deletions(-) delete mode 100644 packages/cli-v3/src/entryPoints/managed/heartbeat.ts diff --git a/packages/cli-v3/src/entryPoints/managed/heartbeat.ts b/packages/cli-v3/src/entryPoints/managed/heartbeat.ts deleted file mode 100644 index 3b3c820c91..0000000000 --- a/packages/cli-v3/src/entryPoints/managed/heartbeat.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { IntervalService } from "@trigger.dev/core/v3"; -import { WorkloadHttpClient } from "@trigger.dev/core/v3/runEngineWorker"; -import { RunLogger } from "./logger.js"; - -export type RunExecutionHeartbeatOptions = { - runFriendlyId: string; - snapshotFriendlyId: string; - httpClient: WorkloadHttpClient; - logger: RunLogger; - heartbeatIntervalSeconds: number; -}; - -export class RunExecutionHeartbeat { - private readonly runFriendlyId: string; - private snapshotFriendlyId: string; - - private readonly httpClient: WorkloadHttpClient; - private readonly logger: RunLogger; - private readonly heartbeatIntervalMs: number; - private readonly heartbeat: IntervalService; - - constructor(opts: RunExecutionHeartbeatOptions) { - this.runFriendlyId = opts.runFriendlyId; - this.snapshotFriendlyId = opts.snapshotFriendlyId; - this.httpClient = opts.httpClient; - this.logger = opts.logger; - this.heartbeatIntervalMs = opts.heartbeatIntervalSeconds * 1000; - - this.logger.sendDebugLog({ - runId: this.runFriendlyId, - message: "RunExecutionHeartbeat", - properties: { - runFriendlyId: this.runFriendlyId, - snapshotFriendlyId: this.snapshotFriendlyId, - heartbeatIntervalSeconds: opts.heartbeatIntervalSeconds, - }, - }); - - this.heartbeat = new IntervalService({ - onInterval: async () => { - this.logger.sendDebugLog({ - runId: this.runFriendlyId, - message: "heartbeat: started", - }); - - const response = await this.httpClient.heartbeatRun( - this.runFriendlyId, - this.snapshotFriendlyId - ); - - if (!response.success) { - this.logger.sendDebugLog({ - runId: this.runFriendlyId, - message: "heartbeat: failed", - properties: { - error: response.error, - }, - }); - } - }, - intervalMs: this.heartbeatIntervalMs, - leadingEdge: false, - onError: async (error) => { - this.logger.sendDebugLog({ - runId: this.runFriendlyId, - message: "Failed to send heartbeat", - properties: { error: error instanceof Error ? error.message : String(error) }, - }); - }, - }); - } - - resetCurrentInterval() { - this.heartbeat.resetCurrentInterval(); - } - - updateSnapshotId(snapshotFriendlyId: string) { - this.snapshotFriendlyId = snapshotFriendlyId; - } - - updateInterval(intervalMs: number) { - this.heartbeat.updateInterval(intervalMs); - } - - start() { - this.heartbeat.start(); - } - - stop() { - this.heartbeat.stop(); - } -}