From ad1672d95f9e009cfc5bc109fb3c596fa3000699 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 23 Jul 2025 12:21:54 +0200 Subject: [PATCH 001/641] Fail gracefully when runs cannot be loaded (#2296) --- .../route.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index edc9e9f34ca..8c41f0ceaca 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -58,6 +58,7 @@ import { } from "~/utils/pathBuilder"; import { ListPagination } from "../../components/ListPagination"; import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; +import { Callout } from "~/components/primitives/Callout"; export const meta: MetaFunction = () => { return [ @@ -149,7 +150,17 @@ export default function Page() { } > - + + + Unable to load your task runs. Please refresh the page or try again in a + moment. + + + } + > {(list) => { return ( Date: Wed, 23 Jul 2025 12:28:06 +0100 Subject: [PATCH 002/641] The tasks page shows an error rather than breaking the entire page (#2297) --- .../route.tsx | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 449ef16dcb3..29efff3dc03 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -3,6 +3,7 @@ import { BookOpenIcon, ChevronDownIcon, ChevronUpIcon, + ExclamationTriangleIcon, LightBulbIcon, MagnifyingGlassIcon, UserPlusIcon, @@ -299,7 +300,10 @@ export default function Page() { } > - + } + > {(data) => { const taskData = data[task.slug]; return taskData?.running ?? "0"; @@ -309,7 +313,10 @@ export default function Page() { }> - + } + > {(data) => { const taskData = data[task.slug]; return taskData?.queued ?? "0"; @@ -319,7 +326,10 @@ export default function Page() { }> - + } + > {(data) => { const taskData = data[task.slug]; return ( @@ -339,7 +349,10 @@ export default function Page() { }> - + } + > {(data) => { const taskData = data[task.slug]; return taskData @@ -828,3 +841,12 @@ function LinkWithIcon({ ); } + +function FailedToLoadStats() { + return ( + } + content="We were unable to load the task stats, please try again later." + /> + ); +} From c927fbc8e4445baccf84fb02672896999f3fa243 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 23 Jul 2025 16:11:26 +0100 Subject: [PATCH 003/641] Gracefully shutdown task run processes (#2299) * Gracefully shutdown task run processes * better log, thanks coderabbit --- .changeset/early-points-jam.md | 5 ++++ .../cli-v3/src/executions/taskRunProcess.ts | 23 ++++++++++++++++--- .../src/indexing/indexWorkerManifest.ts | 8 +++---- 3 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 .changeset/early-points-jam.md diff --git a/.changeset/early-points-jam.md b/.changeset/early-points-jam.md new file mode 100644 index 00000000000..645a50002fa --- /dev/null +++ b/.changeset/early-points-jam.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Gracefully shutdown task run processes using SIGTERM followed by SIGKILL after a 1s timeout. This also prevents cancelled or completed runs from leaving orphaned Ttask run processes behind diff --git a/packages/cli-v3/src/executions/taskRunProcess.ts b/packages/cli-v3/src/executions/taskRunProcess.ts index 74aecededf3..7b2adb6eb6a 100644 --- a/packages/cli-v3/src/executions/taskRunProcess.ts +++ b/packages/cli-v3/src/executions/taskRunProcess.ts @@ -51,6 +51,7 @@ export type TaskRunProcessOptions = { machineResources: MachinePresetResources; isWarmStart?: boolean; cwd?: string; + gracefulTerminationTimeoutInMs?: number; }; export type TaskRunProcessExecuteParams = { @@ -114,7 +115,7 @@ export class TaskRunProcess { console.error("Error cancelling task run process", { err }); } - await this.kill(); + await this.#gracefullyTerminate(this.options.gracefulTerminationTimeoutInMs); } async cleanup(kill = true) { @@ -131,7 +132,7 @@ export class TaskRunProcess { } if (kill) { - await this.kill("SIGKILL"); + await this.#gracefullyTerminate(this.options.gracefulTerminationTimeoutInMs); } } @@ -395,6 +396,18 @@ export class TaskRunProcess { this._stderr.push(errorLine); } + async #gracefullyTerminate(timeoutInMs: number = 1_000) { + logger.debug("gracefully terminating task run process", { pid: this.pid, timeoutInMs }); + + await this.kill("SIGTERM", timeoutInMs); + + if (this._child?.connected) { + logger.debug("child process is still connected, sending SIGKILL", { pid: this.pid }); + + await this.kill("SIGKILL"); + } + } + /** This will never throw. */ async kill(signal?: number | NodeJS.Signals, timeoutInMs?: number) { logger.debug(`killing task run process`, { @@ -420,7 +433,11 @@ export class TaskRunProcess { const [error] = await tryCatch(killTimeout); if (error) { - logger.debug("kill: failed to wait for child process to exit", { error }); + logger.debug("kill: failed to wait for child process to exit", { + timeoutInMs, + signal, + pid: this.pid, + }); } } diff --git a/packages/cli-v3/src/indexing/indexWorkerManifest.ts b/packages/cli-v3/src/indexing/indexWorkerManifest.ts index ff8de685cea..e4ae72283ff 100644 --- a/packages/cli-v3/src/indexing/indexWorkerManifest.ts +++ b/packages/cli-v3/src/indexing/indexWorkerManifest.ts @@ -61,7 +61,7 @@ export async function indexWorkerManifest({ } resolved = true; - child.kill(); + child.kill("SIGKILL"); reject(new Error("Worker timed out")); }, 20_000); @@ -79,21 +79,21 @@ export async function indexWorkerManifest({ } else { resolve(message.payload.manifest); } - child.kill(); + child.kill("SIGKILL"); break; } case "TASKS_FAILED_TO_PARSE": { clearTimeout(timeout); resolved = true; reject(new TaskMetadataParseError(message.payload.zodIssues, message.payload.tasks)); - child.kill(); + child.kill("SIGKILL"); break; } case "UNCAUGHT_EXCEPTION": { clearTimeout(timeout); resolved = true; reject(new UncaughtExceptionError(message.payload.error, message.payload.origin)); - child.kill(); + child.kill("SIGKILL"); break; } } From 9b0eb64035ad92345d02c59052472e463a3793f8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 23 Jul 2025 16:30:30 +0100 Subject: [PATCH 004/641] Fallback from ClickHouse to Postgres (#2300) * Set the default replication concurrency to 2 for self-hosters 100 was a bit crazy * Made the run repository an interface, deferring just to CH for now * Added run repository feature flag, allowing passing a default when getting a flag * Switch run repository using a feature flag * Added spans * Pass the default repository in, so we can try Postgres in the tests * Fallback to Postgres if ClickHouse errors * Update feature flags API endpoint --- apps/webapp/app/env.server.ts | 2 +- .../app/presenters/RunFilters.server.ts | 2 +- .../v3/BulkActionPresenter.server.ts | 2 +- .../v3/CreateBulkActionPresenter.server.ts | 2 +- .../v3/NextRunListPresenter.server.ts | 2 +- .../app/routes/admin.api.v1.feature-flags.ts | 71 ++++ .../clickhouseRunsRepository.server.ts} | 160 +------- .../postgresRunsRepository.server.ts | 290 ++++++++++++++ .../runsRepository/runsRepository.server.ts | 366 ++++++++++++++++++ apps/webapp/app/v3/featureFlags.server.ts | 70 +++- .../v3/services/bulk/BulkActionV2.server.ts | 2 +- apps/webapp/test/runsRepository.test.ts | 2 +- 12 files changed, 814 insertions(+), 157 deletions(-) create mode 100644 apps/webapp/app/routes/admin.api.v1.feature-flags.ts rename apps/webapp/app/services/{runsRepository.server.ts => runsRepository/clickhouseRunsRepository.server.ts} (61%) create mode 100644 apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts create mode 100644 apps/webapp/app/services/runsRepository/runsRepository.server.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 6fd895c456c..88006862363 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -891,7 +891,7 @@ const EnvironmentSchema = z.object({ RUN_REPLICATION_ENABLED: z.string().default("0"), RUN_REPLICATION_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v1"), RUN_REPLICATION_PUBLICATION_NAME: z.string().default("task_runs_to_clickhouse_v1_publication"), - RUN_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(100), + RUN_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(2), RUN_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000), RUN_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100), RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000), diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts index db19e656565..8d70b4d3bdf 100644 --- a/apps/webapp/app/presenters/RunFilters.server.ts +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -3,7 +3,7 @@ import { TaskRunListSearchFilters, } from "~/components/runs/v3/RunFilters"; import { getRootOnlyFilterPreference } from "~/services/preferences/uiPreferences.server"; -import { type ParsedRunFilters } from "~/services/runsRepository.server"; +import { type ParsedRunFilters } from "~/services/runsRepository/runsRepository.server"; type FiltersFromRequest = ParsedRunFilters & Required>; diff --git a/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts index 0434045ea49..f98d0819cba 100644 --- a/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts @@ -1,7 +1,7 @@ import { getUsername } from "~/utils/username"; import { BasePresenter } from "./basePresenter.server"; import { type BulkActionMode } from "~/components/BulkActionFilterSummary"; -import { parseRunListInputOptions } from "~/services/runsRepository.server"; +import { parseRunListInputOptions } from "~/services/runsRepository/runsRepository.server"; import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; type BulkActionOptions = { diff --git a/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts index e80c7df42b2..acf511f0f5e 100644 --- a/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts @@ -1,7 +1,7 @@ import { type PrismaClient } from "@trigger.dev/database"; import { CreateBulkActionSearchParams } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; -import { RunsRepository } from "~/services/runsRepository.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { getRunFiltersFromRequest } from "../RunFilters.server"; import { BasePresenter } from "./basePresenter.server"; diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index 960bdfc10a6..2375ea161a9 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -9,7 +9,7 @@ import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { getAllTaskIdentifiers } from "~/models/task.server"; -import { RunsRepository } from "~/services/runsRepository.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; diff --git a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts new file mode 100644 index 00000000000..cd7958c5b88 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts @@ -0,0 +1,71 @@ +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server"; +import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; +import { + makeSetFlags, + setFlags, + FeatureFlagCatalogSchema, + validateAllFeatureFlags, + validatePartialFeatureFlags, + makeSetMultipleFlags, +} from "~/v3/featureFlags.server"; +import { z } from "zod"; + +export async function action({ request }: ActionFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { + id: authenticationResult.userId, + }, + }); + + if (!user) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!user.admin) { + return json({ error: "You must be an admin to perform this action" }, { status: 403 }); + } + + try { + // Parse the request body + const body = await request.json(); + + // Validate the input using the partial schema + const validationResult = validatePartialFeatureFlags(body as Record); + if (!validationResult.success) { + return json( + { + error: "Invalid feature flags data", + details: validationResult.error.issues, + }, + { status: 400 } + ); + } + + const featureFlags = validationResult.data; + const setMultipleFlags = makeSetMultipleFlags(prisma); + const updatedFlags = await setMultipleFlags(featureFlags); + + return json({ + success: true, + updatedFlags, + message: `Updated ${updatedFlags.length} feature flag(s)`, + }); + } catch (error) { + return json( + { + error: error instanceof Error ? error.message : String(error), + }, + { status: 400 } + ); + } +} diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts similarity index 61% rename from apps/webapp/app/services/runsRepository.server.ts rename to apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index 3196c436b3d..56a42c751db 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -1,79 +1,26 @@ -import { type ClickHouse, type ClickhouseQueryBuilder } from "@internal/clickhouse"; -import { type Tracer } from "@internal/tracing"; -import { type Logger, type LogLevel } from "@trigger.dev/core/logger"; -import { MachinePresetName } from "@trigger.dev/core/v3"; -import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic"; -import { TaskRunStatus } from "@trigger.dev/database"; -import parseDuration from "parse-duration"; -import { z } from "zod"; -import { timeFilters } from "~/components/runs/v3/SharedFilters"; -import { type PrismaClient } from "~/db.server"; - -export type RunsRepositoryOptions = { - clickhouse: ClickHouse; - prisma: PrismaClient; - logger?: Logger; - logLevel?: LogLevel; - tracer?: Tracer; -}; - -const RunStatus = z.enum(Object.values(TaskRunStatus) as [TaskRunStatus, ...TaskRunStatus[]]); - -const RunListInputOptionsSchema = z.object({ - organizationId: z.string(), - projectId: z.string(), - environmentId: z.string(), - //filters - tasks: z.array(z.string()).optional(), - versions: z.array(z.string()).optional(), - statuses: z.array(RunStatus).optional(), - tags: z.array(z.string()).optional(), - scheduleId: z.string().optional(), - period: z.string().optional(), - from: z.number().optional(), - to: z.number().optional(), - isTest: z.boolean().optional(), - rootOnly: z.boolean().optional(), - batchId: z.string().optional(), - runId: z.array(z.string()).optional(), - bulkId: z.string().optional(), - queues: z.array(z.string()).optional(), - machines: MachinePresetName.array().optional(), -}); - -export type RunListInputOptions = z.infer; -export type RunListInputFilters = Omit< - RunListInputOptions, - "organizationId" | "projectId" | "environmentId" ->; - -export type ParsedRunFilters = RunListInputFilters & { - cursor?: string; - direction?: "forward" | "backward"; -}; - -type FilterRunsOptions = Omit & { - period: number | undefined; -}; - -type Pagination = { - page: { - size: number; - cursor?: string; - direction?: "forward" | "backward"; - }; -}; - -export type ListRunsOptions = RunListInputOptions & Pagination; - -export class RunsRepository { +import { type ClickhouseQueryBuilder } from "@internal/clickhouse"; +import { RunId } from "@trigger.dev/core/v3/isomorphic"; +import { + type FilterRunsOptions, + type IRunsRepository, + type ListRunsOptions, + type RunListInputOptions, + type RunsRepositoryOptions, + convertRunListInputOptionsToFilterRunsOptions, +} from "./runsRepository.server"; + +export class ClickHouseRunsRepository implements IRunsRepository { constructor(private readonly options: RunsRepositoryOptions) {} + get name() { + return "clickhouse"; + } + async listRunIds(options: ListRunsOptions) { const queryBuilder = this.options.clickhouse.taskRuns.queryBuilder(); applyRunFiltersToQueryBuilder( queryBuilder, - await this.#convertRunListInputOptionsToFilterRunsOptions(options) + await convertRunListInputOptionsToFilterRunsOptions(options, this.options.prisma) ); if (options.page.cursor) { @@ -200,7 +147,7 @@ export class RunsRepository { const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder(); applyRunFiltersToQueryBuilder( queryBuilder, - await this.#convertRunListInputOptionsToFilterRunsOptions(options) + await convertRunListInputOptionsToFilterRunsOptions(options, this.options.prisma) ); const [queryError, result] = await queryBuilder.execute(); @@ -215,73 +162,6 @@ export class RunsRepository { return result[0].count; } - - async #convertRunListInputOptionsToFilterRunsOptions( - options: RunListInputOptions - ): Promise { - const convertedOptions: FilterRunsOptions = { - ...options, - period: undefined, - }; - - // Convert time period to ms - const time = timeFilters({ - period: options.period, - from: options.from, - to: options.to, - }); - convertedOptions.period = time.period ? parseDuration(time.period) ?? undefined : undefined; - - // batch friendlyId to id - if (options.batchId && options.batchId.startsWith("batch_")) { - const batch = await this.options.prisma.batchTaskRun.findFirst({ - select: { - id: true, - }, - where: { - friendlyId: options.batchId, - runtimeEnvironmentId: options.environmentId, - }, - }); - - if (batch) { - convertedOptions.batchId = batch.id; - } - } - - // scheduleId can be a friendlyId - if (options.scheduleId && options.scheduleId.startsWith("sched_")) { - const schedule = await this.options.prisma.taskSchedule.findFirst({ - select: { - id: true, - }, - where: { - friendlyId: options.scheduleId, - projectId: options.projectId, - }, - }); - - if (schedule) { - convertedOptions.scheduleId = schedule?.id; - } - } - - if (options.bulkId && options.bulkId.startsWith("bulk_")) { - convertedOptions.bulkId = BulkActionId.toId(options.bulkId); - } - - if (options.runId) { - //convert to friendlyId - convertedOptions.runId = options.runId.map((r) => RunId.toFriendlyId(r)); - } - - // Show all runs if we are filtering by batchId or runId - if (options.batchId || options.runId?.length || options.scheduleId || options.tasks?.length) { - convertedOptions.rootOnly = false; - } - - return convertedOptions; - } } function applyRunFiltersToQueryBuilder( @@ -373,7 +253,3 @@ function applyRunFiltersToQueryBuilder( }); } } - -export function parseRunListInputOptions(data: any): RunListInputOptions { - return RunListInputOptionsSchema.parse(data); -} diff --git a/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts new file mode 100644 index 00000000000..ec9b5be69b6 --- /dev/null +++ b/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts @@ -0,0 +1,290 @@ +import { RunId } from "@trigger.dev/core/v3/isomorphic"; +import { Prisma } from "@trigger.dev/database"; +import { sqlDatabaseSchema } from "~/db.server"; +import { + type FilterRunsOptions, + type IRunsRepository, + type ListRunsOptions, + type ListedRun, + type RunListInputOptions, + type RunsRepositoryOptions, + convertRunListInputOptionsToFilterRunsOptions, +} from "./runsRepository.server"; + +export class PostgresRunsRepository implements IRunsRepository { + constructor(private readonly options: RunsRepositoryOptions) {} + + get name() { + return "postgres"; + } + + async listRunIds(options: ListRunsOptions) { + const filterOptions = await convertRunListInputOptionsToFilterRunsOptions( + options, + this.options.prisma + ); + + const query = this.#buildRunIdsQuery(filterOptions, options.page); + const runs = await this.options.prisma.$queryRaw<{ id: string }[]>(query); + + return runs.map((run) => run.id); + } + + async listRuns(options: ListRunsOptions) { + const filterOptions = await convertRunListInputOptionsToFilterRunsOptions( + options, + this.options.prisma + ); + + const query = this.#buildRunsQuery(filterOptions, options.page); + const runs = await this.options.prisma.$queryRaw(query); + + // If there are more runs than the page size, we need to fetch the next page + const hasMore = runs.length > options.page.size; + + let nextCursor: string | null = null; + let previousCursor: string | null = null; + + // Get cursors for next and previous pages + const direction = options.page.direction ?? "forward"; + switch (direction) { + case "forward": { + previousCursor = options.page.cursor ? runs.at(0)?.id ?? null : null; + if (hasMore) { + // The next cursor should be the last run ID from this page + nextCursor = runs[options.page.size - 1]?.id ?? null; + } + break; + } + case "backward": { + const reversedRuns = [...runs].reverse(); + if (hasMore) { + previousCursor = reversedRuns.at(1)?.id ?? null; + nextCursor = reversedRuns.at(options.page.size)?.id ?? null; + } else { + nextCursor = reversedRuns.at(options.page.size - 1)?.id ?? null; + } + break; + } + } + + const runsToReturn = + options.page.direction === "backward" && hasMore + ? runs.slice(1, options.page.size + 1) + : runs.slice(0, options.page.size); + + // ClickHouse is slightly delayed, so we're going to do in-memory status filtering too + let filteredRuns = runsToReturn; + if (options.statuses && options.statuses.length > 0) { + filteredRuns = runsToReturn.filter((run) => options.statuses!.includes(run.status)); + } + + return { + runs: filteredRuns, + pagination: { + nextCursor, + previousCursor, + }, + }; + } + + async countRuns(options: RunListInputOptions) { + const filterOptions = await convertRunListInputOptionsToFilterRunsOptions( + options, + this.options.prisma + ); + + const query = this.#buildCountQuery(filterOptions); + const result = await this.options.prisma.$queryRaw<{ count: bigint }[]>(query); + + if (result.length === 0) { + throw new Error("No count rows returned"); + } + + return Number(result[0].count); + } + + #buildRunIdsQuery( + filterOptions: FilterRunsOptions, + page: { size: number; cursor?: string; direction?: "forward" | "backward" } + ) { + const whereConditions = this.#buildWhereConditions(filterOptions, page.cursor, page.direction); + + return Prisma.sql` + SELECT tr.id + FROM ${sqlDatabaseSchema}."TaskRun" tr + WHERE ${whereConditions} + ORDER BY ${page.direction === "backward" ? Prisma.sql`tr.id ASC` : Prisma.sql`tr.id DESC`} + LIMIT ${page.size + 1} + `; + } + + #buildRunsQuery( + filterOptions: FilterRunsOptions, + page: { size: number; cursor?: string; direction?: "forward" | "backward" } + ) { + const whereConditions = this.#buildWhereConditions(filterOptions, page.cursor, page.direction); + + return Prisma.sql` + SELECT + tr.id, + tr."friendlyId", + tr."taskIdentifier", + tr."taskVersion", + tr."runtimeEnvironmentId", + tr.status, + tr."createdAt", + tr."startedAt", + tr."lockedAt", + tr."delayUntil", + tr."updatedAt", + tr."completedAt", + tr."isTest", + tr."spanId", + tr."idempotencyKey", + tr."ttl", + tr."expiredAt", + tr."costInCents", + tr."baseCostInCents", + tr."usageDurationMs", + tr."runTags", + tr."depth", + tr."rootTaskRunId", + tr."batchId", + tr."metadata", + tr."metadataType", + tr."machinePreset", + tr."queue" + FROM ${sqlDatabaseSchema}."TaskRun" tr + WHERE ${whereConditions} + ORDER BY ${page.direction === "backward" ? Prisma.sql`tr.id ASC` : Prisma.sql`tr.id DESC`} + LIMIT ${page.size + 1} + `; + } + + #buildCountQuery(filterOptions: FilterRunsOptions) { + const whereConditions = this.#buildWhereConditions(filterOptions); + + return Prisma.sql` + SELECT COUNT(*) as count + FROM ${sqlDatabaseSchema}."TaskRun" tr + WHERE ${whereConditions} + `; + } + + #buildWhereConditions( + filterOptions: FilterRunsOptions, + cursor?: string, + direction?: "forward" | "backward" + ) { + const conditions: Prisma.Sql[] = []; + + // Environment filter + conditions.push(Prisma.sql`tr."runtimeEnvironmentId" = ${filterOptions.environmentId}`); + + // Cursor pagination + if (cursor) { + if (direction === "forward" || !direction) { + conditions.push(Prisma.sql`tr.id < ${cursor}`); + } else { + conditions.push(Prisma.sql`tr.id > ${cursor}`); + } + } + + // Task filters + if (filterOptions.tasks && filterOptions.tasks.length > 0) { + conditions.push(Prisma.sql`tr."taskIdentifier" IN (${Prisma.join(filterOptions.tasks)})`); + } + + // Version filters + if (filterOptions.versions && filterOptions.versions.length > 0) { + conditions.push(Prisma.sql`tr."taskVersion" IN (${Prisma.join(filterOptions.versions)})`); + } + + // Status filters + if (filterOptions.statuses && filterOptions.statuses.length > 0) { + conditions.push( + Prisma.sql`tr.status = ANY(ARRAY[${Prisma.join( + filterOptions.statuses + )}]::"TaskRunStatus"[])` + ); + } + + // Tag filters + if (filterOptions.tags && filterOptions.tags.length > 0) { + conditions.push( + Prisma.sql`tr."runTags" && ARRAY[${Prisma.join(filterOptions.tags)}]::text[]` + ); + } + + // Schedule filter + if (filterOptions.scheduleId) { + conditions.push(Prisma.sql`tr."scheduleId" = ${filterOptions.scheduleId}`); + } + + // Time period filter + if (filterOptions.period) { + conditions.push( + Prisma.sql`tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${filterOptions.period}` + ); + } + + // From date filter + if (filterOptions.from) { + conditions.push( + Prisma.sql`tr."createdAt" >= ${new Date(filterOptions.from).toISOString()}::timestamp` + ); + } + + // To date filter + if (filterOptions.to) { + const toDate = new Date(filterOptions.to); + const now = new Date(); + const clampedDate = toDate > now ? now : toDate; + conditions.push(Prisma.sql`tr."createdAt" <= ${clampedDate.toISOString()}::timestamp`); + } + + // Test filter + if (typeof filterOptions.isTest === "boolean") { + conditions.push(Prisma.sql`tr."isTest" = ${filterOptions.isTest}`); + } + + // Root only filter + if (filterOptions.rootOnly) { + conditions.push(Prisma.sql`tr."rootTaskRunId" IS NULL`); + } + + // Batch filter + if (filterOptions.batchId) { + conditions.push(Prisma.sql`tr."batchId" = ${filterOptions.batchId}`); + } + + // Bulk action filter + if (filterOptions.bulkId) { + conditions.push( + Prisma.sql`tr."bulkActionGroupIds" && ARRAY[${filterOptions.bulkId}]::text[]` + ); + } + + // Run ID filter + if (filterOptions.runId && filterOptions.runId.length > 0) { + const friendlyIds = filterOptions.runId.map((runId) => RunId.toFriendlyId(runId)); + conditions.push(Prisma.sql`tr."friendlyId" IN (${Prisma.join(friendlyIds)})`); + } + + // Queue filter + if (filterOptions.queues && filterOptions.queues.length > 0) { + conditions.push(Prisma.sql`tr."queue" IN (${Prisma.join(filterOptions.queues)})`); + } + + // Machine preset filter + if (filterOptions.machines && filterOptions.machines.length > 0) { + conditions.push(Prisma.sql`tr."machinePreset" IN (${Prisma.join(filterOptions.machines)})`); + } + + // Combine all conditions with AND + return conditions.reduce((acc, condition) => + acc === null ? condition : Prisma.sql`${acc} AND ${condition}` + ); + } +} diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts new file mode 100644 index 00000000000..7b9bf2a368a --- /dev/null +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -0,0 +1,366 @@ +import { type ClickHouse } from "@internal/clickhouse"; +import { type Tracer } from "@internal/tracing"; +import { type Logger, type LogLevel } from "@trigger.dev/core/logger"; +import { MachinePresetName } from "@trigger.dev/core/v3"; +import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic"; +import { type Prisma, TaskRunStatus } from "@trigger.dev/database"; +import parseDuration from "parse-duration"; +import { z } from "zod"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; +import { type PrismaClient } from "~/db.server"; +import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server"; +import { startActiveSpan } from "~/v3/tracer.server"; +import { logger } from "../logger.server"; +import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server"; +import { PostgresRunsRepository } from "./postgresRunsRepository.server"; + +export type RunsRepositoryOptions = { + clickhouse: ClickHouse; + prisma: PrismaClient; + logger?: Logger; + logLevel?: LogLevel; + tracer?: Tracer; +}; + +const RunStatus = z.enum(Object.values(TaskRunStatus) as [TaskRunStatus, ...TaskRunStatus[]]); + +const RunListInputOptionsSchema = z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), + //filters + tasks: z.array(z.string()).optional(), + versions: z.array(z.string()).optional(), + statuses: z.array(RunStatus).optional(), + tags: z.array(z.string()).optional(), + scheduleId: z.string().optional(), + period: z.string().optional(), + from: z.number().optional(), + to: z.number().optional(), + isTest: z.boolean().optional(), + rootOnly: z.boolean().optional(), + batchId: z.string().optional(), + runId: z.array(z.string()).optional(), + bulkId: z.string().optional(), + queues: z.array(z.string()).optional(), + machines: MachinePresetName.array().optional(), +}); + +export type RunListInputOptions = z.infer; +export type RunListInputFilters = Omit< + RunListInputOptions, + "organizationId" | "projectId" | "environmentId" +>; + +export type ParsedRunFilters = RunListInputFilters & { + cursor?: string; + direction?: "forward" | "backward"; +}; + +export type FilterRunsOptions = Omit & { + period: number | undefined; +}; + +type Pagination = { + page: { + size: number; + cursor?: string; + direction?: "forward" | "backward"; + }; +}; + +export type ListedRun = Prisma.TaskRunGetPayload<{ + select: { + id: true; + friendlyId: true; + taskIdentifier: true; + taskVersion: true; + runtimeEnvironmentId: true; + status: true; + createdAt: true; + startedAt: true; + lockedAt: true; + delayUntil: true; + updatedAt: true; + completedAt: true; + isTest: true; + spanId: true; + idempotencyKey: true; + ttl: true; + expiredAt: true; + costInCents: true; + baseCostInCents: true; + usageDurationMs: true; + runTags: true; + depth: true; + rootTaskRunId: true; + batchId: true; + metadata: true; + metadataType: true; + machinePreset: true; + queue: true; + }; +}>; + +export type ListRunsOptions = RunListInputOptions & Pagination; + +export interface IRunsRepository { + name: string; + listRunIds(options: ListRunsOptions): Promise; + listRuns(options: ListRunsOptions): Promise<{ + runs: ListedRun[]; + pagination: { + nextCursor: string | null; + previousCursor: string | null; + }; + }>; + countRuns(options: RunListInputOptions): Promise; +} + +export class RunsRepository implements IRunsRepository { + private readonly clickHouseRunsRepository: ClickHouseRunsRepository; + private readonly postgresRunsRepository: PostgresRunsRepository; + private readonly defaultRepository: "clickhouse" | "postgres"; + private readonly logger: Logger; + + constructor( + private readonly options: RunsRepositoryOptions & { + defaultRepository?: "clickhouse" | "postgres"; + } + ) { + this.clickHouseRunsRepository = new ClickHouseRunsRepository(options); + this.postgresRunsRepository = new PostgresRunsRepository(options); + this.defaultRepository = options.defaultRepository ?? "clickhouse"; + this.logger = options.logger ?? logger; + } + + get name() { + return "runsRepository"; + } + + async #getRepository(): Promise { + return startActiveSpan("runsRepository.getRepository", async (span) => { + const getFlag = makeFlags(this.options.prisma); + const runsListRepository = await getFlag({ + key: FEATURE_FLAG.runsListRepository, + defaultValue: this.defaultRepository, + }); + + span.setAttribute("repository.name", runsListRepository); + + logger.log("runsListRepository", { runsListRepository }); + + switch (runsListRepository) { + case "postgres": + return this.postgresRunsRepository; + case "clickhouse": + default: + return this.clickHouseRunsRepository; + } + }); + } + + async listRunIds(options: ListRunsOptions): Promise { + const repository = await this.#getRepository(); + return startActiveSpan( + "runsRepository.listRunIds", + async () => { + try { + return await repository.listRunIds(options); + } catch (error) { + // If ClickHouse fails, retry with Postgres + if (repository.name === "clickhouse") { + this.logger?.warn("ClickHouse failed, retrying with Postgres", { error }); + return startActiveSpan( + "runsRepository.listRunIds.fallback", + async () => { + return await this.postgresRunsRepository.listRunIds(options); + }, + { + attributes: { + "repository.name": "postgres", + "fallback.reason": "clickhouse_error", + "fallback.error": error instanceof Error ? error.message : String(error), + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + throw error; + } + }, + { + attributes: { + "repository.name": repository.name, + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async listRuns(options: ListRunsOptions): Promise<{ + runs: ListedRun[]; + pagination: { + nextCursor: string | null; + previousCursor: string | null; + }; + }> { + const repository = await this.#getRepository(); + return startActiveSpan( + "runsRepository.listRuns", + async () => { + try { + return await repository.listRuns(options); + } catch (error) { + // If ClickHouse fails, retry with Postgres + if (repository.name === "clickhouse") { + this.logger?.warn("ClickHouse failed, retrying with Postgres", { error }); + return startActiveSpan( + "runsRepository.listRuns.fallback", + async () => { + return await this.postgresRunsRepository.listRuns(options); + }, + { + attributes: { + "repository.name": "postgres", + "fallback.reason": "clickhouse_error", + "fallback.error": error instanceof Error ? error.message : String(error), + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + throw error; + } + }, + { + attributes: { + "repository.name": repository.name, + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + + async countRuns(options: RunListInputOptions): Promise { + const repository = await this.#getRepository(); + return startActiveSpan( + "runsRepository.countRuns", + async () => { + try { + return await repository.countRuns(options); + } catch (error) { + // If ClickHouse fails, retry with Postgres + if (repository.name === "clickhouse") { + this.logger?.warn("ClickHouse failed, retrying with Postgres", { error }); + return startActiveSpan( + "runsRepository.countRuns.fallback", + async () => { + return await this.postgresRunsRepository.countRuns(options); + }, + { + attributes: { + "repository.name": "postgres", + "fallback.reason": "clickhouse_error", + "fallback.error": error instanceof Error ? error.message : String(error), + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + throw error; + } + }, + { + attributes: { + "repository.name": repository.name, + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } +} + +export function parseRunListInputOptions(data: any): RunListInputOptions { + return RunListInputOptionsSchema.parse(data); +} + +export async function convertRunListInputOptionsToFilterRunsOptions( + options: RunListInputOptions, + prisma: RunsRepositoryOptions["prisma"] +): Promise { + const convertedOptions: FilterRunsOptions = { + ...options, + period: undefined, + }; + + // Convert time period to ms + const time = timeFilters({ + period: options.period, + from: options.from, + to: options.to, + }); + convertedOptions.period = time.period ? parseDuration(time.period) ?? undefined : undefined; + + // Batch friendlyId to id + if (options.batchId && options.batchId.startsWith("batch_")) { + const batch = await prisma.batchTaskRun.findFirst({ + select: { + id: true, + }, + where: { + friendlyId: options.batchId, + runtimeEnvironmentId: options.environmentId, + }, + }); + + if (batch) { + convertedOptions.batchId = batch.id; + } + } + + // ScheduleId can be a friendlyId + if (options.scheduleId && options.scheduleId.startsWith("sched_")) { + const schedule = await prisma.taskSchedule.findFirst({ + select: { + id: true, + }, + where: { + friendlyId: options.scheduleId, + projectId: options.projectId, + }, + }); + + if (schedule) { + convertedOptions.scheduleId = schedule?.id; + } + } + + if (options.bulkId && options.bulkId.startsWith("bulk_")) { + convertedOptions.bulkId = BulkActionId.toId(options.bulkId); + } + + if (options.runId) { + // Convert to friendlyId + convertedOptions.runId = options.runId.map((r) => RunId.toFriendlyId(r)); + } + + // Show all runs if we are filtering by batchId or runId + if (options.batchId || options.runId?.length || options.scheduleId || options.tasks?.length) { + convertedOptions.rootOnly = false; + } + + return convertedOptions; +} diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index 6f7c3edce51..f1bc913c422 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -1,23 +1,32 @@ import { z } from "zod"; -import { prisma, PrismaClientOrTransaction } from "~/db.server"; +import { prisma, type PrismaClientOrTransaction } from "~/db.server"; export const FEATURE_FLAG = { defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId", + runsListRepository: "runsListRepository", } as const; const FeatureFlagCatalog = { [FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(), + [FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]), }; type FeatureFlagKey = keyof typeof FeatureFlagCatalog; -export type FlagsOptions = { - key: FeatureFlagKey; +export type FlagsOptions = { + key: T; + defaultValue?: z.infer<(typeof FeatureFlagCatalog)[T]>; }; export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) { - return async function flags( - opts: FlagsOptions + function flags( + opts: FlagsOptions & { defaultValue: z.infer<(typeof FeatureFlagCatalog)[T]> } + ): Promise>; + function flags( + opts: FlagsOptions + ): Promise | undefined>; + async function flags( + opts: FlagsOptions ): Promise | undefined> { const value = await _prisma.featureFlag.findUnique({ where: { @@ -28,16 +37,18 @@ export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) { const parsed = FeatureFlagCatalog[opts.key].safeParse(value?.value); if (!parsed.success) { - return; + return opts.defaultValue; } return parsed.data; - }; + } + + return flags; } export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) { return async function setFlags( - opts: FlagsOptions & { value: z.infer<(typeof FeatureFlagCatalog)[T]> } + opts: FlagsOptions & { value: z.infer<(typeof FeatureFlagCatalog)[T]> } ): Promise { await _prisma.featureFlag.upsert({ where: { @@ -56,3 +67,46 @@ export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) { export const flags = makeFlags(); export const setFlags = makeSetFlags(); + +// Create a Zod schema from the existing catalog +export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog); + +// Utility function to validate a feature flag value +export function validateFeatureFlagValue( + key: T, + value: unknown +): z.SafeParseReturnType> { + return FeatureFlagCatalog[key].safeParse(value); +} + +// Utility function to validate all feature flags at once +export function validateAllFeatureFlags(values: Record) { + return FeatureFlagCatalogSchema.safeParse(values); +} + +// Utility function to validate partial feature flags (all keys optional) +export function validatePartialFeatureFlags(values: Record) { + return FeatureFlagCatalogSchema.partial().safeParse(values); +} + +// Utility function to set multiple feature flags at once +export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma) { + return async function setMultipleFlags( + flags: Partial> + ): Promise<{ key: string; value: any }[]> { + const setFlag = makeSetFlags(_prisma); + const updatedFlags: { key: string; value: any }[] = []; + + for (const [key, value] of Object.entries(flags)) { + if (value !== undefined) { + await setFlag({ + key: key as any, + value: value as any, + }); + updatedFlags.push({ key, value }); + } + } + + return updatedFlags; + }; +} diff --git a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts index a68d6d330f5..98b6079c108 100644 --- a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts @@ -12,7 +12,7 @@ import { parseRunListInputOptions, type RunListInputFilters, RunsRepository, -} from "~/services/runsRepository.server"; +} from "~/services/runsRepository/runsRepository.server"; import { BaseService } from "../baseService.server"; import { commonWorker } from "~/v3/commonWorker.server"; import { env } from "~/env.server"; diff --git a/apps/webapp/test/runsRepository.test.ts b/apps/webapp/test/runsRepository.test.ts index 56e6bf92d6e..37f83d7f0c1 100644 --- a/apps/webapp/test/runsRepository.test.ts +++ b/apps/webapp/test/runsRepository.test.ts @@ -1,6 +1,6 @@ import { containerTest } from "@internal/testcontainers"; import { setTimeout } from "node:timers/promises"; -import { RunsRepository } from "~/services/runsRepository.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { setupClickhouseReplication } from "./utils/replicationUtils"; vi.setConfig({ testTimeout: 60_000 }); From ed86b4f5dbbc6a6d368f00703394148e5b4b82e7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 23 Jul 2025 17:10:20 +0100 Subject: [PATCH 005/641] Run repository test fix (#2302) The normal database needs to be mocked because an import somewhere is using it which causes issues with the test container dbs and hanging at the end. Strategy copied from the trigger test file. --- apps/webapp/test/runsRepository.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/webapp/test/runsRepository.test.ts b/apps/webapp/test/runsRepository.test.ts index 37f83d7f0c1..60b36ecd206 100644 --- a/apps/webapp/test/runsRepository.test.ts +++ b/apps/webapp/test/runsRepository.test.ts @@ -1,3 +1,11 @@ +import { describe, expect, vi } from "vitest"; + +// Mock the db prisma client +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, +})); + import { containerTest } from "@internal/testcontainers"; import { setTimeout } from "node:timers/promises"; import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; From b447a8040f41586a40ef3a5a8af13ec7b8c65d1e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 23 Jul 2025 17:17:13 +0100 Subject: [PATCH 006/641] Use sync insert strategy since we are already batching client-side (#2303) --- apps/webapp/app/env.server.ts | 1 + .../runsReplicationInstance.server.ts | 1 + .../services/runsReplicationService.server.ts | 25 ++++++++++++++----- internal-packages/clickhouse/src/taskRuns.ts | 4 --- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 88006862363..65a7059e239 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -911,6 +911,7 @@ const EnvironmentSchema = z.object({ RUN_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3), RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100), RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000), + RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"), // Clickhouse CLICKHOUSE_URL: z.string(), diff --git a/apps/webapp/app/services/runsReplicationInstance.server.ts b/apps/webapp/app/services/runsReplicationInstance.server.ts index 86b17601a7d..45b7b7a971f 100644 --- a/apps/webapp/app/services/runsReplicationInstance.server.ts +++ b/apps/webapp/app/services/runsReplicationInstance.server.ts @@ -65,6 +65,7 @@ function initializeRunsReplicationInstance() { insertMaxRetries: env.RUN_REPLICATION_INSERT_MAX_RETRIES, insertBaseDelayMs: env.RUN_REPLICATION_INSERT_BASE_DELAY_MS, insertMaxDelayMs: env.RUN_REPLICATION_INSERT_MAX_DELAY_MS, + insertStrategy: env.RUN_REPLICATION_INSERT_STRATEGY, }); if (env.RUN_REPLICATION_ENABLED === "1") { diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 3acd04e4129..aeaea7a046b 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -53,6 +53,7 @@ export type RunsReplicationServiceOptions = { logLevel?: LogLevel; tracer?: Tracer; waitForAsyncInsert?: boolean; + insertStrategy?: "insert" | "insert_async"; // Retry configuration for insert operations insertMaxRetries?: number; insertBaseDelayMs?: number; @@ -90,6 +91,7 @@ export class RunsReplicationService { private _insertMaxRetries: number; private _insertBaseDelayMs: number; private _insertMaxDelayMs: number; + private _insertStrategy: "insert" | "insert_async"; public readonly events: EventEmitter; @@ -101,6 +103,8 @@ export class RunsReplicationService { this._acknowledgeTimeoutMs = options.acknowledgeTimeoutMs ?? 1_000; + this._insertStrategy = options.insertStrategy ?? "insert"; + this._replicationClient = new LogicalReplicationClient({ pgConfig: { connectionString: options.pgConnectionUrl, @@ -598,15 +602,26 @@ export class RunsReplicationService { return delay + jitter; } + #getClickhouseInsertSettings() { + if (this._insertStrategy === "insert") { + return {}; + } else if (this._insertStrategy === "insert_async") { + return { + async_insert: 1 as const, + async_insert_max_data_size: "1000000", + async_insert_busy_timeout_ms: 1000, + wait_for_async_insert: this.options.waitForAsyncInsert ? (1 as const) : (0 as const), + }; + } + } + async #insertTaskRunInserts(taskRunInserts: TaskRunV2[], attempt: number) { return await startSpan(this._tracer, "insertTaskRunsInserts", async (span) => { const [insertError, insertResult] = await this.options.clickhouse.taskRuns.insert( taskRunInserts, { params: { - clickhouse_settings: { - wait_for_async_insert: this.options.waitForAsyncInsert ? 1 : 0, - }, + clickhouse_settings: this.#getClickhouseInsertSettings(), }, } ); @@ -631,9 +646,7 @@ export class RunsReplicationService { payloadInserts, { params: { - clickhouse_settings: { - wait_for_async_insert: this.options.waitForAsyncInsert ? 1 : 0, - }, + clickhouse_settings: this.#getClickhouseInsertSettings(), }, } ); diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index e30affaf84c..1d114772087 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -56,10 +56,6 @@ export function insertTaskRuns(ch: ClickhouseWriter, settings?: ClickHouseSettin table: "trigger_dev.task_runs_v2", schema: TaskRunV2, settings: { - async_insert: 1, - wait_for_async_insert: 0, - async_insert_max_data_size: "1000000", - async_insert_busy_timeout_ms: 1000, enable_json_type: 1, type_json_skip_duplicated_paths: 1, ...settings, From ef59a623685d405c74db582dc173c7405756b61c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 23 Jul 2025 20:12:35 +0100 Subject: [PATCH 007/641] Ability to perform bulk run backfills (#2304) * Ability to perform bulk run backfills * Fix tests * Allow controlling the delay interval when creating the batch --- apps/webapp/app/env.server.ts | 39 ++++ .../app/routes/admin.api.v1.feature-flags.ts | 12 +- ...i.v1.runs-replication.$batchId.backfill.ts | 68 +++++++ ...api.v1.runs-replication.$batchId.cancel.ts | 45 +++++ .../app/services/runsBackfiller.server.ts | 92 +++++++++ .../app/v3/services/adminWorker.server.ts | 102 ++++++++++ apps/webapp/test/runsBackfiller.test.ts | 183 ++++++++++++++++++ packages/redis-worker/src/queue.ts | 58 +++++- packages/redis-worker/src/worker.test.ts | 79 ++++++++ packages/redis-worker/src/worker.ts | 14 ++ 10 files changed, 674 insertions(+), 18 deletions(-) create mode 100644 apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts create mode 100644 apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts create mode 100644 apps/webapp/app/services/runsBackfiller.server.ts create mode 100644 apps/webapp/app/v3/services/adminWorker.server.ts create mode 100644 apps/webapp/test/runsBackfiller.test.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 65a7059e239..b72857e04f1 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -763,6 +763,45 @@ const EnvironmentSchema = z.object({ .default(process.env.REDIS_TLS_DISABLED ?? "false"), BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + ADMIN_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + ADMIN_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + ADMIN_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20), + ADMIN_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + ADMIN_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + + ADMIN_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + ADMIN_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + ADMIN_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + ADMIN_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), + ADMIN_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + ADMIN_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + ADMIN_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + ADMIN_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + ALERTS_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), ALERTS_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), ALERTS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), diff --git a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts index cd7958c5b88..d0e1dd6b274 100644 --- a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts @@ -1,17 +1,7 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server"; -import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; -import { - makeSetFlags, - setFlags, - FeatureFlagCatalogSchema, - validateAllFeatureFlags, - validatePartialFeatureFlags, - makeSetMultipleFlags, -} from "~/v3/featureFlags.server"; -import { z } from "zod"; +import { makeSetMultipleFlags, validatePartialFeatureFlags } from "~/v3/featureFlags.server"; export async function action({ request }: ActionFunctionArgs) { // Next authenticate the request diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts new file mode 100644 index 00000000000..105fcaa408f --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.backfill.ts @@ -0,0 +1,68 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { adminWorker } from "~/v3/services/adminWorker.server"; + +const Body = z.object({ + from: z.coerce.date(), + to: z.coerce.date(), + batchSize: z.number().optional(), + delayIntervalMs: z.number().optional(), +}); + +const Params = z.object({ + batchId: z.string(), +}); + +const DEFAULT_BATCH_SIZE = 500; +const DEFAULT_DELAY_INTERVAL_MS = 1000; + +export async function action({ request, params }: ActionFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { + id: authenticationResult.userId, + }, + }); + + if (!user) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!user.admin) { + return json({ error: "You must be an admin to perform this action" }, { status: 403 }); + } + + const { batchId } = Params.parse(params); + + try { + const body = await request.json(); + + const { from, to, batchSize, delayIntervalMs } = Body.parse(body); + + await adminWorker.enqueue({ + job: "admin.backfillRunsToReplication", + payload: { + from, + to, + batchSize: batchSize ?? DEFAULT_BATCH_SIZE, + delayIntervalMs: delayIntervalMs ?? DEFAULT_DELAY_INTERVAL_MS, + }, + id: batchId, + }); + + return json({ + success: true, + id: batchId, + }); + } catch (error) { + return json({ error: error instanceof Error ? error.message : error }, { status: 400 }); + } +} diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts new file mode 100644 index 00000000000..8dfcf9fb85b --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.$batchId.cancel.ts @@ -0,0 +1,45 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { adminWorker } from "~/v3/services/adminWorker.server"; + +const Params = z.object({ + batchId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { + id: authenticationResult.userId, + }, + }); + + if (!user) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!user.admin) { + return json({ error: "You must be an admin to perform this action" }, { status: 403 }); + } + + const { batchId } = Params.parse(params); + + try { + await adminWorker.cancel(batchId); + + return json({ + success: true, + id: batchId, + }); + } catch (error) { + return json({ error: error instanceof Error ? error.message : error }, { status: 400 }); + } +} diff --git a/apps/webapp/app/services/runsBackfiller.server.ts b/apps/webapp/app/services/runsBackfiller.server.ts new file mode 100644 index 00000000000..a566b44bb3d --- /dev/null +++ b/apps/webapp/app/services/runsBackfiller.server.ts @@ -0,0 +1,92 @@ +import { Tracer } from "@opentelemetry/api"; +import type { PrismaClientOrTransaction } from "@trigger.dev/database"; +import { RunsReplicationService } from "~/services/runsReplicationService.server"; +import { startSpan } from "~/v3/tracing.server"; +import { FINAL_RUN_STATUSES } from "../v3/taskStatus"; +import { Logger } from "@trigger.dev/core/logger"; + +export class RunsBackfillerService { + private readonly prisma: PrismaClientOrTransaction; + private readonly runsReplicationInstance: RunsReplicationService; + private readonly tracer: Tracer; + private readonly logger: Logger; + + constructor(opts: { + prisma: PrismaClientOrTransaction; + runsReplicationInstance: RunsReplicationService; + tracer: Tracer; + logLevel?: "log" | "error" | "warn" | "info" | "debug"; + }) { + this.prisma = opts.prisma; + this.runsReplicationInstance = opts.runsReplicationInstance; + this.tracer = opts.tracer; + this.logger = new Logger("RunsBackfillerService", opts.logLevel ?? "debug"); + } + + public async call({ + from, + to, + cursor, + batchSize, + }: { + from: Date; + to: Date; + cursor?: string; + batchSize?: number; + }): Promise { + return await startSpan(this.tracer, "RunsBackfillerService.call()", async (span) => { + span.setAttribute("from", from.toISOString()); + span.setAttribute("to", to.toISOString()); + span.setAttribute("cursor", cursor ?? ""); + span.setAttribute("batchSize", batchSize ?? 0); + + const runs = await this.prisma.taskRun.findMany({ + where: { + createdAt: { + gte: from, + lte: to, + }, + status: { + in: FINAL_RUN_STATUSES, + }, + ...(cursor ? { id: { gt: cursor } } : {}), + }, + orderBy: { + id: "asc", + }, + take: batchSize, + }); + + if (runs.length === 0) { + this.logger.info("No runs to backfill", { from, to, cursor }); + + return; + } + + this.logger.info("Backfilling runs", { + from, + to, + cursor, + batchSize, + runCount: runs.length, + firstCreatedAt: runs[0].createdAt, + lastCreatedAt: runs[runs.length - 1].createdAt, + }); + + await this.runsReplicationInstance.backfill(runs); + + const lastRun = runs[runs.length - 1]; + + this.logger.info("Backfilled runs", { + from, + to, + cursor, + batchSize, + lastRunId: lastRun.id, + }); + + // Return the last run ID to continue from + return lastRun.id; + }); + } +} diff --git a/apps/webapp/app/v3/services/adminWorker.server.ts b/apps/webapp/app/v3/services/adminWorker.server.ts new file mode 100644 index 00000000000..97c94b954f0 --- /dev/null +++ b/apps/webapp/app/v3/services/adminWorker.server.ts @@ -0,0 +1,102 @@ +import { Logger } from "@trigger.dev/core/logger"; +import { Worker as RedisWorker } from "@trigger.dev/redis-worker"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { runsReplicationInstance } from "~/services/runsReplicationInstance.server"; +import { singleton } from "~/utils/singleton"; +import { tracer } from "../tracer.server"; +import { $replica } from "~/db.server"; +import { RunsBackfillerService } from "../../services/runsBackfiller.server"; + +function initializeWorker() { + const redisOptions = { + keyPrefix: "admin:worker:", + host: env.ADMIN_WORKER_REDIS_HOST, + port: env.ADMIN_WORKER_REDIS_PORT, + username: env.ADMIN_WORKER_REDIS_USERNAME, + password: env.ADMIN_WORKER_REDIS_PASSWORD, + enableAutoPipelining: true, + ...(env.ADMIN_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }; + + logger.debug(`👨‍🏭 Initializing admin worker at host ${env.ADMIN_WORKER_REDIS_HOST}`); + + const worker = new RedisWorker({ + name: "admin-worker", + redisOptions, + catalog: { + "admin.backfillRunsToReplication": { + schema: z.object({ + from: z.coerce.date(), + to: z.coerce.date(), + cursor: z.string().optional(), + batchSize: z.coerce.number().int().default(500), + delayIntervalMs: z.coerce.number().int().default(1000), + }), + visibilityTimeoutMs: 60_000 * 15, // 15 minutes + retry: { + maxAttempts: 5, + }, + }, + }, + concurrency: { + workers: env.ADMIN_WORKER_CONCURRENCY_WORKERS, + tasksPerWorker: env.ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER, + limit: env.ADMIN_WORKER_CONCURRENCY_LIMIT, + }, + pollIntervalMs: env.ADMIN_WORKER_POLL_INTERVAL, + immediatePollIntervalMs: env.ADMIN_WORKER_IMMEDIATE_POLL_INTERVAL, + shutdownTimeoutMs: env.ADMIN_WORKER_SHUTDOWN_TIMEOUT_MS, + logger: new Logger("AdminWorker", env.ADMIN_WORKER_LOG_LEVEL), + jobs: { + "admin.backfillRunsToReplication": async ({ payload, id }) => { + if (!runsReplicationInstance) { + logger.error("Runs replication instance not found"); + return; + } + + const service = new RunsBackfillerService({ + prisma: $replica, + runsReplicationInstance: runsReplicationInstance, + tracer: tracer, + }); + + const cursor = await service.call({ + from: payload.from, + to: payload.to, + cursor: payload.cursor, + batchSize: payload.batchSize, + }); + + if (cursor) { + await worker.enqueue({ + job: "admin.backfillRunsToReplication", + payload: { + from: payload.from, + to: payload.to, + cursor, + batchSize: payload.batchSize, + delayIntervalMs: payload.delayIntervalMs, + }, + id, + availableAt: new Date(Date.now() + payload.delayIntervalMs), + cancellationKey: id, + }); + } + }, + }, + }); + + if (env.ADMIN_WORKER_ENABLED === "true") { + logger.debug( + `👨‍🏭 Starting admin worker at host ${env.ADMIN_WORKER_REDIS_HOST}, pollInterval = ${env.ADMIN_WORKER_POLL_INTERVAL}, immediatePollInterval = ${env.ADMIN_WORKER_IMMEDIATE_POLL_INTERVAL}, workers = ${env.ADMIN_WORKER_CONCURRENCY_WORKERS}, tasksPerWorker = ${env.ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER}, concurrencyLimit = ${env.ADMIN_WORKER_CONCURRENCY_LIMIT}` + ); + + worker.start(); + } + + return worker; +} + +export const adminWorker = singleton("adminWorker", initializeWorker); diff --git a/apps/webapp/test/runsBackfiller.test.ts b/apps/webapp/test/runsBackfiller.test.ts new file mode 100644 index 00000000000..7051fb976f5 --- /dev/null +++ b/apps/webapp/test/runsBackfiller.test.ts @@ -0,0 +1,183 @@ +import { vi } from "vitest"; + +// Mock the db prisma client +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, +})); + +import { ClickHouse } from "@internal/clickhouse"; +import { containerTest } from "@internal/testcontainers"; +import { z } from "zod"; +import { RunsBackfillerService } from "~/services/runsBackfiller.server"; +import { RunsReplicationService } from "~/services/runsReplicationService.server"; +import { createInMemoryTracing } from "./utils/tracing"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunsBackfillerService", () => { + containerTest( + "should backfill completed runs to clickhouse", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + name: "runs-replication", + compression: { + request: true, + }, + }); + + const { tracer, exporter } = createInMemoryTracing(); + + const runsReplicationService = new RunsReplicationService({ + clickhouse, + pgConnectionUrl: postgresContainer.getConnectionUri(), + serviceName: "runs-replication", + slotName: "task_runs_to_clickhouse_v1", + publicationName: "task_runs_to_clickhouse_v1_publication", + redisOptions, + maxFlushConcurrency: 1, + flushIntervalMs: 100, + flushBatchSize: 1, + leaderLockTimeoutMs: 5000, + leaderLockExtendIntervalMs: 1000, + ackIntervalSeconds: 5, + tracer, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Insert 11 completed runs into the database + for (let i = 0; i < 11; i++) { + await prisma.taskRun.create({ + data: { + friendlyId: `run_1234_${i}`, + taskIdentifier: "my-task", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "COMPLETED_SUCCESSFULLY", + }, + }); + } + + // Insert a second run that's not completed + await prisma.taskRun.create({ + data: { + friendlyId: "run_1235", + taskIdentifier: "my-task", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "PENDING", + }, + }); + + // Insert a third run that was created before the "from" date + await prisma.taskRun.create({ + data: { + friendlyId: "run_1236", + taskIdentifier: "my-task", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "COMPLETED_SUCCESSFULLY", + createdAt: new Date(Date.now() - 60000), // 60 seconds ago + }, + }); + + const service = new RunsBackfillerService({ + prisma, + runsReplicationInstance: runsReplicationService, + tracer, + }); + + const from = new Date(Date.now() - 10000); + const to = new Date(Date.now() + 1000); + + const backfillResult = await service.call({ + from, + to, + batchSize: 10, + }); + + expect(backfillResult).toBeDefined(); + + // Okay now use the cursor to backfill again for the next batch + const backfillResult2 = await service.call({ + from, + to, + batchSize: 10, + cursor: backfillResult, + }); + + expect(backfillResult2).toBeDefined(); + + // Now use the cursor to backfill again for the next batch, but this time it will return undefined because there are no more runs to backfill + const backfillResult3 = await service.call({ + from, + to, + batchSize: 10, + cursor: backfillResult2, + }); + + expect(backfillResult3).toBeUndefined(); + + // Check that the row was replicated to clickhouse + const queryRuns = clickhouse.reader.query({ + name: "runs-replication", + query: "SELECT * FROM trigger_dev.task_runs_v2", + schema: z.any(), + }); + + const [queryError, result] = await queryRuns({}); + + expect(queryError).toBeNull(); + expect(result?.length).toBe(11); + } + ); +}); diff --git a/packages/redis-worker/src/queue.ts b/packages/redis-worker/src/queue.ts index b09a9979bdc..e4c593a148d 100644 --- a/packages/redis-worker/src/queue.ts +++ b/packages/redis-worker/src/queue.ts @@ -83,6 +83,10 @@ export class SimpleQueue { this.schema = schema; } + async cancel(cancellationKey: string): Promise { + await this.redis.set(`cancellationKey:${cancellationKey}`, "1", "EX", 60 * 60 * 24); // 1 day + } + async enqueue({ id, job, @@ -90,6 +94,7 @@ export class SimpleQueue { attempt, availableAt, visibilityTimeoutMs, + cancellationKey, }: { id?: string; job: MessageCatalogKey; @@ -97,6 +102,7 @@ export class SimpleQueue { attempt?: number; availableAt?: Date; visibilityTimeoutMs: number; + cancellationKey?: string; }): Promise { try { const score = availableAt ? availableAt.getTime() : Date.now(); @@ -109,13 +115,16 @@ export class SimpleQueue { deduplicationKey, }); - const result = await this.redis.enqueueItem( - `queue`, - `items`, - id ?? nanoid(), - score, - serializedItem - ); + const result = cancellationKey + ? await this.redis.enqueueItemWithCancellationKey( + `queue`, + `items`, + `cancellationKey:${cancellationKey}`, + id ?? nanoid(), + score, + serializedItem + ) + : await this.redis.enqueueItem(`queue`, `items`, id ?? nanoid(), score, serializedItem); if (result !== 1) { throw new Error("Enqueue operation failed"); @@ -409,6 +418,29 @@ export class SimpleQueue { `, }); + this.redis.defineCommand("enqueueItemWithCancellationKey", { + numberOfKeys: 3, + lua: ` + local queue = KEYS[1] + local items = KEYS[2] + local cancellationKey = KEYS[3] + + local id = ARGV[1] + local score = ARGV[2] + local serializedItem = ARGV[3] + + -- if the cancellation key exists, return 1 + if redis.call('EXISTS', cancellationKey) == 1 then + return 1 + end + + redis.call('ZADD', queue, score, id) + redis.call('HSET', items, id, serializedItem) + + return 1 + `, + }); + this.redis.defineCommand("dequeueItems", { numberOfKeys: 2, lua: ` @@ -599,6 +631,18 @@ declare module "@internal/redis" { callback?: Callback ): Result; + enqueueItemWithCancellationKey( + //keys + queue: string, + items: string, + cancellationKey: string, + //args + id: string, + score: number, + serializedItem: string, + callback?: Callback + ): Result; + dequeueItems( //keys queue: string, diff --git a/packages/redis-worker/src/worker.test.ts b/packages/redis-worker/src/worker.test.ts index 8b604be8ae0..e4b6fd3e858 100644 --- a/packages/redis-worker/src/worker.test.ts +++ b/packages/redis-worker/src/worker.test.ts @@ -548,4 +548,83 @@ describe("Worker", () => { await worker.stop(); } ); + + redisTest( + "Should allow cancelling a job before it's enqueued, but only if the enqueue.cancellationKey is provided", + { timeout: 30_000 }, + async ({ redisContainer }) => { + const processedPayloads: string[] = []; + + const worker = new Worker({ + name: "test-worker", + redisOptions: { + host: redisContainer.getHost(), + port: redisContainer.getPort(), + password: redisContainer.getPassword(), + }, + catalog: { + testJob: { + schema: z.object({ value: z.string() }), + visibilityTimeoutMs: 5000, + retry: { maxAttempts: 3 }, + }, + }, + jobs: { + testJob: async ({ payload }) => { + processedPayloads.push(payload.value); + }, + }, + concurrency: { + workers: 1, + tasksPerWorker: 1, + }, + pollIntervalMs: 10, + logger: new Logger("test", "debug"), // Use debug to see all logs + }).start(); + + // Enqueue a job to run immediately + await worker.enqueue({ + id: "immediate-job", + job: "testJob", + payload: { value: "test" }, + cancellationKey: "test-cancellation-key", + }); + + // Verify it's in the present queue + const initialSize = await worker.queue.size(); + const initialSizeWithFuture = await worker.queue.size({ includeFuture: true }); + expect(initialSize).toBe(1); + expect(initialSizeWithFuture).toBe(1); + + // Wait for job to be processed + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Verify job was processed + expect(processedPayloads).toContain("test"); + + // Verify queue is completely empty + const finalSize = await worker.queue.size(); + const finalSizeWithFuture = await worker.queue.size({ includeFuture: true }); + expect(finalSize).toBe(0); + expect(finalSizeWithFuture).toBe(0); + + // Now cancel a key + await worker.cancel("test-cancellation-key-2"); + + await worker.enqueue({ + id: "immediate-job", + job: "testJob", + payload: { value: "test" }, + cancellationKey: "test-cancellation-key-2", + }); + + // Verify it's not in the queue (since it's been cancelled) + const finalSize2 = await worker.queue.size(); + expect(finalSize2).toBe(0); + const finalSize2WithFuture = await worker.queue.size({ includeFuture: true }); + expect(finalSize2WithFuture).toBe(0); + + await worker.stop(); + } + ); }); diff --git a/packages/redis-worker/src/worker.ts b/packages/redis-worker/src/worker.ts index 58db6bef565..a5e77d3a356 100644 --- a/packages/redis-worker/src/worker.ts +++ b/packages/redis-worker/src/worker.ts @@ -264,12 +264,14 @@ class Worker { payload, visibilityTimeoutMs, availableAt, + cancellationKey, }: { id?: string; job: K; payload: z.infer; visibilityTimeoutMs?: number; availableAt?: Date; + cancellationKey?: string; }) { return startSpan( this.tracer, @@ -291,6 +293,7 @@ class Worker { item: payload, visibilityTimeoutMs: timeout, availableAt, + cancellationKey, }), { job_type: String(job), @@ -391,6 +394,17 @@ class Worker { ); } + /** + * Cancels a job before it's enqueued. + * @param cancellationKey - The cancellation key to cancel. + * @returns A promise that resolves when the job is cancelled. + * + * Any jobs enqueued with the same cancellation key will be not be enqueued. + */ + cancel(cancellationKey: string) { + return startSpan(this.tracer, "cancel", () => this.queue.cancel(cancellationKey)); + } + ack(id: string) { return startSpan( this.tracer, From 17bb0c0ceffd2e8f074d212383c177fa8b805616 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 20:22:33 +0100 Subject: [PATCH 008/641] Release v4.0.0-v4-beta.25 * chore: Update version for release (v4-beta) * Release v4.0.0-v4-beta.25 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Eric Allam --- .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 | 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 | 22 +++++++++++----------- 18 files changed, 88 insertions(+), 32 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 01f1e73c963..a3f385b125d 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -26,6 +26,7 @@ "clean-beans-compete", "cuddly-boats-press", "curvy-dogs-share", + "early-points-jam", "eight-ligers-help", "eighty-rings-divide", "fifty-beers-bake", diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index 6c26678b7c5..9a58424fe7a 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/build +## 4.0.0-v4-beta.25 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index 9e9ef7e6a13..6751f8bae25 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.24", + "version": "4.0.0-v4-beta.25", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -73,7 +73,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.24", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", "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 be6392c8b9d..a3bf51b9be5 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,14 @@ # trigger.dev +## 4.0.0-v4-beta.25 + +### Patch Changes + +- Gracefully shutdown task run processes using SIGTERM followed by SIGKILL after a 1s timeout. This also prevents cancelled or completed runs from leaving orphaned Ttask run processes behind ([#2299](https://github.com/triggerdotdev/trigger.dev/pull/2299)) +- Updated dependencies: + - `@trigger.dev/build@4.0.0-v4-beta.25` + - `@trigger.dev/core@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index d61340cb530..846c0675988 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.24", + "version": "4.0.0-v4-beta.25", "description": "A Command-Line Interface for Trigger.dev (v3) projects", "type": "module", "license": "MIT", @@ -93,8 +93,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.24", - "@trigger.dev/core": "workspace:4.0.0-v4-beta.24", + "@trigger.dev/build": "workspace:4.0.0-v4-beta.25", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index e66976d0ed9..513890632d2 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # internal-platform +## 4.0.0-v4-beta.25 + ## 4.0.0-v4-beta.24 ## 4.0.0-v4-beta.23 diff --git a/packages/core/package.json b/packages/core/package.json index 23d2eddcf9b..42c76333a4a 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.24", + "version": "4.0.0-v4-beta.25", "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 1fe4b0ab82f..754b5eac3b0 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.0.0-v4-beta.25 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/build@4.0.0-v4-beta.25` + - `@trigger.dev/core@4.0.0-v4-beta.25` + - `@trigger.dev/sdk@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index 8916e7ef169..c57046bdb0d 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.24", + "version": "4.0.0-v4-beta.25", "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.24", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", "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.24", - "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.24" + "@trigger.dev/build": "workspace:4.0.0-v4-beta.25", + "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.25" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.24", - "@trigger.dev/build": "workspace:^4.0.0-v4-beta.24" + "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.25", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.25" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index c95bff1e04f..8e466a5b467 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.25 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 091b0ef74d1..1390dc41e25 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.24", + "version": "4.0.0-v4-beta.25", "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.24", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.25", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index 3b887f2998a..08a55016653 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.25 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 11754c39fd5..3698cb76e3f 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.24", + "version": "4.0.0-v4-beta.25", "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.24", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", "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 056e903df94..43af1bec883 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.0.0-v4-beta.25 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index fa7d2cef791..3f8a3ca2aa9 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.24", + "version": "4.0.0-v4-beta.25", "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.24", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.25", "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.24", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.25", "@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 a6ed2bdab9f..c454d6c3a98 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.25 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.25` + ## 4.0.0-v4-beta.24 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index eba0ea1aa1b..c42cd98d364 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.24", + "version": "4.0.0-v4-beta.25", "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.24", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87446789277..91ffec85a09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1209,7 +1209,7 @@ importers: packages/build: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../core pkg-types: specifier: ^1.1.3 @@ -1285,10 +1285,10 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../build '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../core ansi-escapes: specifier: ^7.0.0 @@ -1635,7 +1635,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1645,10 +1645,10 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../trigger-sdk '@types/node': specifier: 20.14.14 @@ -1672,7 +1672,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.24 + specifier: workspace:^4.0.0-v4-beta.25 version: link:../core react: specifier: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1706,7 +1706,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../core cron-parser: specifier: ^4.9.0 @@ -1749,7 +1749,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.24 + specifier: workspace:^4.0.0-v4-beta.25 version: link:../core mlly: specifier: ^1.7.1 @@ -1765,7 +1765,7 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:^4.0.0-v4-beta.24 + specifier: workspace:^4.0.0-v4-beta.25 version: link:../build '@types/node': specifier: ^20.14.14 @@ -1798,7 +1798,7 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.24 + specifier: workspace:4.0.0-v4-beta.25 version: link:../core chalk: specifier: ^5.2.0 From 3dce1f66f769183abd7fe87cf2673478662ce22e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 24 Jul 2025 09:38:24 +0100 Subject: [PATCH 009/641] docs: beta.25 changelog entry (#2306) --- docs/upgrade-to-v4.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/upgrade-to-v4.mdx b/docs/upgrade-to-v4.mdx index 528888b6f9b..d001a190f85 100644 --- a/docs/upgrade-to-v4.mdx +++ b/docs/upgrade-to-v4.mdx @@ -1175,3 +1175,11 @@ We recommend enabling this option and testing in a staging or preview environmen - Added runs.list filtering for queue and machine ([#2277](https://github.com/triggerdotdev/trigger.dev/pull/2277)) + + + [Release + v4.0.0-beta.25](https://github.com/triggerdotdev/trigger.dev/releases/tag/trigger.dev%404.0.0-v4-beta.25). + +- Gracefully shutdown task run processes using SIGTERM followed by SIGKILL after a 1s timeout. This also prevents cancelled or completed runs from leaving orphaned task run processes behind ([#2299](https://github.com/triggerdotdev/trigger.dev/pull/2299)) + + From 6791328a940d7bed72c87b4b7a037ce5185def36 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 24 Jul 2025 10:17:54 +0100 Subject: [PATCH 010/641] Queues dashboard update (burst concurrency) (#2293) * Initial burst changes to queue page * Added tooltip and changed wording around * View runs from Queues page * Fix for ugly Version filter "Current" badge --- .../app/components/metrics/BigNumber.tsx | 2 +- .../app/components/primitives/Buttons.tsx | 4 +- .../app/components/runs/v3/RunFilters.tsx | 6 +- .../v3/EnvironmentQueuePresenter.server.ts | 2 + .../route.tsx | 224 +++++++++++++++--- 5 files changed, 196 insertions(+), 42 deletions(-) diff --git a/apps/webapp/app/components/metrics/BigNumber.tsx b/apps/webapp/app/components/metrics/BigNumber.tsx index 7c4441be345..df3fa9e0a43 100644 --- a/apps/webapp/app/components/metrics/BigNumber.tsx +++ b/apps/webapp/app/components/metrics/BigNumber.tsx @@ -14,7 +14,7 @@ interface BigNumberProps { valueClassName?: string; defaultValue?: number; accessory?: ReactNode; - suffix?: string; + suffix?: ReactNode; suffixClassName?: string; compactThreshold?: number; } diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index bafd772b0ae..6859608eef3 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -163,6 +163,8 @@ const allVariants = { variant: variant, }; +export type ButtonVariant = keyof typeof variant; + export type ButtonContentPropsType = { children?: React.ReactNode; LeadingIcon?: RenderIcon; @@ -173,7 +175,7 @@ export type ButtonContentPropsType = { textAlignLeft?: boolean; className?: string; shortcut?: ShortcutDefinition; - variant: keyof typeof variant; + variant: ButtonVariant; shortcutPosition?: "before-trailing-icon" | "after-trailing-icon"; tooltip?: ReactNode; iconSpacing?: string; diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 9eae1e1eb51..f1153417cd6 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1286,8 +1286,10 @@ function VersionsDropdown({ {filtered.length > 0 ? filtered.map((version) => ( - {version.version}{" "} - {version.isCurrent ? current : null} + + {version.version} + {version.isCurrent ? Current : null} + )) : null} diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts index 7469a2c0b1b..b73c8260811 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts @@ -7,6 +7,7 @@ export type Environment = { running: number; queued: number; concurrencyLimit: number; + burstFactor: number; }; export class EnvironmentQueuePresenter extends BasePresenter { @@ -26,6 +27,7 @@ export class EnvironmentQueuePresenter extends BasePresenter { running, queued, concurrencyLimit: environment.maximumConcurrencyLimit, + burstFactor: environment.concurrencyLimitBurstFactor.toNumber(), }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index de6dea27112..13817f96675 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -30,7 +30,7 @@ import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { BigNumber } from "~/components/metrics/BigNumber"; import { Badge } from "~/components/primitives/Badge"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -48,6 +48,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { + InfoIconTooltip, SimpleTooltip, Tooltip, TooltipContent, @@ -65,13 +66,14 @@ import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePrese import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; -import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder"; import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; import { PauseQueueService } from "~/v3/services/pauseQueue.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { Header3 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { useThrottle } from "~/hooks/useThrottle"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -238,6 +240,16 @@ export default function Page() { } }, [streamedEvents]); + const limitStatus = + environment.running === environment.concurrencyLimit * environment.burstFactor + ? "limit" + : environment.running > environment.concurrencyLimit + ? "burst" + : "within"; + + const limitClassName = + limitStatus === "burst" ? "text-warning" : limitStatus === "limit" ? "text-error" : undefined; + return ( @@ -261,7 +273,20 @@ export default function Page() { value={environment.queued} suffix={env.paused && environment.queued > 0 ? "paused" : undefined} animate - accessory={} + accessory={ +
+ + View runs + + +
+ } valueClassName={env.paused ? "text-warning" : undefined} compactThreshold={1000000} /> @@ -269,13 +294,27 @@ export default function Page() { title="Running" value={environment.running} animate - valueClassName={ - environment.running === environment.concurrencyLimit ? "text-warning" : undefined - } + valueClassName={limitClassName} suffix={ - environment.running === environment.concurrencyLimit - ? "At concurrency limit" - : undefined + limitStatus === "burst" ? ( + + Including {environment.running - environment.concurrencyLimit} burst runs{" "} + + + ) : limitStatus === "limit" ? ( + "At concurrency limit" + ) : undefined + } + accessory={ + + View runs + } compactThreshold={1000000} /> @@ -283,8 +322,14 @@ export default function Page() { title="Concurrency limit" value={environment.concurrencyLimit} animate - valueClassName={ - environment.running === environment.concurrencyLimit ? "text-warning" : undefined + valueClassName={limitClassName} + suffix={ + environment.burstFactor > 1 ? ( + + Burst limit {environment.burstFactor * environment.concurrencyLimit}{" "} + + + ) : undefined } accessory={ plan ? ( @@ -323,7 +368,14 @@ export default function Page() { pagination.totalPages > 1 && "grid-rows-[auto_1fr_auto]" )} > - +
+ + +
@@ -370,6 +422,9 @@ export default function Page() { queues.map((queue) => { const limit = queue.concurrencyLimit ?? environment.concurrencyLimit; const isAtLimit = queue.running === limit; + const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${ + queue.name + }`; return ( @@ -450,6 +505,66 @@ export default function Page() { hiddenButtons={ !queue.paused && } + popoverContent={ + <> + {queue.paused ? ( + + ) : ( + + )} + + View all runs + + + View queued runs + + + View running runs + + + } /> ); @@ -603,40 +718,56 @@ function EnvironmentPauseResumeButton({ function QueuePauseResumeButton({ queue, + variant = "tertiary/small", + fullWidth = false, + showTooltip = true, }: { /** The "id" here is a friendlyId */ queue: { id: string; name: string; paused: boolean }; + variant?: ButtonVariant; + fullWidth?: boolean; + showTooltip?: boolean; }) { const navigation = useNavigation(); const [isOpen, setIsOpen] = useState(false); + const button = ( + + ); + + const trigger = showTooltip ? ( +
+ + + +
+ {button} +
+
+ + {queue.paused + ? `Resume processing runs in queue "${queue.name}"` + : `Pause processing runs in queue "${queue.name}"`} + +
+
+
+ ) : ( + {button} + ); + return ( -
- - - -
- - - -
-
- - {queue.paused - ? `Resume processing runs in queue "${queue.name}"` - : `Pause processing runs in queue "${queue.name}"`} - -
-
-
+ {trigger} {queue.paused ? "Resume queue?" : "Pause queue?"}
@@ -743,7 +874,7 @@ export function QueueFilters() { const search = searchParams.get("query") ?? ""; return ( -
+
); } + +function BurstFactorTooltip({ + environment, +}: { + environment: { burstFactor: number; concurrencyLimit: number }; +}) { + return ( + + ); +} From 74808d7400d6dbea48e4dd718c4ff3d88d35f65c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 24 Jul 2025 15:17:26 +0200 Subject: [PATCH 011/641] Pre-merge and pre-sort run batches before sending to clickhouse (#2308) * Premerge run batch before sending it to clickhouse * Pre-order batch items before sending to clickhouse in favor of performance * existing * Emit event on batch flushes * When merging batches, keep the last occurrence items with the same version * Add a couple of tests --- .../services/runsReplicationService.server.ts | 69 +++- .../test/runsReplicationService.part2.test.ts | 344 +++++++++++++++++- 2 files changed, 400 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index aeaea7a046b..60badb2ebc5 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -1,5 +1,5 @@ import type { ClickHouse, RawTaskRunPayloadV1, TaskRunV2 } from "@internal/clickhouse"; -import { RedisOptions } from "@internal/redis"; +import { type RedisOptions } from "@internal/redis"; import { LogicalReplicationClient, type MessageDelete, @@ -8,14 +8,13 @@ import { type PgoutputMessage, } from "@internal/replication"; import { recordSpanError, startSpan, trace, type Tracer } from "@internal/tracing"; -import { Logger, LogLevel } from "@trigger.dev/core/logger"; +import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import { tryCatch } from "@trigger.dev/core/utils"; import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; -import { TaskRun } from "@trigger.dev/database"; +import { type TaskRun } from "@trigger.dev/database"; import { nanoid } from "nanoid"; import EventEmitter from "node:events"; import pLimit from "p-limit"; -import { logger } from "./logger.server"; import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings"; interface TransactionEvent { @@ -64,6 +63,9 @@ type TaskRunInsert = { _version: bigint; run: TaskRun; event: "insert" | "update export type RunsReplicationServiceEvents = { message: [{ lsn: string; message: PgoutputMessage; service: RunsReplicationService }]; + batchFlushed: [ + { flushId: string; taskRunInserts: TaskRunV2[]; payloadInserts: RawTaskRunPayloadV1[] } + ]; }; export class RunsReplicationService { @@ -130,6 +132,29 @@ export class RunsReplicationService { flushInterval: options.flushIntervalMs ?? 100, maxConcurrency: options.maxFlushConcurrency ?? 100, callback: this.#flushBatch.bind(this), + // we can do some pre-merging to reduce the amount of data we need to send to clickhouse + mergeBatch: (existingBatch: TaskRunInsert[], newBatch: TaskRunInsert[]) => { + const merged = new Map(); + + for (const item of existingBatch) { + const key = `${item.event}_${item.run.id}`; + merged.set(key, item); + } + + for (const item of newBatch) { + const key = `${item.event}_${item.run.id}`; + const existingItem = merged.get(key); + + // Keep the run with the higher version (latest) + // and take the last occurrence for that version. + // Items originating from the same DB transaction have the same version. + if (!existingItem || item._version >= existingItem._version) { + merged.set(key, item); + } + } + + return Array.from(merged.values()); + }, logger: new Logger("ConcurrentFlushScheduler", options.logLevel ?? "info"), tracer: options.tracer, }); @@ -467,11 +492,33 @@ export class RunsReplicationService { const taskRunInserts = preparedInserts .map(({ taskRunInsert }) => taskRunInsert) - .filter(Boolean); + .filter(Boolean) + // batch inserts in clickhouse are more performant if the items + // are pre-sorted by the primary key + .sort((a, b) => { + if (a.organization_id !== b.organization_id) { + return a.organization_id < b.organization_id ? -1 : 1; + } + if (a.project_id !== b.project_id) { + return a.project_id < b.project_id ? -1 : 1; + } + if (a.environment_id !== b.environment_id) { + return a.environment_id < b.environment_id ? -1 : 1; + } + if (a.created_at !== b.created_at) { + return a.created_at - b.created_at; + } + return a.run_id < b.run_id ? -1 : 1; + }); const payloadInserts = preparedInserts .map(({ payloadInsert }) => payloadInsert) - .filter(Boolean); + .filter(Boolean) + // batch inserts in clickhouse are more performant if the items + // are pre-sorted by the primary key + .sort((a, b) => { + return a.run_id < b.run_id ? -1 : 1; + }); span.setAttribute("task_run_inserts", taskRunInserts.length); span.setAttribute("payload_inserts", payloadInserts.length); @@ -519,6 +566,8 @@ export class RunsReplicationService { taskRunInserts: taskRunInserts.length, payloadInserts: payloadInserts.length, }); + + this.events.emit("batchFlushed", { flushId, taskRunInserts, payloadInserts }); }); } @@ -825,12 +874,13 @@ export type ConcurrentFlushSchedulerConfig = { flushInterval: number; maxConcurrency?: number; callback: (flushId: string, batch: T[]) => Promise; + mergeBatch?: (existingBatch: T[], newBatch: T[]) => T[]; tracer?: Tracer; logger?: Logger; }; export class ConcurrentFlushScheduler { - private currentBatch: T[]; // Adjust the type according to your data structure + private currentBatch: T[]; private readonly BATCH_SIZE: number; private readonly flushInterval: number; private readonly MAX_CONCURRENCY: number; @@ -855,7 +905,10 @@ export class ConcurrentFlushScheduler { } addToBatch(items: T[]): void { - this.currentBatch = this.currentBatch.concat(items); + this.currentBatch = this.config.mergeBatch + ? this.config.mergeBatch(this.currentBatch, items) + : this.currentBatch.concat(items); + this.#flushNextBatchIfNeeded(); } diff --git a/apps/webapp/test/runsReplicationService.part2.test.ts b/apps/webapp/test/runsReplicationService.part2.test.ts index cb04867f939..e08b579738a 100644 --- a/apps/webapp/test/runsReplicationService.part2.test.ts +++ b/apps/webapp/test/runsReplicationService.part2.test.ts @@ -754,11 +754,6 @@ describe("RunsReplicationService (part 2/2)", () => { expect(queryError).toBeNull(); expect(result?.length).toBe(10); - console.log("Data", { - runsData, - result, - }); - // Check a few random runs for correctness for (let i = 0; i < 9; i++) { const expected = runsData[i]; @@ -783,4 +778,343 @@ describe("RunsReplicationService (part 2/2)", () => { await runsReplicationService.stop(); } ); + + containerTest( + "should merge duplicate event+run.id combinations keeping the latest version", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + await prisma.$executeRawUnsafe(`ALTER TABLE public.\"TaskRun\" REPLICA IDENTITY FULL;`); + + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + name: "runs-replication-merge-batch", + }); + + const runsReplicationService = new RunsReplicationService({ + clickhouse, + pgConnectionUrl: postgresContainer.getConnectionUri(), + serviceName: "runs-replication-merge-batch", + slotName: "task_runs_to_clickhouse_v1", + publicationName: "task_runs_to_clickhouse_v1_publication", + redisOptions, + maxFlushConcurrency: 1, + flushIntervalMs: 100, + flushBatchSize: 10, // Higher batch size to test merging + leaderLockTimeoutMs: 5000, + leaderLockExtendIntervalMs: 1000, + ackIntervalSeconds: 5, + logger: new Logger("runs-replication-merge-batch", "info"), + }); + + // Listen to batchFlushed events to verify merging + const batchFlushedEvents: Array<{ + flushId: string; + taskRunInserts: any[]; + payloadInserts: any[]; + }> = []; + + runsReplicationService.events.on("batchFlushed", (event) => { + batchFlushedEvents.push(event); + }); + + await runsReplicationService.start(); + + const organization = await prisma.organization.create({ + data: { + title: "test-merge-batch", + slug: "test-merge-batch", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test-merge-batch", + slug: "test-merge-batch", + organizationId: organization.id, + externalRef: "test-merge-batch", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test-merge-batch", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test-merge-batch", + pkApiKey: "test-merge-batch", + shortcode: "test-merge-batch", + }, + }); + + // Create a run and rapidly update it multiple times in a transaction + // This should create multiple events for the same run that get merged + const run = await prisma.taskRun.create({ + data: { + friendlyId: `run_merge_${Date.now()}`, + taskIdentifier: "my-task-merge", + payload: JSON.stringify({ version: 1 }), + payloadType: "application/json", + traceId: `merge-${Date.now()}`, + spanId: `merge-${Date.now()}`, + queue: "test-merge-batch", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "PENDING_VERSION", + }, + }); + await prisma.taskRun.update({ + where: { id: run.id }, + data: { status: "DEQUEUED" }, + }); + await prisma.taskRun.update({ + where: { id: run.id }, + data: { status: "EXECUTING" }, + }); + await prisma.taskRun.update({ + where: { id: run.id }, + data: { status: "PAUSED" }, + }); + await prisma.taskRun.update({ + where: { id: run.id }, + data: { status: "EXECUTING" }, + }); + await prisma.taskRun.update({ + where: { id: run.id }, + data: { status: "COMPLETED_SUCCESSFULLY" }, + }); + + await setTimeout(1000); + + expect(batchFlushedEvents?.[0].taskRunInserts).toHaveLength(2); + expect(batchFlushedEvents?.[0].taskRunInserts[0]).toEqual( + expect.objectContaining({ + run_id: run.id, + status: "PENDING_VERSION", + }) + ); + expect(batchFlushedEvents?.[0].taskRunInserts[1]).toEqual( + expect.objectContaining({ + run_id: run.id, + status: "COMPLETED_SUCCESSFULLY", + }) + ); + + await runsReplicationService.stop(); + } + ); + + containerTest( + "should sort batch inserts according to table schema ordering for optimal performance", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + await prisma.$executeRawUnsafe(`ALTER TABLE public.\"TaskRun\" REPLICA IDENTITY FULL;`); + + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + name: "runs-replication-sorting", + }); + + const runsReplicationService = new RunsReplicationService({ + clickhouse, + pgConnectionUrl: postgresContainer.getConnectionUri(), + serviceName: "runs-replication-sorting", + slotName: "task_runs_to_clickhouse_v1", + publicationName: "task_runs_to_clickhouse_v1_publication", + redisOptions, + maxFlushConcurrency: 1, + flushIntervalMs: 100, + flushBatchSize: 10, + leaderLockTimeoutMs: 5000, + leaderLockExtendIntervalMs: 1000, + ackIntervalSeconds: 5, + logger: new Logger("runs-replication-sorting", "info"), + }); + + // Listen to batchFlushed events to verify sorting + const batchFlushedEvents: Array<{ + flushId: string; + taskRunInserts: any[]; + payloadInserts: any[]; + }> = []; + + runsReplicationService.events.on("batchFlushed", (event) => { + batchFlushedEvents.push(event); + }); + + await runsReplicationService.start(); + + // Create two organizations to test sorting by organization_id + const org1 = await prisma.organization.create({ + data: { title: "org-z", slug: "org-z" }, + }); + + const org2 = await prisma.organization.create({ + data: { title: "org-a", slug: "org-a" }, + }); + + const project1 = await prisma.project.create({ + data: { + name: "test-sorting-z", + slug: "test-sorting-z", + organizationId: org1.id, + externalRef: "test-sorting-z", + }, + }); + + const project2 = await prisma.project.create({ + data: { + name: "test-sorting-a", + slug: "test-sorting-a", + organizationId: org2.id, + externalRef: "test-sorting-a", + }, + }); + + const env1 = await prisma.runtimeEnvironment.create({ + data: { + slug: "test-sorting-z", + type: "DEVELOPMENT", + projectId: project1.id, + organizationId: org1.id, + apiKey: "test-sorting-z", + pkApiKey: "test-sorting-z", + shortcode: "test-sorting-z", + }, + }); + + const env2 = await prisma.runtimeEnvironment.create({ + data: { + slug: "test-sorting-a", + type: "DEVELOPMENT", + projectId: project2.id, + organizationId: org2.id, + apiKey: "test-sorting-a", + pkApiKey: "test-sorting-a", + shortcode: "test-sorting-a", + }, + }); + + const now = Date.now(); + + const run1 = await prisma.taskRun.create({ + data: { + friendlyId: `run_sort_org_z_${now}`, + taskIdentifier: "my-task-sort", + payload: JSON.stringify({ org: "z" }), + payloadType: "application/json", + traceId: `sort-z-${now}`, + spanId: `sort-z-${now}`, + queue: "test-sorting", + runtimeEnvironmentId: env1.id, + projectId: project1.id, + organizationId: org1.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "PENDING", + createdAt: new Date(now + 2000), + }, + }); + await prisma.taskRun.update({ + where: { id: run1.id }, + data: { status: "DEQUEUED" }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: `run_sort_org_a_${now}`, + taskIdentifier: "my-task-sort", + payload: JSON.stringify({ org: "a" }), + payloadType: "application/json", + traceId: `sort-a-${now}`, + spanId: `sort-a-${now}`, + queue: "test-sorting", + runtimeEnvironmentId: env2.id, + projectId: project2.id, + organizationId: org2.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "PENDING", + createdAt: new Date(now + 1000), + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: `run_sort_org_a_${now}_2`, + taskIdentifier: "my-task-sort", + payload: JSON.stringify({ org: "a" }), + payloadType: "application/json", + traceId: `sort-a-${now}`, + spanId: `sort-a-${now}`, + queue: "test-sorting", + runtimeEnvironmentId: env2.id, + projectId: project2.id, + organizationId: org2.id, + environmentType: "DEVELOPMENT", + engine: "V2", + status: "PENDING", + createdAt: new Date(now), + }, + }); + + await setTimeout(1000); + + expect(batchFlushedEvents[0]?.taskRunInserts.length).toBeGreaterThan(1); + expect(batchFlushedEvents[0]?.payloadInserts.length).toBeGreaterThan(1); + + // Verify sorting order: organization_id, project_id, environment_id, created_at, run_id + for (let i = 1; i < batchFlushedEvents[0]?.taskRunInserts.length; i++) { + const prev = batchFlushedEvents[0]?.taskRunInserts[i - 1]; + const curr = batchFlushedEvents[0]?.taskRunInserts[i]; + + const prevKey = [ + prev.organization_id, + prev.project_id, + prev.environment_id, + prev.created_at, + prev.run_id, + ]; + const currKey = [ + curr.organization_id, + curr.project_id, + curr.environment_id, + curr.created_at, + curr.run_id, + ]; + + const keysAreEqual = prevKey.every((val, idx) => val === currKey[idx]); + if (keysAreEqual) { + // Also valid order + continue; + } + + // Compare tuples lexicographically + let isCorrectOrder = false; + for (let j = 0; j < prevKey.length; j++) { + if (prevKey[j] < currKey[j]) { + isCorrectOrder = true; + break; + } + if (prevKey[j] > currKey[j]) { + isCorrectOrder = false; + break; + } + // If equal, continue to next field + } + + expect(isCorrectOrder).toBeTruthy(); + } + + // Verify payloadInserts are also sorted by run_id + for (let i = 1; i < batchFlushedEvents[0]?.payloadInserts.length; i++) { + const prev = batchFlushedEvents[0]?.payloadInserts[i - 1]; + const curr = batchFlushedEvents[0]?.payloadInserts[i]; + expect(prev.run_id <= curr.run_id).toBeTruthy(); + } + + await runsReplicationService.stop(); + } + ); }); From 09d0e80804d37ea411d7e0d29844c1e0ee786240 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 24 Jul 2025 15:09:50 +0100 Subject: [PATCH 012/641] Add sentry error reporting (#2309) * Sentry WIP * Configure sentry for uploading and releasing during the publish webapp step * Delete source maps after uploading * Forward logger.error calls to sentry through Logger.onError * Couple tweaks to the dockerfile --- .github/workflows/publish-webapp.yml | 5 + apps/webapp/app/entry.server.tsx | 19 +- .../app/routes/admin.api.v1.simulate-error.ts | 12 + apps/webapp/app/services/logger.server.ts | 36 + apps/webapp/package.json | 18 +- apps/webapp/sentry.server.ts | 27 + apps/webapp/server.ts | 2 + apps/webapp/upload-sourcemaps.sh | 13 + docker/Dockerfile | 13 +- packages/core/src/logger.ts | 7 + pnpm-lock.yaml | 801 ++++++++++++++++-- references/effect/.gitignore | 1 + references/effect/package.json | 17 + references/effect/src/trigger/effect.ts | 37 + references/effect/trigger.config.ts | 22 + references/effect/tsconfig.json | 15 + 16 files changed, 965 insertions(+), 80 deletions(-) create mode 100644 apps/webapp/app/routes/admin.api.v1.simulate-error.ts create mode 100644 apps/webapp/sentry.server.ts create mode 100755 apps/webapp/upload-sourcemaps.sh create mode 100644 references/effect/.gitignore create mode 100644 references/effect/package.json create mode 100644 references/effect/src/trigger/effect.ts create mode 100644 references/effect/trigger.config.ts create mode 100644 references/effect/tsconfig.json diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index ed5a259a853..6fcc30209ab 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -86,3 +86,8 @@ jobs: BUILD_GIT_SHA=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} BUILD_GIT_REF_NAME=${{ steps.set_build_info.outputs.BUILD_GIT_REF_NAME }} BUILD_TIMESTAMP_SECONDS=${{ steps.set_build_info.outputs.BUILD_TIMESTAMP_SECONDS }} + SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} + SENTRY_ORG=triggerdev + SENTRY_PROJECT=trigger-cloud + secrets: | + sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index d05fabd90bb..1c17c9baf28 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -16,6 +16,7 @@ import { } from "./components/primitives/OperatingSystemProvider"; import { singleton } from "./utils/singleton"; import { bootstrap } from "./bootstrap"; +import { wrapHandleErrorWithSentry } from "@sentry/remix"; const ABORT_DELAY = 30000; @@ -170,9 +171,21 @@ function handleBrowserRequest( }); } -export function handleError(error: unknown, { request, params, context }: DataFunctionArgs) { - logError(error, request); -} +export const handleError = wrapHandleErrorWithSentry((error, { request }) => { + if (request instanceof Request) { + logger.error("Error in handleError", { + error, + request: { + url: request.url, + method: request.method, + }, + }); + } else { + logger.error("Error in handleError", { + error, + }); + } +}); Worker.init().catch((error) => { logError(error); diff --git a/apps/webapp/app/routes/admin.api.v1.simulate-error.ts b/apps/webapp/app/routes/admin.api.v1.simulate-error.ts new file mode 100644 index 00000000000..6dcf681e09f --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.simulate-error.ts @@ -0,0 +1,12 @@ +import { type DataFunctionArgs } from "@remix-run/node"; +import { requireUser } from "~/services/session.server"; + +export async function loader({ request }: DataFunctionArgs) { + const user = await requireUser(request); + + if (!user.admin) { + throw new Response("You must be an admin to perform this action", { status: 403 }); + } + + throw new Error("Test error"); +} diff --git a/apps/webapp/app/services/logger.server.ts b/apps/webapp/app/services/logger.server.ts index 2d208bca07a..be5baff9c85 100644 --- a/apps/webapp/app/services/logger.server.ts +++ b/apps/webapp/app/services/logger.server.ts @@ -3,6 +3,7 @@ import { Logger } from "@trigger.dev/core/logger"; import { sensitiveDataReplacer } from "./sensitiveDataReplacer"; import { AsyncLocalStorage } from "async_hooks"; import { getHttpContext } from "./httpAsyncStorage.server"; +import { captureException, captureMessage } from "@sentry/remix"; const currentFieldsStore = new AsyncLocalStorage>(); @@ -10,6 +11,41 @@ export function trace(fields: Record, fn: () => T): T { return currentFieldsStore.run(fields, fn); } +Logger.onError = (message, ...args) => { + const error = extractErrorFromArgs(args); + + if (error) { + captureException(error, { + extra: { + message, + ...flattenArgs(args), + }, + }); + } else { + captureMessage(message, { + level: "error", + extra: flattenArgs(args), + }); + } +}; + +function extractErrorFromArgs(args: Array | undefined>) { + for (const arg of args) { + if (arg && "error" in arg && arg.error instanceof Error) { + return arg.error; + } + } + return; +} + +function flattenArgs(args: Array | undefined>) { + return args.reduce((acc, arg) => { + if (arg) { + return { ...acc, ...arg }; + } + return acc; + }, {}); +} export const logger = new Logger( "webapp", (process.env.APP_LOG_LEVEL ?? "debug") as LogLevel, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index c0ed2e0c588..86ac8347499 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -4,10 +4,11 @@ "version": "1.0.0", "sideEffects": false, "scripts": { - "build": "run-s build:**", + "build": "run-s build:** && pnpm run upload:sourcemaps", "build:db:seed": "esbuild --platform=node --bundle --minify --format=cjs ./prisma/seed.ts --outdir=prisma", - "build:remix": "remix build", - "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build", + "build:remix": "remix build --sourcemap", + "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap", + "build:sentry": "esbuild --platform=node --format=cjs ./sentry.server.ts --outdir=build --sourcemap", "dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"", "dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js", "format": "prettier --write .", @@ -19,10 +20,7 @@ "db:seed:local": "ts-node prisma/seed.ts", "build:db:populate": "esbuild --platform=node --bundle --minify --format=cjs ./prisma/populate.ts --outdir=prisma", "db:populate": "node prisma/populate.js --", - "generate:sourcemaps": "remix build --sourcemap", - "clean:sourcemaps": "run-s clean:sourcemaps:*", - "clean:sourcemaps:public": "rimraf ./build/**/*.map", - "clean:sourcemaps:build": "rimraf ./public/build/**/*.map", + "upload:sourcemaps": "bash ./upload-sourcemaps.sh", "test": "vitest --no-file-parallelism", "eval:dev": "evalite watch" }, @@ -103,6 +101,8 @@ "@remix-run/serve": "2.1.0", "@remix-run/server-runtime": "2.1.0", "@remix-run/v1-meta": "^0.1.3", + "@sentry/node-native": "^9.40.0", + "@sentry/remix": "^9.40.0", "@slack/web-api": "7.9.1", "@socket.io/redis-adapter": "^8.3.0", "@splinetool/react-spline": "^2.2.6", @@ -142,7 +142,6 @@ "express": "4.20.0", "framer-motion": "^10.12.11", "graphile-worker": "0.16.6", - "highlight.run": "^7.3.4", "humanize-duration": "^3.27.3", "input-otp": "^1.4.2", "intl-parse-accept-language": "^1.0.0", @@ -218,6 +217,7 @@ "@remix-run/testing": "^2.1.0", "@swc/core": "^1.3.4", "@swc/helpers": "^0.4.11", + "@sentry/cli": "2.50.2", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", "@total-typescript/ts-reset": "^0.4.2", @@ -279,4 +279,4 @@ "engines": { "node": ">=16.0.0" } -} +} \ No newline at end of file diff --git a/apps/webapp/sentry.server.ts b/apps/webapp/sentry.server.ts new file mode 100644 index 00000000000..38f8ab895a3 --- /dev/null +++ b/apps/webapp/sentry.server.ts @@ -0,0 +1,27 @@ +import * as Sentry from "@sentry/remix"; +import { eventLoopBlockIntegration } from "@sentry/node-native"; + +if (process.env.SENTRY_DSN) { + console.log("🔭 Initializing Sentry"); + + Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: process.env.BUILD_GIT_SHA, + + // Adds request headers and IP for users, for more info visit: and captures action formData attributes + // https://docs.sentry.io/platforms/javascript/guides/remix/configuration/options/#sendDefaultPii + sendDefaultPii: false, + + skipOpenTelemetrySetup: true, + registerEsmLoaderHooks: false, + disableInstrumentationWarnings: true, + + maxBreadcrumbs: 0, + shutdownTimeout: 10, + + serverName: process.env.SERVICE_NAME, + environment: process.env.APP_ENV, + + integrations: [eventLoopBlockIntegration({ threshold: 1000 })], + }); +} diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 1ad45fb538a..455fded7a37 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -1,3 +1,5 @@ +import "./sentry.server"; + import { createRequestHandler } from "@remix-run/express"; import { broadcastDevReady, logDevReady } from "@remix-run/server-runtime"; import compression from "compression"; diff --git a/apps/webapp/upload-sourcemaps.sh b/apps/webapp/upload-sourcemaps.sh new file mode 100755 index 00000000000..699399e4bd2 --- /dev/null +++ b/apps/webapp/upload-sourcemaps.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -eo pipefail + +if [ -n "$SENTRY_ORG" ] && [ -n "$SENTRY_PROJECT" ] && [ -n "$SENTRY_AUTH_TOKEN" ] && [ -n "$SENTRY_RELEASE" ]; then + sentry-cli releases new $SENTRY_RELEASE + sentry-cli sourcemaps inject ./build + sentry-cli sourcemaps upload ./build --release $SENTRY_RELEASE + # Now we need to delete the sourcemaps from the build directory + rm -rf ./build/*.map +else + echo "Skipping sourcemap upload: Missing required environment variables" + echo "Required: SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN, SENTRY_RELEASE" +fi diff --git a/docker/Dockerfile b/docker/Dockerfile index 62343b652e9..8757fe2aa5e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,10 +42,19 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpx prisma@ ## Builder (builds the webapp) FROM base AS builder +# This is needed for the sentry-cli binary while building the webapp +RUN apt-get update && apt-get install -y openssl dumb-init ca-certificates WORKDIR /triggerdotdev # Corepack is used to install pnpm RUN corepack enable +ARG SENTRY_RELEASE +ARG SENTRY_ORG +ARG SENTRY_PROJECT +ENV SENTRY_RELEASE=${SENTRY_RELEASE} \ + SENTRY_ORG=${SENTRY_ORG} \ + SENTRY_PROJECT=${SENTRY_PROJECT} + # Goose and schemas COPY --from=goose_builder /go/bin/goose /usr/local/bin/goose RUN chmod +x /usr/local/bin/goose @@ -60,7 +69,9 @@ RUN chmod +x ./scripts/entrypoint.sh COPY --chown=node:node .configs/tsconfig.base.json .configs/tsconfig.base.json COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts RUN pnpm run generate -RUN pnpm run build --filter=webapp... +RUN --mount=type=secret,id=sentry_auth_token \ + SENTRY_AUTH_TOKEN=$(cat /run/secrets/sentry_auth_token) \ + pnpm run build --filter=webapp... # Runner FROM ${NODE_IMAGE} AS runner diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index e63987405b9..7b0de5db093 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -23,6 +23,9 @@ export class Logger { #jsonReplacer?: (key: string, value: unknown) => unknown; #additionalFields: () => Record; + // Add a static "onError" method that will be called when an error is logged + static onError: (message: string, ...args: Array | undefined>) => void; + constructor( name: string, level: LogLevel = "info", @@ -67,6 +70,10 @@ export class Logger { if (this.#level < 1) return; this.#structuredLog(console.error, message, "error", ...args); + + if (Logger.onError) { + Logger.onError(message, ...args); + } } warn(message: string, ...args: Array | undefined>) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91ffec85a09..84f2b94eeac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,12 @@ importers: '@remix-run/v1-meta': specifier: ^0.1.3 version: 0.1.3(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0) + '@sentry/node-native': + specifier: ^9.40.0 + version: 9.40.0 + '@sentry/remix': + specifier: ^9.40.0 + version: 9.40.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0)(react@18.2.0) '@slack/web-api': specifier: 7.9.1 version: 7.9.1 @@ -518,9 +524,6 @@ importers: graphile-worker: specifier: 0.16.6 version: 0.16.6(patch_hash=hdpetta7btqcc7xb5wfkcnanoa)(typescript@5.5.4) - highlight.run: - specifier: ^7.3.4 - version: 7.3.4 humanize-duration: specifier: ^3.27.3 version: 3.27.3 @@ -735,6 +738,9 @@ importers: '@remix-run/testing': specifier: ^2.1.0 version: 2.1.0(react-dom@18.2.0)(react@18.2.0)(typescript@5.5.4) + '@sentry/cli': + specifier: 2.50.2 + version: 2.50.2 '@swc/core': specifier: ^1.3.4 version: 1.3.26 @@ -2072,6 +2078,22 @@ importers: specifier: ^5 version: 5.5.4 + references/effect: + dependencies: + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build + '@trigger.dev/sdk': + specifier: workspace:* + version: link:../../packages/trigger-sdk + effect: + specifier: 3.17.1 + version: 3.17.1 + devDependencies: + trigger.dev: + specifier: workspace:* + version: link:../../packages/cli-v3 + references/hello-world: dependencies: '@trigger.dev/build': @@ -2260,7 +2282,7 @@ importers: version: 0.3.0 '@effect/schema': specifier: ^0.75.5 - version: 0.75.5(effect@3.16.3) + version: 0.75.5(effect@3.17.1) '@infisical/sdk': specifier: ^2.3.5 version: 2.3.5 @@ -6157,15 +6179,15 @@ packages: effect: ^3.7.2 dependencies: effect: 3.7.2 - fast-check: 3.22.0 + fast-check: 3.23.2 dev: false - /@effect/schema@0.75.5(effect@3.16.3): + /@effect/schema@0.75.5(effect@3.17.1): resolution: {integrity: sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw==} peerDependencies: effect: ^3.9.2 dependencies: - effect: 3.16.3 + effect: 3.17.1 fast-check: 3.22.0 dev: false @@ -9616,7 +9638,7 @@ packages: dependencies: agent-base: 7.1.1 http-proxy-agent: 7.0.0 - https-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 lru-cache: 10.4.3 socks-proxy-agent: 8.0.4 transitivePeerDependencies: @@ -9731,6 +9753,13 @@ packages: '@opentelemetry/api': 1.9.0 dev: false + /@opentelemetry/api-logs@0.57.2: + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + dependencies: + '@opentelemetry/api': 1.9.0 + dev: false + /@opentelemetry/api@1.4.1: resolution: {integrity: sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==} engines: {node: '>=8.0.0'} @@ -9771,6 +9800,15 @@ packages: '@opentelemetry/api': 1.9.0 dev: false + /@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + dev: false + /@opentelemetry/core@1.22.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==} engines: {node: '>=14'} @@ -10014,6 +10052,47 @@ packages: '@opentelemetry/semantic-conventions': 1.25.1 dev: false + /@opentelemetry/instrumentation-amqplib@0.46.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-connect@0.43.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-dataloader@0.16.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/instrumentation-express@0.36.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-ltIE4kIMa+83QjW/p7oe7XCESF29w3FQ9/T1VgShdX7fzm56K2a0xfEX1vF8lnHRGERYxIWX9D086C6gJOjVGA==} engines: {node: '>=14'} @@ -10042,6 +10121,20 @@ packages: - supports-color dev: false + /@opentelemetry/instrumentation-express@0.47.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/instrumentation-fetch@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-hizhULZXlq02y8YC0vPQ4WtUWiXcwxPdEqHBy8p75jzF9rAuP/ldrVr0Oxvz5Xr9qQcdEOFLvEl0ZxbVL76WKw==} engines: {node: '>=14'} @@ -10072,6 +10165,57 @@ packages: - supports-color dev: false + /@opentelemetry/instrumentation-fs@0.19.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-generic-pool@0.43.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-graphql@0.47.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-hapi@0.45.2(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/instrumentation-http@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-Yib5zrW2s0V8wTeUK/B3ZtpyP4ldgXj9L3Ws/axXrW1dW0/mEFKifK50MxMQK9g5NNJQS9dWH7rvcEGZdWdQDA==} engines: {node: '>=14'} @@ -10102,6 +10246,201 @@ packages: - supports-color dev: false + /@opentelemetry/instrumentation-http@0.57.2(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + forwarded-parse: 2.1.2 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-ioredis@0.47.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-kafkajs@0.7.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-knex@0.44.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-koa@0.47.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-lru-memoizer@0.44.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-mongodb@0.52.0(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-mongoose@0.46.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-mysql2@0.45.2(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-mysql@0.45.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/mysql': 2.15.26 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-pg@0.51.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.0) + '@types/pg': 8.6.1 + '@types/pg-pool': 2.0.6 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-redis-4@0.46.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-tedious@0.18.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + dev: false + + /@opentelemetry/instrumentation-undici@0.10.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/instrumentation-undici@0.2.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-RH9WdVRtpnyp8kvya2RYqKsJouPxvHl7jKPsIfrbL8u2QCKloAGi0uEqDHoOS15ZRYPQTDXZ7d8jSpUgSQmvpA==} engines: {node: '>=14'} @@ -10217,6 +10556,23 @@ packages: - supports-color dev: false + /@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.11.0 + require-in-the-middle: 7.1.1(supports-color@10.0.0) + semver: 7.7.2 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/otlp-exporter-base@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==} engines: {node: '>=14'} @@ -10394,6 +10750,11 @@ packages: '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) dev: false + /@opentelemetry/redis-common@0.36.2: + resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==} + engines: {node: '>=14'} + dev: false + /@opentelemetry/resources@1.22.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==} engines: {node: '>=14'} @@ -10449,6 +10810,17 @@ packages: '@opentelemetry/semantic-conventions': 1.28.0 dev: false + /@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + dev: false + /@opentelemetry/sdk-logs@0.49.1(@opentelemetry/api-logs@0.49.1)(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==} engines: {node: '>=14'} @@ -10642,6 +11014,18 @@ packages: '@opentelemetry/semantic-conventions': 1.28.0 dev: false + /@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + dev: false + /@opentelemetry/sdk-trace-node@1.22.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-gTGquNz7ue8uMeiWPwp3CU321OstQ84r7PCDtOaCicjbJxzvO8RZMlEC4geOipTeiF88kss5n6w+//A0MhP1lQ==} engines: {node: '>=14'} @@ -10724,6 +11108,21 @@ packages: engines: {node: '>=14'} dev: false + /@opentelemetry/semantic-conventions@1.36.0: + resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} + engines: {node: '>=14'} + dev: false + + /@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + dev: false + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -10904,6 +11303,17 @@ packages: - supports-color dev: false + /@prisma/instrumentation@6.11.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA==} + peerDependencies: + '@opentelemetry/api': ^1.8 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + transitivePeerDependencies: + - supports-color + dev: false + /@prisma/internals@5.3.1: resolution: {integrity: sha512-zkW73hPHHNrMD21PeYgCTBfMu71vzJf+WtfydtJbS0JVJKyLfOel0iWSQg7wjNeQfccKp+NdHJ/5rTJ4NEUzgA==} dependencies: @@ -16958,18 +17368,68 @@ packages: selderee: 0.11.0 dev: false + /@sentry-internal/browser-utils@9.40.0: + resolution: {integrity: sha512-Ajvz6jN+EEMKrOHcUv2+HlhbRUh69uXhhRoBjJw8sc61uqA2vv3QWyBSmTRoHdTnLGboT5bKEhHIkzVXb+YgEw==} + engines: {node: '>=18'} + dependencies: + '@sentry/core': 9.40.0 + dev: false + + /@sentry-internal/feedback@9.40.0: + resolution: {integrity: sha512-39UbLdGWGvSJ7bAzRnkv91cBdd6fLbdkLVVvqE2ZUfegm7+rH1mRPglmEhw4VE4mQfKZM1zWr/xus2+XPqJcYw==} + engines: {node: '>=18'} + dependencies: + '@sentry/core': 9.40.0 + dev: false + + /@sentry-internal/node-native-stacktrace@0.2.1: + resolution: {integrity: sha512-sIfIj0LFL8WKxifRB6xtD2u4a1imeTNywk5PrXXqZnYLXgR7mr1vb9oGNxh8YJNCwsmr1EtitCm5IjgXWJtQ2Q==} + engines: {node: '>=18'} + requiresBuild: true + dependencies: + detect-libc: 2.0.4 + node-abi: 3.75.0 + dev: false + + /@sentry-internal/replay-canvas@9.40.0: + resolution: {integrity: sha512-GLoJ4R4Uipd7Vb+0LzSJA2qCyN1J6YalQIoDuOJTfYyykHvKltds5D8a/5S3Q6d8PcL/nxTn93fynauGEZt2Ow==} + engines: {node: '>=18'} + dependencies: + '@sentry-internal/replay': 9.40.0 + '@sentry/core': 9.40.0 + dev: false + + /@sentry-internal/replay@9.40.0: + resolution: {integrity: sha512-WrmCvqbLJQC45IFRVN3k0J5pU5NkdX0e9o6XxjcmDiATKk00RHnW4yajnCJ8J1cPR4918yqiJHPX5xpG08BZNA==} + engines: {node: '>=18'} + dependencies: + '@sentry-internal/browser-utils': 9.40.0 + '@sentry/core': 9.40.0 + dev: false + /@sentry/babel-plugin-component-annotate@2.22.2: resolution: {integrity: sha512-6kFAHGcs0npIC4HTt4ULs8uOfEucvMI7VW4hoyk17jhRaW8CbxzxfWCfIeRbDkE8pYwnARaq83tu025Hrk2zgA==} engines: {node: '>= 14'} dev: false + /@sentry/browser@9.40.0: + resolution: {integrity: sha512-qz/1Go817vcsbcIwgrz4/T34vi3oQ4UIqikosuaCTI9wjZvK0HyW3QmLvTbAnsE7G7h6+UZsVkpO5R16IQvQhQ==} + engines: {node: '>=18'} + dependencies: + '@sentry-internal/browser-utils': 9.40.0 + '@sentry-internal/feedback': 9.40.0 + '@sentry-internal/replay': 9.40.0 + '@sentry-internal/replay-canvas': 9.40.0 + '@sentry/core': 9.40.0 + dev: false + /@sentry/bundler-plugin-core@2.22.2: resolution: {integrity: sha512-TwEEW4FeEJ5Mamp4fGnktfVjzN77KAW0xFQsEPuxZtOAPG17zX/PGvdyRX/TE1jkZWhTzqUDIdgzqlNLjyEnUw==} engines: {node: '>= 14'} dependencies: '@babel/core': 7.22.17 '@sentry/babel-plugin-component-annotate': 2.22.2 - '@sentry/cli': 2.33.1 + '@sentry/cli': 2.50.2 dotenv: 16.4.7 find-up: 5.0.0 glob: 9.3.5 @@ -16980,70 +17440,71 @@ packages: - supports-color dev: false - /@sentry/cli-darwin@2.33.1: - resolution: {integrity: sha512-+4/VIx/E1L2hChj5nGf5MHyEPHUNHJ/HoG5RY+B+vyEutGily1c1+DM2bum7RbD0xs6wKLIyup5F02guzSzG8A==} + /@sentry/cli-darwin@2.50.2: + resolution: {integrity: sha512-0Pjpl0vQqKhwuZm19z6AlEF+ds3fJg1KWabv8WzGaSc/fwxMEwjFwOZj+IxWBJPV578cXXNvB39vYjjpCH8j7A==} engines: {node: '>=10'} os: [darwin] requiresBuild: true - dev: false optional: true - /@sentry/cli-linux-arm64@2.33.1: - resolution: {integrity: sha512-DbGV56PRKOLsAZJX27Jt2uZ11QfQEMmWB4cIvxkKcFVE+LJP4MVA+MGGRUL6p+Bs1R9ZUuGbpKGtj0JiG6CoXw==} + /@sentry/cli-linux-arm64@2.50.2: + resolution: {integrity: sha512-03Cj215M3IdoHAwevCxm5oOm9WICFpuLR05DQnODFCeIUsGvE1pZsc+Gm0Ky/ZArq2PlShBJTpbHvXbCUka+0w==} engines: {node: '>=10'} cpu: [arm64] - os: [linux, freebsd] + os: [linux, freebsd, android] requiresBuild: true - dev: false optional: true - /@sentry/cli-linux-arm@2.33.1: - resolution: {integrity: sha512-zbxEvQju+tgNvzTOt635le4kS/Fbm2XC2RtYbCTs034Vb8xjrAxLnK0z1bQnStUV8BkeBHtsNVrG+NSQDym2wg==} + /@sentry/cli-linux-arm@2.50.2: + resolution: {integrity: sha512-jzFwg9AeeuFAFtoCcyaDEPG05TU02uOy1nAX09c1g7FtsyQlPcbhI94JQGmnPzdRjjDmORtwIUiVZQrVTkDM7w==} engines: {node: '>=10'} cpu: [arm] - os: [linux, freebsd] + os: [linux, freebsd, android] requiresBuild: true - dev: false optional: true - /@sentry/cli-linux-i686@2.33.1: - resolution: {integrity: sha512-g2LS4oPXkPWOfKWukKzYp4FnXVRRSwBxhuQ9eSw2peeb58ZIObr4YKGOA/8HJRGkooBJIKGaAR2mH2Pk1TKaiA==} + /@sentry/cli-linux-i686@2.50.2: + resolution: {integrity: sha512-J+POvB34uVyHbIYF++Bc/OCLw+gqKW0H/y/mY7rRZCiocgpk266M4NtsOBl6bEaurMx1D+BCIEjr4nc01I/rqA==} engines: {node: '>=10'} cpu: [x86, ia32] - os: [linux, freebsd] + os: [linux, freebsd, android] requiresBuild: true - dev: false optional: true - /@sentry/cli-linux-x64@2.33.1: - resolution: {integrity: sha512-IV3dcYV/ZcvO+VGu9U6kuxSdbsV2kzxaBwWUQxtzxJ+cOa7J8Hn1t0koKGtU53JVZNBa06qJWIcqgl4/pCuKIg==} + /@sentry/cli-linux-x64@2.50.2: + resolution: {integrity: sha512-81yQVRLj8rnuHoYcrM7QbOw8ubA3weiMdPtTxTim1s6WExmPgnPTKxLCr9xzxGJxFdYo3xIOhtf5JFpUX/3j4A==} engines: {node: '>=10'} cpu: [x64] - os: [linux, freebsd] + os: [linux, freebsd, android] + requiresBuild: true + optional: true + + /@sentry/cli-win32-arm64@2.50.2: + resolution: {integrity: sha512-QjentLGvpibgiZlmlV9ifZyxV73lnGH6pFZWU5wLeRiaYKxWtNrrHpVs+HiWlRhkwQ0mG1/S40PGNgJ20DJ3gA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] requiresBuild: true - dev: false optional: true - /@sentry/cli-win32-i686@2.33.1: - resolution: {integrity: sha512-F7cJySvkpzIu7fnLKNHYwBzZYYwlhoDbAUnaFX0UZCN+5DNp/5LwTp37a5TWOsmCaHMZT4i9IO4SIsnNw16/zQ==} + /@sentry/cli-win32-i686@2.50.2: + resolution: {integrity: sha512-UkBIIzkQkQ1UkjQX8kHm/+e7IxnEhK6CdgSjFyNlxkwALjDWHJjMztevqAPz3kv4LdM6q1MxpQ/mOqXICNhEGg==} engines: {node: '>=10'} cpu: [x86, ia32] os: [win32] requiresBuild: true - dev: false optional: true - /@sentry/cli-win32-x64@2.33.1: - resolution: {integrity: sha512-8VyRoJqtb2uQ8/bFRKNuACYZt7r+Xx0k2wXRGTyH05lCjAiVIXn7DiS2BxHFty7M1QEWUCMNsb/UC/x/Cu2wuA==} + /@sentry/cli-win32-x64@2.50.2: + resolution: {integrity: sha512-tE27pu1sRRub1Jpmemykv3QHddBcyUk39Fsvv+n4NDpQyMgsyVPcboxBZyby44F0jkpI/q3bUH2tfCB1TYDNLg==} engines: {node: '>=10'} cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true - /@sentry/cli@2.33.1: - resolution: {integrity: sha512-dUlZ4EFh98VFRPJ+f6OW3JEYQ7VvqGNMa0AMcmvk07ePNeK/GicAWmSQE4ZfJTTl80ul6HZw1kY01fGQOQlVRA==} + /@sentry/cli@2.50.2: + resolution: {integrity: sha512-m1L9shxutF3WHSyNld6Y1vMPoXfEyQhoRh1V3SYSdl+4AB40U+zr2sRzFa2OPm7XP4zYNaWuuuHLkY/iHITs8Q==} engines: {node: '>= 10'} hasBin: true requiresBuild: true @@ -17054,16 +17515,21 @@ packages: proxy-from-env: 1.1.0 which: 2.0.2 optionalDependencies: - '@sentry/cli-darwin': 2.33.1 - '@sentry/cli-linux-arm': 2.33.1 - '@sentry/cli-linux-arm64': 2.33.1 - '@sentry/cli-linux-i686': 2.33.1 - '@sentry/cli-linux-x64': 2.33.1 - '@sentry/cli-win32-i686': 2.33.1 - '@sentry/cli-win32-x64': 2.33.1 + '@sentry/cli-darwin': 2.50.2 + '@sentry/cli-linux-arm': 2.50.2 + '@sentry/cli-linux-arm64': 2.50.2 + '@sentry/cli-linux-i686': 2.50.2 + '@sentry/cli-linux-x64': 2.50.2 + '@sentry/cli-win32-arm64': 2.50.2 + '@sentry/cli-win32-i686': 2.50.2 + '@sentry/cli-win32-x64': 2.50.2 transitivePeerDependencies: - encoding - supports-color + + /@sentry/core@9.40.0: + resolution: {integrity: sha512-cZkuz6BDna6VXSqvlWnrRsaDx4QBKq1PcfQrqhVz8ljs0M7Gcl+Mtj8dCzUxx12fkYM62hQXG72DEGNlAQpH/Q==} + engines: {node: '>=18'} dev: false /@sentry/esbuild-plugin@2.22.2: @@ -17078,6 +17544,143 @@ packages: - supports-color dev: false + /@sentry/node-core@9.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1)(@opentelemetry/core@1.30.1)(@opentelemetry/instrumentation@0.57.2)(@opentelemetry/resources@1.30.1)(@opentelemetry/sdk-trace-base@1.30.1)(@opentelemetry/semantic-conventions@1.36.0): + resolution: {integrity: sha512-97JONDa8NxItX0Cz5WQPMd1gQjzodt38qQ0OzZNFvYg2Cpvxob8rxwsNA08Liu7B97rlvsvqMt+Wbgw8SAMfgQ==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.0.0 + '@opentelemetry/core': ^1.30.1 || ^2.0.0 + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/resources': ^1.30.1 || ^2.0.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 + '@opentelemetry/semantic-conventions': ^1.34.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@sentry/core': 9.40.0 + '@sentry/opentelemetry': 9.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1)(@opentelemetry/core@1.30.1)(@opentelemetry/sdk-trace-base@1.30.1)(@opentelemetry/semantic-conventions@1.36.0) + import-in-the-middle: 1.14.2 + dev: false + + /@sentry/node-native@9.40.0: + resolution: {integrity: sha512-uA5eNbSbzDBD5ptFZ2WFkqUBLVc2+OWHKmxu7guiQH3aiAAb6l/mlITdTtsUBFhqKssR/yPgEA99cDppOWWlpA==} + engines: {node: '>=18'} + dependencies: + '@sentry-internal/node-native-stacktrace': 0.2.1 + '@sentry/core': 9.40.0 + '@sentry/node': 9.40.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@sentry/node@9.40.0: + resolution: {integrity: sha512-8bVWChXzGH4QmbVw+H/yiJ6zxqPDhnx11fEAP+vpL1UBm1cAV67CoB4eS7OqQdPC8gF/BQb2sqF0TvY/12NPpA==} + engines: {node: '>=18'} + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.16.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.19.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.45.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.52.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.45.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.45.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.51.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis-4': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.18.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.10.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@prisma/instrumentation': 6.11.1(@opentelemetry/api@1.9.0) + '@sentry/core': 9.40.0 + '@sentry/node-core': 9.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1)(@opentelemetry/core@1.30.1)(@opentelemetry/instrumentation@0.57.2)(@opentelemetry/resources@1.30.1)(@opentelemetry/sdk-trace-base@1.30.1)(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/opentelemetry': 9.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1)(@opentelemetry/core@1.30.1)(@opentelemetry/sdk-trace-base@1.30.1)(@opentelemetry/semantic-conventions@1.36.0) + import-in-the-middle: 1.14.2 + minimatch: 9.0.5 + transitivePeerDependencies: + - supports-color + dev: false + + /@sentry/opentelemetry@9.40.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1)(@opentelemetry/core@1.30.1)(@opentelemetry/sdk-trace-base@1.30.1)(@opentelemetry/semantic-conventions@1.36.0): + resolution: {integrity: sha512-POQ/ZFmBbi15z3EO9gmTExpxCfW0Ug+WooA8QZPJaizo24gcF5AMOgwuGFwT2YLw/2HdPWjPUPujNNGdCWM6hw==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.0.0 + '@opentelemetry/core': ^1.30.1 || ^2.0.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 + '@opentelemetry/semantic-conventions': ^1.34.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@sentry/core': 9.40.0 + dev: false + + /@sentry/react@9.40.0(react@18.2.0): + resolution: {integrity: sha512-y00d33qozmQAKroQ4Kk2jxhznprPBOb55SL4LOpNPRHGEomxZCUeM3geltczrf14JsGowCr5+xlT+cZQ2XcNlA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + dependencies: + '@sentry/browser': 9.40.0 + '@sentry/core': 9.40.0 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + dev: false + + /@sentry/remix@9.40.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0)(react@18.2.0): + resolution: {integrity: sha512-8cXI06jqCqzwH/132Z1K1hKBid4HKoC8LfE1sBa6hJGu+/dosbYIy7M8wCYrD5gIAcIacEIWlGyB4xOBjyXTpA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@remix-run/node': 2.x + '@remix-run/react': 2.x + '@remix-run/server-runtime': 2.x + react: 18.x + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@remix-run/node': 2.1.0(typescript@5.5.4) + '@remix-run/react': 2.1.0(react-dom@18.2.0)(react@18.2.0)(typescript@5.5.4) + '@remix-run/router': 1.15.3 + '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) + '@sentry/cli': 2.50.2 + '@sentry/core': 9.40.0 + '@sentry/node': 9.40.0 + '@sentry/react': 9.40.0(react@18.2.0) + glob: 10.4.5 + react: 18.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /@sideway/address@4.1.4: resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} dependencies: @@ -19017,6 +19620,12 @@ packages: '@types/node': 20.14.14 dev: true + /@types/connect@3.4.38: + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + dependencies: + '@types/node': 20.14.14 + dev: false + /@types/cookie@0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} @@ -19335,6 +19944,12 @@ packages: '@types/node': 20.14.14 dev: false + /@types/mysql@2.15.26: + resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + dependencies: + '@types/node': 20.14.14 + dev: false + /@types/node-fetch@2.6.12: resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} dependencies: @@ -19405,13 +20020,18 @@ packages: resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} dev: true + /@types/pg-pool@2.0.6: + resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} + dependencies: + '@types/pg': 8.11.14 + dev: false + /@types/pg@8.11.14: resolution: {integrity: sha512-qyD11E5R3u0eJmd1lB0WnWKXJGA7s015nyARWljfz5DcX83TKAIlY+QrmvzQTsbIe+hkiFtkyL2gHC6qwF6Fbg==} dependencies: '@types/node': 20.14.14 pg-protocol: 1.9.5 pg-types: 4.0.2 - dev: true /@types/pg@8.11.6: resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} @@ -19421,6 +20041,14 @@ packages: pg-types: 4.0.2 dev: false + /@types/pg@8.6.1: + resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + dependencies: + '@types/node': 20.14.14 + pg-protocol: 1.9.5 + pg-types: 2.2.0 + dev: false + /@types/pg@8.6.6: resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==} dependencies: @@ -19563,6 +20191,10 @@ packages: /@types/shimmer@1.0.2: resolution: {integrity: sha512-dKkr1bTxbEsFlh2ARpKzcaAmsYixqt9UyCdoEZk8rHyE4iQYcDCyvSjDSf7JUWJHlJiTtbIoQjxKh6ViywqDAg==} + /@types/shimmer@1.2.0: + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + dev: false + /@types/simple-oauth2@5.0.4: resolution: {integrity: sha512-4SvTfmAa1fGUa1d07j9vIiC4o92bGh0ihPXmtS05udMMmNwVIaU2nZ706cC4wI8cJxOlHD4P/d5tzqvWYd+KxA==} dev: true @@ -19628,6 +20260,12 @@ packages: '@types/node': 18.19.20 minipass: 4.0.0 + /@types/tedious@4.0.14: + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + dependencies: + '@types/node': 20.14.14 + dev: false + /@types/tinycolor2@1.4.3: resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==} @@ -20530,12 +21168,28 @@ packages: dependencies: acorn: 8.12.1 + /acorn-import-assertions@1.9.0(acorn@8.14.1): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.14.1 + dev: false + /acorn-import-attributes@1.9.5(acorn@8.12.1): resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: acorn: ^8 dependencies: acorn: 8.12.1 + dev: false + + /acorn-import-attributes@1.9.5(acorn@8.14.1): + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.14.1 /acorn-jsx@5.3.2(acorn@8.12.1): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -20544,6 +21198,14 @@ packages: dependencies: acorn: 8.12.1 + /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 + dev: true + /acorn-node@1.8.2: resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} dependencies: @@ -20599,15 +21261,6 @@ packages: debug: 4.4.0(supports-color@10.0.0) transitivePeerDependencies: - supports-color - dev: false - - /agent-base@7.1.0: - resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} - engines: {node: '>= 14'} - dependencies: - debug: 4.4.0(supports-color@10.0.0) - transitivePeerDependencies: - - supports-color /agent-base@7.1.1: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} @@ -23467,8 +24120,8 @@ packages: fast-check: 3.22.0 dev: false - /effect@3.16.3: - resolution: {integrity: sha512-SWndb1UavNWvet1+hnkU4qp3EHtnmDKhUeP14eB+7vf/2nCFlM77/oIjdDeZctveibNjE65P9H/sBBmF0NTy/w==} + /effect@3.17.1: + resolution: {integrity: sha512-t917ks10FGNf7MpwOxHUg6vo42p0XsdMHuBMVpy4NttPu5gIv8/ah5MgbHLVQJ2kmDvZfQUT1/xyCa1IR09u2Q==} dependencies: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 @@ -25450,6 +26103,10 @@ packages: once: 1.4.0 dev: true + /forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + dev: false + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -26217,8 +26874,10 @@ packages: /highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - /highlight.run@7.3.4: - resolution: {integrity: sha512-Rgx+gy0tb2tH4hNzxYi/VK5pL/msaAtaQBIy8XsPHLujdSgo5OPWO6vOdjjB7ufM1l/CI2RLmlQ+L2QZOuHBjw==} + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 dev: false /hono@4.5.11: @@ -26299,7 +26958,7 @@ packages: resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==} engines: {node: '>= 14'} dependencies: - agent-base: 7.1.0 + agent-base: 7.1.1 debug: 4.4.0(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -26331,7 +26990,6 @@ packages: debug: 4.4.0(supports-color@10.0.0) transitivePeerDependencies: - supports-color - dev: false /https-proxy-agent@7.0.2: resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==} @@ -26341,6 +26999,7 @@ packages: debug: 4.4.0(supports-color@10.0.0) transitivePeerDependencies: - supports-color + dev: true /https-proxy-agent@7.0.5: resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} @@ -26439,6 +27098,15 @@ packages: module-details-from-path: 1.0.3 dev: false + /import-in-the-middle@1.14.2: + resolution: {integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==} + dependencies: + acorn: 8.14.1 + acorn-import-attributes: 1.9.5(acorn@8.14.1) + cjs-module-lexer: 1.2.3 + module-details-from-path: 1.0.3 + dev: false + /import-in-the-middle@1.7.1: resolution: {integrity: sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg==} dependencies: @@ -26450,8 +27118,8 @@ packages: /import-in-the-middle@1.7.4: resolution: {integrity: sha512-Lk+qzWmiQuRPPulGQeK5qq0v32k2bHnWrRPFgqyvhw7Kkov5L6MOLOIU3pcWeujc9W4q54Cp3Q2WV16eQkc7Bg==} dependencies: - acorn: 8.12.1 - acorn-import-attributes: 1.9.5(acorn@8.12.1) + acorn: 8.14.1 + acorn-import-attributes: 1.9.5(acorn@8.14.1) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 dev: true @@ -28462,8 +29130,8 @@ packages: /micromark-extension-mdxjs@1.0.0: resolution: {integrity: sha512-TZZRZgeHvtgm+IhtgC2+uDMR7h8eTKF0QUX9YsgoL9+bADBpBY6SiLvWqnBlLbCEevITmTqmEuY3FoxMKVs1rQ==} 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) micromark-extension-mdx-expression: 1.0.3 micromark-extension-mdx-jsx: 1.0.3 micromark-extension-mdx-md: 1.0.0 @@ -31616,7 +32284,6 @@ packages: /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false /pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} @@ -34906,7 +35573,7 @@ packages: hasBin: true dependencies: '@jridgewell/source-map': 0.3.3 - acorn: 8.12.1 + acorn: 8.14.1 commander: 2.20.3 source-map-support: 0.5.21 dev: false @@ -36825,8 +37492,8 @@ packages: '@webassemblyjs/ast': 1.11.5 '@webassemblyjs/wasm-edit': 1.11.5 '@webassemblyjs/wasm-parser': 1.11.5 - acorn: 8.12.1 - acorn-import-assertions: 1.9.0(acorn@8.12.1) + acorn: 8.14.1 + acorn-import-assertions: 1.9.0(acorn@8.14.1) browserslist: 4.24.4 chrome-trace-event: 1.0.3 enhanced-resolve: 5.18.1 diff --git a/references/effect/.gitignore b/references/effect/.gitignore new file mode 100644 index 00000000000..6524f048dcb --- /dev/null +++ b/references/effect/.gitignore @@ -0,0 +1 @@ +.trigger \ No newline at end of file diff --git a/references/effect/package.json b/references/effect/package.json new file mode 100644 index 00000000000..662944ed50e --- /dev/null +++ b/references/effect/package.json @@ -0,0 +1,17 @@ +{ + "name": "references-effect", + "private": true, + "type": "module", + "devDependencies": { + "trigger.dev": "workspace:*" + }, + "dependencies": { + "@trigger.dev/build": "workspace:*", + "@trigger.dev/sdk": "workspace:*", + "effect": "3.17.1" + }, + "scripts": { + "dev": "trigger dev", + "deploy": "trigger deploy" + } +} \ No newline at end of file diff --git a/references/effect/src/trigger/effect.ts b/references/effect/src/trigger/effect.ts new file mode 100644 index 00000000000..fafbe9e2e63 --- /dev/null +++ b/references/effect/src/trigger/effect.ts @@ -0,0 +1,37 @@ +import { logger, task } from "@trigger.dev/sdk"; + +import { Console, Effect, Schedule } from "effect"; + +const helloWorldIteration = Effect.gen(function* () { + yield* Console.log(`Hello World!`); + yield* Effect.sleep("1 second"); + yield* Console.log(`Done!`); + return "Iteration completed"; +}); + +// Repeat the effect 9 times (plus the initial run = 10 total) +const helloWorldLoop = helloWorldIteration.pipe(Effect.repeat(Schedule.recurs(9))); + +export const effectTask = task({ + id: "effect", + run: async () => { + const result = await Effect.runPromise(Effect.scoped(helloWorldLoop)); + + return result; + }, + onSuccess: async () => { + logger.info("Hello, world from the onSuccess hook"); + }, + onFailure: async () => { + logger.info("Hello, world from the onFailure hook"); + }, + onCancel: async () => { + logger.info("Hello, world from the onCancel hook"); + }, +}); + +// Prevent SIGTERM from killing the process immediately +process.on("SIGTERM", () => { + console.log("Received SIGTERM signal, but ignoring it..."); + // Process continues running +}); diff --git a/references/effect/trigger.config.ts b/references/effect/trigger.config.ts new file mode 100644 index 00000000000..9873daf22f0 --- /dev/null +++ b/references/effect/trigger.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@trigger.dev/sdk/v3"; + +export default defineConfig({ + project: process.env.TRIGGER_PROJECT_REF!, + experimental_processKeepAlive: { + enabled: true, + maxExecutionsPerProcess: 20, + }, + logLevel: "log", + maxDuration: 3600, + retries: { + enabledInDev: true, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + machine: "small-2x", +}); diff --git a/references/effect/tsconfig.json b/references/effect/tsconfig.json new file mode 100644 index 00000000000..9a5ee0b9d68 --- /dev/null +++ b/references/effect/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "customConditions": ["@triggerdotdev/source"], + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable"], + "noEmit": true + }, + "include": ["./src/**/*.ts", "trigger.config.ts"] +} From 038ee75c2803f1207f50b22ab3cccede319a8af3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 24 Jul 2025 15:26:56 +0100 Subject: [PATCH 013/641] Queue "View runs" buttons turn off root only (#2310) --- .../route.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 13817f96675..d42c10ec512 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -280,6 +280,7 @@ export default function Page() { to={v3RunsPath(organization, project, env, { statuses: ["PENDING"], period: "30d", + rootOnly: false, })} > View runs @@ -311,6 +312,7 @@ export default function Page() { to={v3RunsPath(organization, project, env, { statuses: ["DEQUEUED", "EXECUTING"], period: "30d", + rootOnly: false, })} > View runs @@ -527,6 +529,7 @@ export default function Page() { to={v3RunsPath(organization, project, env, { queues: [queueFilterableName], period: "30d", + rootOnly: false, })} fullWidth textAlignLeft @@ -541,6 +544,7 @@ export default function Page() { queues: [queueFilterableName], statuses: ["PENDING"], period: "30d", + rootOnly: false, })} fullWidth textAlignLeft @@ -555,6 +559,7 @@ export default function Page() { queues: [queueFilterableName], statuses: ["DEQUEUED", "EXECUTING"], period: "30d", + rootOnly: false, })} fullWidth textAlignLeft From fb59bfd8f8fc1f052c31b5c23bfee9a30f6e4774 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 24 Jul 2025 15:37:06 +0100 Subject: [PATCH 014/641] sentry: remove node native integration because it breaks (#2311) --- apps/webapp/package.json | 1 - apps/webapp/sentry.server.ts | 3 --- pnpm-lock.yaml | 23 ----------------------- 3 files changed, 27 deletions(-) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 86ac8347499..8c9f4996269 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -101,7 +101,6 @@ "@remix-run/serve": "2.1.0", "@remix-run/server-runtime": "2.1.0", "@remix-run/v1-meta": "^0.1.3", - "@sentry/node-native": "^9.40.0", "@sentry/remix": "^9.40.0", "@slack/web-api": "7.9.1", "@socket.io/redis-adapter": "^8.3.0", diff --git a/apps/webapp/sentry.server.ts b/apps/webapp/sentry.server.ts index 38f8ab895a3..d6f76f47a7c 100644 --- a/apps/webapp/sentry.server.ts +++ b/apps/webapp/sentry.server.ts @@ -1,5 +1,4 @@ import * as Sentry from "@sentry/remix"; -import { eventLoopBlockIntegration } from "@sentry/node-native"; if (process.env.SENTRY_DSN) { console.log("🔭 Initializing Sentry"); @@ -21,7 +20,5 @@ if (process.env.SENTRY_DSN) { serverName: process.env.SERVICE_NAME, environment: process.env.APP_ENV, - - integrations: [eventLoopBlockIntegration({ threshold: 1000 })], }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84f2b94eeac..1e6911778b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,9 +401,6 @@ importers: '@remix-run/v1-meta': specifier: ^0.1.3 version: 0.1.3(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0) - '@sentry/node-native': - specifier: ^9.40.0 - version: 9.40.0 '@sentry/remix': specifier: ^9.40.0 version: 9.40.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0)(react@18.2.0) @@ -17382,15 +17379,6 @@ packages: '@sentry/core': 9.40.0 dev: false - /@sentry-internal/node-native-stacktrace@0.2.1: - resolution: {integrity: sha512-sIfIj0LFL8WKxifRB6xtD2u4a1imeTNywk5PrXXqZnYLXgR7mr1vb9oGNxh8YJNCwsmr1EtitCm5IjgXWJtQ2Q==} - engines: {node: '>=18'} - requiresBuild: true - dependencies: - detect-libc: 2.0.4 - node-abi: 3.75.0 - dev: false - /@sentry-internal/replay-canvas@9.40.0: resolution: {integrity: sha512-GLoJ4R4Uipd7Vb+0LzSJA2qCyN1J6YalQIoDuOJTfYyykHvKltds5D8a/5S3Q6d8PcL/nxTn93fynauGEZt2Ow==} engines: {node: '>=18'} @@ -17568,17 +17556,6 @@ packages: import-in-the-middle: 1.14.2 dev: false - /@sentry/node-native@9.40.0: - resolution: {integrity: sha512-uA5eNbSbzDBD5ptFZ2WFkqUBLVc2+OWHKmxu7guiQH3aiAAb6l/mlITdTtsUBFhqKssR/yPgEA99cDppOWWlpA==} - engines: {node: '>=18'} - dependencies: - '@sentry-internal/node-native-stacktrace': 0.2.1 - '@sentry/core': 9.40.0 - '@sentry/node': 9.40.0 - transitivePeerDependencies: - - supports-color - dev: false - /@sentry/node@9.40.0: resolution: {integrity: sha512-8bVWChXzGH4QmbVw+H/yiJ6zxqPDhnx11fEAP+vpL1UBm1cAV67CoB4eS7OqQdPC8gF/BQb2sqF0TvY/12NPpA==} engines: {node: '>=18'} From 71693eb9b323084ef4048de3b3acd769b5c96d98 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 24 Jul 2025 16:02:26 +0100 Subject: [PATCH 015/641] stop double reporting errors to sentry (#2312) --- apps/webapp/app/entry.server.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 1c17c9baf28..0e37555cb0f 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -173,7 +173,7 @@ function handleBrowserRequest( export const handleError = wrapHandleErrorWithSentry((error, { request }) => { if (request instanceof Request) { - logger.error("Error in handleError", { + logger.debug("Error in handleError", { error, request: { url: request.url, @@ -181,7 +181,7 @@ export const handleError = wrapHandleErrorWithSentry((error, { request }) => { }, }); } else { - logger.error("Error in handleError", { + logger.debug("Error in handleError", { error, }); } From 00cc07dad37e78b9656c307107e96c2dbfcbd429 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 24 Jul 2025 16:22:21 +0100 Subject: [PATCH 016/641] Ignore queryRoute() call aborted errors (#2313) --- apps/webapp/sentry.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/webapp/sentry.server.ts b/apps/webapp/sentry.server.ts index d6f76f47a7c..e752574732e 100644 --- a/apps/webapp/sentry.server.ts +++ b/apps/webapp/sentry.server.ts @@ -20,5 +20,7 @@ if (process.env.SENTRY_DSN) { serverName: process.env.SERVICE_NAME, environment: process.env.APP_ENV, + + ignoreErrors: ["queryRoute() call aborted"], }); } From c8165ccd440e4f3a746923e8bf41ba83fb423319 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:44:19 +0100 Subject: [PATCH 017/641] security: bump form-data to latest patch versions (CVE-2025-7783) (#2314) --- package.json | 5 +++- pnpm-lock.yaml | 66 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index d16cba29ace..877660e12fb 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,10 @@ "overrides": { "express@^4>body-parser": "1.20.3", "@remix-run/dev@2.1.0>tar-fs": "2.1.3", - "testcontainers@10.28.0>tar-fs": "3.0.9" + "testcontainers@10.28.0>tar-fs": "3.0.9", + "form-data@^2": "2.5.4", + "form-data@^3": "3.0.4", + "form-data@^4": "4.0.4" } } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e6911778b8..cecbf40d537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ overrides: express@^4>body-parser: 1.20.3 '@remix-run/dev@2.1.0>tar-fs': 2.1.3 testcontainers@10.28.0>tar-fs: 3.0.9 + form-data@^2: 2.5.4 + form-data@^3: 3.0.4 + form-data@^4: 4.0.4 patchedDependencies: '@changesets/assemble-release-plan@5.2.4': @@ -9081,7 +9084,7 @@ packages: '@types/stream-buffers': 3.0.7 '@types/tar': 6.1.4 '@types/ws': 8.5.12 - form-data: 4.0.0 + form-data: 4.0.4 isomorphic-ws: 5.0.0(ws@8.18.0) js-yaml: 4.1.0 jsonpath-plus: 10.3.0 @@ -17722,7 +17725,7 @@ packages: '@types/retry': 0.12.0 axios: 1.9.0 eventemitter3: 5.0.1 - form-data: 4.0.0 + form-data: 4.0.4 is-electron: 2.2.2 is-stream: 2.0.1 p-queue: 6.6.2 @@ -19931,20 +19934,20 @@ packages: resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} dependencies: '@types/node': 20.14.14 - form-data: 4.0.0 + form-data: 4.0.4 /@types/node-fetch@2.6.2: resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==} dependencies: '@types/node': 18.19.20 - form-data: 3.0.1 + form-data: 3.0.4 dev: true /@types/node-fetch@2.6.4: resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==} dependencies: '@types/node': 20.14.14 - form-data: 3.0.1 + form-data: 3.0.4 dev: false /@types/node-forge@1.3.10: @@ -20117,7 +20120,7 @@ packages: '@types/caseless': 0.12.5 '@types/node': 20.14.14 '@types/tough-cookie': 4.0.5 - form-data: 2.5.1 + form-data: 2.5.4 dev: false /@types/resolve@1.20.6: @@ -20221,7 +20224,7 @@ packages: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 '@types/node': 20.14.14 - form-data: 4.0.0 + form-data: 4.0.4 dev: true /@types/supertest@6.0.2: @@ -21985,7 +21988,7 @@ packages: resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} dependencies: follow-redirects: 1.15.9 - form-data: 4.0.0 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -24378,6 +24381,15 @@ packages: hasown: 2.0.2 dev: true + /es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + /es-shim-unscopables@1.0.0: resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} dependencies: @@ -26026,38 +26038,37 @@ packages: /form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - /form-data@2.3.3: - resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} - engines: {node: '>= 0.12'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - dev: false - - /form-data@2.5.1: - resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} + /form-data@2.5.4: + resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} engines: {node: '>= 0.12'} + deprecated: This version has an incorrect dependency; please use v2.5.5 dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + has-own: 1.0.1 mime-types: 2.1.35 + safe-buffer: 5.2.1 dev: false - /form-data@3.0.1: - resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} + /form-data@3.0.4: + resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==} engines: {node: '>= 6'} dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 - /form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + /form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 /format@0.2.2: @@ -26723,6 +26734,10 @@ packages: engines: {node: '>=12'} dev: false + /has-own@1.0.1: + resolution: {integrity: sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==} + dev: false + /has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} dependencies: @@ -26747,7 +26762,6 @@ packages: engines: {node: '>= 0.4'} dependencies: has-symbols: 1.1.0 - dev: true /has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} @@ -33502,7 +33516,7 @@ packages: combined-stream: 1.0.8 extend: 3.0.2 forever-agent: 0.6.1 - form-data: 2.3.3 + form-data: 2.5.4 har-validator: 5.1.5 http-signature: 1.2.0 is-typedarray: 1.0.0 @@ -35047,7 +35061,7 @@ packages: cookiejar: 2.1.4 debug: 4.4.0(supports-color@10.0.0) fast-safe-stringify: 2.1.1 - form-data: 4.0.0 + form-data: 4.0.4 formidable: 3.5.1 methods: 1.1.2 mime: 2.6.0 From 0d136a3e0c77c7e872d6af4265bae7a0314a2ef3 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 25 Jul 2025 12:00:05 +0100 Subject: [PATCH 018/641] fix runs.retrieve when the payload or output has unstringifiable JSON (#2315) --- .../app/presenters/v3/ApiRetrieveRunPresenter.server.ts | 8 ++++---- references/hello-world/src/trigger/example.ts | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 06f1a4c5383..5fcae91120f 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -7,8 +7,8 @@ import { conditionallyImportPacket, createJsonErrorObject, logger, - parsePacket, } from "@trigger.dev/core/v3"; +import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database"; import assertNever from "assert-never"; import { API_VERSIONS, CURRENT_API_VERSION, RunStatusUnspecifiedApiVersion } from "~/api/versions"; @@ -133,7 +133,7 @@ export class ApiRetrieveRunPresenter { }); } } else { - $payload = await parsePacket(payloadPacket); + $payload = await parsePacketAsJson(payloadPacket); } if (taskRun.status === "COMPLETED_SUCCESSFULLY") { @@ -162,7 +162,7 @@ export class ApiRetrieveRunPresenter { }); } } else { - $output = await parsePacket(outputPacket); + $output = await parsePacketAsJson(outputPacket); } } @@ -433,7 +433,7 @@ async function resolveSchedule(run: CommonRelatedRun) { } async function createCommonRunStructure(run: CommonRelatedRun, apiVersion: API_VERSIONS) { - const metadata = await parsePacket({ + const metadata = await parsePacketAsJson({ data: run.metadata ?? undefined, dataType: run.metadataType, }); diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 7f476dbcfd8..6d1622df28a 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -58,7 +58,7 @@ export const parentTask = task({ machine: "medium-1x", run: async (payload: any, { ctx }) => { logger.log("Hello, world from the parent", { payload }); - await childTask.triggerAndWait({ message: "Hello, world!" }); + await childTask.triggerAndWait({ message: "Hello, world!", aReallyBigInt: BigInt(10000) }); }, }); @@ -103,10 +103,11 @@ export const childTask = task({ message, failureChance = 0.3, duration = 3_000, - }: { message?: string; failureChance?: number; duration?: number }, + aReallyBigInt, + }: { message?: string; failureChance?: number; duration?: number; aReallyBigInt?: bigint }, { ctx } ) => { - logger.info("Hello, world from the child", { message, failureChance }); + logger.info("Hello, world from the child", { message, failureChance, aReallyBigInt }); if (Math.random() < failureChance) { throw new Error("Random error at start"); From a7ff6de3884747c7ab28d17c7c68636f6e44cb1d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 26 Jul 2025 11:06:56 +0100 Subject: [PATCH 019/641] fix: otel logs better DynamicFlushScheduler (#2318) * Improve dynamic flush scheduler for otel data The changes introduce a more flexible and adaptive dynamic flush scheduler to address production issues where the system wasn't flushing data fast enough, causing memory growth and crashes. This issue arises from the existing scheduler handling only a single flush at a time, limiting concurrency and failing to cope with the influx of logs. - Added configuration options for setting minimum and maximum concurrency levels, maximum batch size, and memory pressure threshold. These parameters ensure that flush operations adjust dynamically based on workload and pressure. - Implemented `pLimit` to facilitate concurrent flush operations, with adjustments made according to batch queue length and memory pressure. - Metrics reporting improvements were added to monitor the dynamic behavior of the flush scheduler, aiding in identifying performance issues and optimizing the operation accordingly. * Implement load shedding for TaskEvent records This change introduces load shedding mechanisms to manage TaskEvent records, particularly those of kind LOG, when the system experiences high volumes and is unable to flush to the database in a timely manner. The addition aims to prevent overwhelming the system and ensure critical tasks are prioritized. - Added configuration options for `loadSheddingThreshold` and `loadSheddingEnabled` in multiple modules to activate load shedding. - Introduced `isDroppableEvent` function to allow specific events to be dropped when load shedding is enabled. - Ensured metrics are updated to reflect dropped events and load shedding status, providing visibility into system performance during high load conditions. - Updated loggers to inform about load shedding state changes, ensuring timely awareness of load management activities. * Fix undefined 'queuePressure' variable in DynamicFlushScheduler The 'queuePressure' variable was being used without being defined in the DynamicFlushScheduler class, causing potential runtime errors. This commit adds the missing definition and ensures that the variable is correctly calculated based on the 'totalQueuedItems' and 'memoryPressureThreshold'. - Addressed code inconsistencies and improved formatting. - Defined 'queuePressure' in the 'adjustConcurrency' method to prevent potential undefined errors. - Enhanced readability by maintaining consistent spacing and format across the file, contributing to the stability and maintainability of the code. - Adjusted batch size logic based on the newly defined 'queuePressure' variable. * Refactor concurrency adjustment logic in scheduler The concurrency adjustment logic in the dynamic flush scheduler has been refactored to improve clarity and maintainability. This change moves the calculation of pressure metrics outside of the conditional blocks to ensure they are always determined prior to decision-making. - The queue pressure and time since last flush calculations were moved up in the code to be independent of the 'backOff' condition. - This refactor sets up the groundwork for more reliable concurrency scaling and better performance monitoring capabilities. The overall logic of adjusting concurrency based on system pressure metrics remains unchanged. This adjustment addresses ongoing issues with the scheduler that were not resolved by previous changes. * Some tweaks --- apps/webapp/app/env.server.ts | 6 + .../app/v3/dynamicFlushScheduler.server.ts | 343 +++++++++++++++++- apps/webapp/app/v3/eventRepository.server.ts | 87 +++++ references/hello-world/src/trigger/example.ts | 65 ++++ 4 files changed, 481 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index b72857e04f1..eba67df32d2 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -252,6 +252,12 @@ const EnvironmentSchema = z.object({ EVENTS_BATCH_SIZE: z.coerce.number().int().default(100), EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000), EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7), + EVENTS_MIN_CONCURRENCY: z.coerce.number().int().default(1), + EVENTS_MAX_CONCURRENCY: z.coerce.number().int().default(10), + EVENTS_MAX_BATCH_SIZE: z.coerce.number().int().default(500), + EVENTS_MEMORY_PRESSURE_THRESHOLD: z.coerce.number().int().default(5000), + EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000), + EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"), SHARED_QUEUE_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10), SHARED_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(100), SHARED_QUEUE_CONSUMER_NEXT_TICK_INTERVAL_MS: z.coerce.number().int().default(100), diff --git a/apps/webapp/app/v3/dynamicFlushScheduler.server.ts b/apps/webapp/app/v3/dynamicFlushScheduler.server.ts index f87c2a143be..88e6a102485 100644 --- a/apps/webapp/app/v3/dynamicFlushScheduler.server.ts +++ b/apps/webapp/app/v3/dynamicFlushScheduler.server.ts @@ -1,38 +1,140 @@ +import { Logger } from "@trigger.dev/core/logger"; import { nanoid } from "nanoid"; +import pLimit from "p-limit"; export type DynamicFlushSchedulerConfig = { batchSize: number; flushInterval: number; callback: (flushId: string, batch: T[]) => Promise; + // New configuration options + minConcurrency?: number; + maxConcurrency?: number; + maxBatchSize?: number; + memoryPressureThreshold?: number; // Number of items that triggers increased concurrency + loadSheddingThreshold?: number; // Number of items that triggers load shedding + loadSheddingEnabled?: boolean; + isDroppableEvent?: (item: T) => boolean; // Function to determine if an event can be dropped }; export class DynamicFlushScheduler { - private batchQueue: T[][]; // Adjust the type according to your data structure - private currentBatch: T[]; // Adjust the type according to your data structure + private batchQueue: T[][]; + private currentBatch: T[]; private readonly BATCH_SIZE: number; private readonly FLUSH_INTERVAL: number; private flushTimer: NodeJS.Timeout | null; private readonly callback: (flushId: string, batch: T[]) => Promise; + // New properties for dynamic scaling + private readonly minConcurrency: number; + private readonly maxConcurrency: number; + private readonly maxBatchSize: number; + private readonly memoryPressureThreshold: number; + private limiter: ReturnType; + private currentBatchSize: number; + private totalQueuedItems: number = 0; + private consecutiveFlushFailures: number = 0; + private lastFlushTime: number = Date.now(); + private metrics = { + flushedBatches: 0, + failedBatches: 0, + totalItemsFlushed: 0, + droppedEvents: 0, + droppedEventsByKind: new Map(), + }; + + // New properties for load shedding + private readonly loadSheddingThreshold: number; + private readonly loadSheddingEnabled: boolean; + private readonly isDroppableEvent?: (item: T) => boolean; + private isLoadShedding: boolean = false; + + private readonly logger: Logger = new Logger("EventRepo.DynamicFlushScheduler", "debug"); + constructor(config: DynamicFlushSchedulerConfig) { this.batchQueue = []; this.currentBatch = []; this.BATCH_SIZE = config.batchSize; + this.currentBatchSize = config.batchSize; this.FLUSH_INTERVAL = config.flushInterval; this.callback = config.callback; this.flushTimer = null; + + // Initialize dynamic scaling parameters + this.minConcurrency = config.minConcurrency ?? 1; + this.maxConcurrency = config.maxConcurrency ?? 10; + this.maxBatchSize = config.maxBatchSize ?? config.batchSize * 5; + this.memoryPressureThreshold = config.memoryPressureThreshold ?? config.batchSize * 20; + + // Initialize load shedding parameters + this.loadSheddingThreshold = config.loadSheddingThreshold ?? config.batchSize * 50; + this.loadSheddingEnabled = config.loadSheddingEnabled ?? true; + this.isDroppableEvent = config.isDroppableEvent; + + // Start with minimum concurrency + this.limiter = pLimit(this.minConcurrency); + this.startFlushTimer(); + this.startMetricsReporter(); } addToBatch(items: T[]): void { - this.currentBatch.push(...items); + let itemsToAdd = items; + + // Apply load shedding if enabled and we're over the threshold + if (this.loadSheddingEnabled && this.totalQueuedItems >= this.loadSheddingThreshold) { + const { kept, dropped } = this.applyLoadShedding(items); + itemsToAdd = kept; + + if (dropped.length > 0) { + this.metrics.droppedEvents += dropped.length; - if (this.currentBatch.length >= this.BATCH_SIZE) { - this.batchQueue.push(this.currentBatch); - this.currentBatch = []; - this.flushNextBatch(); - this.resetFlushTimer(); + // Track dropped events by kind if possible + dropped.forEach((item) => { + const kind = this.getEventKind(item); + if (kind) { + const currentCount = this.metrics.droppedEventsByKind.get(kind) || 0; + this.metrics.droppedEventsByKind.set(kind, currentCount + 1); + } + }); + + if (!this.isLoadShedding) { + this.isLoadShedding = true; + } + + this.logger.warn("Load shedding", { + totalQueuedItems: this.totalQueuedItems, + threshold: this.loadSheddingThreshold, + droppedCount: dropped.length, + }); + } + } else if (this.isLoadShedding && this.totalQueuedItems < this.loadSheddingThreshold * 0.8) { + this.isLoadShedding = false; + this.logger.info("Load shedding deactivated", { + totalQueuedItems: this.totalQueuedItems, + threshold: this.loadSheddingThreshold, + totalDropped: this.metrics.droppedEvents, + }); } + + this.currentBatch.push(...itemsToAdd); + this.totalQueuedItems += itemsToAdd.length; + + // Check if we need to create a batch + if (this.currentBatch.length >= this.currentBatchSize) { + this.createBatch(); + } + + // Adjust concurrency based on queue pressure + this.adjustConcurrency(); + } + + private createBatch(): void { + if (this.currentBatch.length === 0) return; + + this.batchQueue.push(this.currentBatch); + this.currentBatch = []; + this.flushBatches(); + this.resetFlushTimer(); } private startFlushTimer(): void { @@ -48,23 +150,224 @@ export class DynamicFlushScheduler { private checkAndFlush(): void { if (this.currentBatch.length > 0) { - this.batchQueue.push(this.currentBatch); - this.currentBatch = []; + this.createBatch(); } - this.flushNextBatch(); + this.flushBatches(); } - private async flushNextBatch(): Promise { - if (this.batchQueue.length === 0) return; + private async flushBatches(): Promise { + const batchesToFlush: T[][] = []; + + // Dequeue all available batches up to current concurrency limit + while (this.batchQueue.length > 0 && batchesToFlush.length < this.limiter.concurrency) { + const batch = this.batchQueue.shift(); + if (batch) { + batchesToFlush.push(batch); + } + } + + if (batchesToFlush.length === 0) return; + + // Schedule all batches for concurrent processing + const flushPromises = batchesToFlush.map((batch) => + this.limiter(async () => { + const flushId = nanoid(); + const itemCount = batch.length; + + try { + const startTime = Date.now(); + await this.callback(flushId, batch); + + const duration = Date.now() - startTime; + this.totalQueuedItems -= itemCount; + this.consecutiveFlushFailures = 0; + this.lastFlushTime = Date.now(); + this.metrics.flushedBatches++; + this.metrics.totalItemsFlushed += itemCount; - const batchToFlush = this.batchQueue.shift(); - try { - await this.callback(nanoid(), batchToFlush!); + this.logger.debug("Batch flushed successfully", { + flushId, + itemCount, + duration, + remainingQueueDepth: this.totalQueuedItems, + activeConcurrency: this.limiter.activeCount, + pendingConcurrency: this.limiter.pendingCount, + }); + } catch (error) { + this.consecutiveFlushFailures++; + this.metrics.failedBatches++; + + this.logger.error("Error flushing batch", { + flushId, + itemCount, + error, + consecutiveFailures: this.consecutiveFlushFailures, + }); + + // Re-queue the batch at the front if it fails + this.batchQueue.unshift(batch); + this.totalQueuedItems += itemCount; + + // Back off on failures + if (this.consecutiveFlushFailures > 3) { + this.adjustConcurrency(true); + } + } + }) + ); + + // Don't await here - let them run concurrently + Promise.allSettled(flushPromises).then(() => { + // After flush completes, check if we need to flush more if (this.batchQueue.length > 0) { - this.flushNextBatch(); + this.flushBatches(); + } + }); + } + + private lastConcurrencyAdjustment: number = Date.now(); + + private adjustConcurrency(backOff: boolean = false): void { + const currentConcurrency = this.limiter.concurrency; + let newConcurrency = currentConcurrency; + + // Calculate pressure metrics - moved outside the if/else block + const queuePressure = this.totalQueuedItems / this.memoryPressureThreshold; + const timeSinceLastFlush = Date.now() - this.lastFlushTime; + const timeSinceLastAdjustment = Date.now() - this.lastConcurrencyAdjustment; + + // Don't adjust too frequently (except for backoff) + if (!backOff && timeSinceLastAdjustment < 1000) { + return; + } + + if (backOff) { + // Reduce concurrency on failures + newConcurrency = Math.max(this.minConcurrency, Math.floor(currentConcurrency * 0.75)); + } else { + if (queuePressure > 0.8 || timeSinceLastFlush > this.FLUSH_INTERVAL * 2) { + // High pressure - increase concurrency + newConcurrency = Math.min(this.maxConcurrency, currentConcurrency + 2); + } else if (queuePressure < 0.2 && currentConcurrency > this.minConcurrency) { + // Low pressure - decrease concurrency + newConcurrency = Math.max(this.minConcurrency, currentConcurrency - 1); + } + } + + // Adjust batch size based on pressure + if (this.totalQueuedItems > this.memoryPressureThreshold) { + this.currentBatchSize = Math.min( + this.maxBatchSize, + Math.floor(this.BATCH_SIZE * (1 + queuePressure)) + ); + } else { + this.currentBatchSize = this.BATCH_SIZE; + } + + // Update concurrency if changed + if (newConcurrency !== currentConcurrency) { + this.limiter = pLimit(newConcurrency); + + this.logger.info("Adjusted flush concurrency", { + previousConcurrency: currentConcurrency, + newConcurrency, + queuePressure, + totalQueuedItems: this.totalQueuedItems, + currentBatchSize: this.currentBatchSize, + memoryPressureThreshold: this.memoryPressureThreshold, + }); + } + } + + private startMetricsReporter(): void { + // Report metrics every 30 seconds + setInterval(() => { + const droppedByKind: Record = {}; + this.metrics.droppedEventsByKind.forEach((count, kind) => { + droppedByKind[kind] = count; + }); + + this.logger.info("DynamicFlushScheduler metrics", { + totalQueuedItems: this.totalQueuedItems, + batchQueueLength: this.batchQueue.length, + currentBatchLength: this.currentBatch.length, + currentConcurrency: this.limiter.concurrency, + activeConcurrent: this.limiter.activeCount, + pendingConcurrent: this.limiter.pendingCount, + currentBatchSize: this.currentBatchSize, + isLoadShedding: this.isLoadShedding, + metrics: { + ...this.metrics, + droppedByKind, + }, + }); + }, 30000); + } + + private applyLoadShedding(items: T[]): { kept: T[]; dropped: T[] } { + if (!this.isDroppableEvent) { + // If no function provided to determine droppable events, keep all + return { kept: items, dropped: [] }; + } + + const kept: T[] = []; + const dropped: T[] = []; + + for (const item of items) { + if (this.isDroppableEvent(item)) { + dropped.push(item); + } else { + kept.push(item); } - } catch (error) { - console.error("Error inserting batch:", error); + } + + return { kept, dropped }; + } + + private getEventKind(item: T): string | undefined { + // Try to extract the kind from the event if it has one + if (item && typeof item === "object" && "kind" in item) { + return String(item.kind); + } + return undefined; + } + + // Method to get current status + getStatus() { + const droppedByKind: Record = {}; + this.metrics.droppedEventsByKind.forEach((count, kind) => { + droppedByKind[kind] = count; + }); + + return { + queuedItems: this.totalQueuedItems, + batchQueueLength: this.batchQueue.length, + currentBatchSize: this.currentBatch.length, + concurrency: this.limiter.concurrency, + activeFlushes: this.limiter.activeCount, + pendingFlushes: this.limiter.pendingCount, + isLoadShedding: this.isLoadShedding, + metrics: { + ...this.metrics, + droppedEventsByKind: droppedByKind, + }, + }; + } + + // Graceful shutdown + async shutdown(): Promise { + if (this.flushTimer) { + clearInterval(this.flushTimer); + } + + // Flush any remaining items + if (this.currentBatch.length > 0) { + this.createBatch(); + } + + // Wait for all pending flushes to complete + while (this.batchQueue.length > 0 || this.limiter.activeCount > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); } } -} +} \ No newline at end of file diff --git a/apps/webapp/app/v3/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository.server.ts index cfca7002097..1fb88e969ff 100644 --- a/apps/webapp/app/v3/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository.server.ts @@ -107,6 +107,12 @@ export type EventRepoConfig = { retentionInDays: number; partitioningEnabled: boolean; tracer?: Tracer; + minConcurrency?: number; + maxConcurrency?: number; + maxBatchSize?: number; + memoryPressureThreshold?: number; + loadSheddingThreshold?: number; + loadSheddingEnabled?: boolean; }; export type QueryOptions = Prisma.TaskEventWhereInput; @@ -199,6 +205,10 @@ export class EventRepository { return this._subscriberCount; } + get flushSchedulerStatus() { + return this._flushScheduler.getStatus(); + } + constructor( db: PrismaClient = prisma, readReplica: PrismaReplicaClient = $replica, @@ -208,6 +218,16 @@ export class EventRepository { batchSize: _config.batchSize, flushInterval: _config.batchInterval, callback: this.#flushBatch.bind(this), + minConcurrency: _config.minConcurrency, + maxConcurrency: _config.maxConcurrency, + maxBatchSize: _config.maxBatchSize, + memoryPressureThreshold: _config.memoryPressureThreshold, + loadSheddingThreshold: _config.loadSheddingThreshold, + loadSheddingEnabled: _config.loadSheddingEnabled, + isDroppableEvent: (event: CreatableEvent) => { + // Only drop LOG events during load shedding + return event.kind === TaskEventKind.LOG; + }, }); this._redisPublishClient = createRedisClient("trigger:eventRepoPublisher", this._config.redis); @@ -1324,6 +1344,12 @@ function initializeEventRepo() { batchInterval: env.EVENTS_BATCH_INTERVAL, retentionInDays: env.EVENTS_DEFAULT_LOG_RETENTION, partitioningEnabled: env.TASK_EVENT_PARTITIONING_ENABLED === "1", + minConcurrency: env.EVENTS_MIN_CONCURRENCY, + maxConcurrency: env.EVENTS_MAX_CONCURRENCY, + maxBatchSize: env.EVENTS_MAX_BATCH_SIZE, + memoryPressureThreshold: env.EVENTS_MEMORY_PRESSURE_THRESHOLD, + loadSheddingThreshold: env.EVENTS_LOAD_SHEDDING_THRESHOLD, + loadSheddingEnabled: env.EVENTS_LOAD_SHEDDING_ENABLED === "1", redis: { port: env.PUBSUB_REDIS_PORT, host: env.PUBSUB_REDIS_HOST, @@ -1343,6 +1369,67 @@ function initializeEventRepo() { registers: [metricsRegister], }); + // Add metrics for flush scheduler + new Gauge({ + name: "event_flush_scheduler_queued_items", + help: "Total number of items queued in the flush scheduler", + collect() { + const status = repo.flushSchedulerStatus; + this.set(status.queuedItems); + }, + registers: [metricsRegister], + }); + + new Gauge({ + name: "event_flush_scheduler_batch_queue_length", + help: "Number of batches waiting to be flushed", + collect() { + const status = repo.flushSchedulerStatus; + this.set(status.batchQueueLength); + }, + registers: [metricsRegister], + }); + + new Gauge({ + name: "event_flush_scheduler_concurrency", + help: "Current concurrency level of the flush scheduler", + collect() { + const status = repo.flushSchedulerStatus; + this.set(status.concurrency); + }, + registers: [metricsRegister], + }); + + new Gauge({ + name: "event_flush_scheduler_active_flushes", + help: "Number of active flush operations", + collect() { + const status = repo.flushSchedulerStatus; + this.set(status.activeFlushes); + }, + registers: [metricsRegister], + }); + + new Gauge({ + name: "event_flush_scheduler_dropped_events", + help: "Total number of events dropped due to load shedding", + collect() { + const status = repo.flushSchedulerStatus; + this.set(status.metrics.droppedEvents); + }, + registers: [metricsRegister], + }); + + new Gauge({ + name: "event_flush_scheduler_is_load_shedding", + help: "Whether load shedding is currently active (1 = active, 0 = inactive)", + collect() { + const status = repo.flushSchedulerStatus; + this.set(status.isLoadShedding ? 1 : 0); + }, + registers: [metricsRegister], + }); + return repo; } diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 6d1622df28a..8759953d28e 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -373,3 +373,68 @@ export const largeAttributesTask = task({ }); }, }); + +export const lotsOfLogsParentTask = task({ + id: "lots-of-logs-parent", + run: async (payload: { count: number }, { ctx }) => { + logger.info("Hello, world from the lots of logs parent task", { count: payload.count }); + await lotsOfLogsTask.batchTriggerAndWait( + Array.from({ length: 20 }, (_, i) => ({ + payload: { count: payload.count }, + })) + ); + }, +}); + +export const lotsOfLogsTask = task({ + id: "lots-of-logs", + run: async (payload: { count: number }, { ctx }) => { + logger.info("Hello, world from the lots of logs task", { count: payload.count }); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + + await setTimeout(1000); + + for (let i = 0; i < payload.count; i++) { + logger.info("Hello, world from the lots of logs task", { count: i }); + } + }, +}); From 3493b9464f720a3ca27ca301b05f5193a88cd551 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Mon, 28 Jul 2025 12:00:35 +0200 Subject: [PATCH 020/641] Cancel readonly ClickHouse queries on client close (#2319) --- internal-packages/clickhouse/src/client/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/internal-packages/clickhouse/src/client/client.ts b/internal-packages/clickhouse/src/client/client.ts index 99fff592673..f1461798ef8 100644 --- a/internal-packages/clickhouse/src/client/client.ts +++ b/internal-packages/clickhouse/src/client/client.ts @@ -62,6 +62,7 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { ...config.clickhouseSettings, output_format_json_quote_64bit_integers: 0, output_format_json_quote_64bit_floats: 0, + cancel_http_readonly_queries_on_client_close: 1, }, log: { level: convertLogLevelToClickhouseLogLevel(config.logLevel), From 2ea52da628ed77c311e1abbb23dbdad9efc94466 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:29:49 +0100 Subject: [PATCH 021/641] fix(runner): disable warm starts after SIGTERM (#2316) * security: bump form-data to latest patch versions (CVE-2025-7783) * disable warm starts on sigterm * add changeset --- .changeset/cyan-news-design.md | 5 +++++ .../src/entryPoints/managed/controller.ts | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .changeset/cyan-news-design.md diff --git a/.changeset/cyan-news-design.md b/.changeset/cyan-news-design.md new file mode 100644 index 00000000000..c7db9189f10 --- /dev/null +++ b/.changeset/cyan-news-design.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Allow any runs to finish after SIGTERM but disable warm starts diff --git a/packages/cli-v3/src/entryPoints/managed/controller.ts b/packages/cli-v3/src/entryPoints/managed/controller.ts index 2f9542092b6..72006652cf3 100644 --- a/packages/cli-v3/src/entryPoints/managed/controller.ts +++ b/packages/cli-v3/src/entryPoints/managed/controller.ts @@ -30,7 +30,9 @@ export class ManagedRunController { private readonly logger: RunLogger; private readonly taskRunProcessProvider: TaskRunProcessProvider; + private warmStartEnabled = true; private warmStartCount = 0; + private restoreCount = 0; private notificationCount = 0; @@ -103,7 +105,12 @@ export class ManagedRunController { runId: this.runFriendlyId, message: "Received SIGTERM, stopping worker", }); - await this.stop(); + + // Disable warm starts + this.warmStartEnabled = false; + + // ..now we wait for any active runs to finish + // SIGKILL will handle the rest, nothing to do here }); } @@ -276,6 +283,14 @@ export class ManagedRunController { * the process on any errors or when no runs are available after the configured duration. */ private async waitForNextRun() { + if (!this.warmStartEnabled) { + this.sendDebugLog({ + runId: this.runFriendlyId, + message: "waitForNextRun: warm starts disabled, shutting down", + }); + this.exitProcess(this.successExitCode); + } + this.sendDebugLog({ runId: this.runFriendlyId, message: "waitForNextRun()", @@ -548,7 +563,7 @@ export class ManagedRunController { return; } - async stop() { + async cancelRunsAndExitProcess() { this.sendDebugLog({ runId: this.runFriendlyId, message: "Shutting down", @@ -578,6 +593,9 @@ export class ManagedRunController { // Close the socket this.socket.close(); + + // Exit the process + this.exitProcess(this.successExitCode); } sendDebugLog(opts: SendDebugLogOptions) { From 3e1cc69db96f592e6e349f1a9e6ae9e9e8b1957e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:47:30 +0100 Subject: [PATCH 022/641] feat(ecr): make assume role optional (#2321) --- .../app/v3/services/finalizeDeploymentV2.server.ts | 10 ++++++---- .../app/v3/services/initializeDeployment.server.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts index e15c3d2c453..993f4b6e2ca 100644 --- a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts @@ -271,10 +271,12 @@ async function ensureLoggedIntoDockerRegistry( if (isEcrRegistry(registryHost)) { auth = await getEcrAuthToken({ registryHost, - assumeRole: { - roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, - externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, - }, + assumeRole: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN + ? { + roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, + externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + } + : undefined, }); } else if (!auth) { throw new Error("Authentication required for non-ECR registry"); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 8370e3fdd90..813b48af584 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -77,10 +77,12 @@ export class InitializeDeploymentService extends BaseService { nextVersion, environmentSlug: environment.slug, registryTags: env.DEPLOY_REGISTRY_ECR_TAGS, - assumeRole: { - roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, - externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, - }, + assumeRole: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN + ? { + roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, + externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + } + : undefined, }) ); From 5ea6605ec6f8f3c162abcf9ae3c329090286d22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Rigaudi=C3=A8re?= Date: Mon, 28 Jul 2025 19:38:13 +0200 Subject: [PATCH 023/641] feat(extensions): add lightpanda (#2192) * feat: add lightpanda structure * chore: add lightpanda doc links * fix: lightpanda extension instructions * feat: add Lightpanda guide and examples * feat: lightpanda - add 3rd example * feat: add lightpandaTask * fix: lightpanda 3rd example * fix: lightpanda 1st example * chore: add changeset * add v4 tag to guide * fix: merge lightpanda docker instructions * fix: add failsafes * add scrape warning * lint * successful login also switches to that profile * update docs and links as this is v4 only * extension tweaks * simplify extension * update examples * update docs * remove from extensions list as v4 only * remove from catalog * update changeset --------- Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- .changeset/empty-dolls-judge.md | 5 + .changeset/rare-mails-fail.md | 5 + docs/config/extensions/lightpanda.mdx | 60 +++++ docs/config/extensions/overview.mdx | 1 - docs/docs.json | 2 + docs/guides/examples/lightpanda.mdx | 227 ++++++++++++++++++ docs/guides/introduction.mdx | 1 + docs/images/intro-lightpanda.jpg | Bin 0 -> 11853 bytes docs/introduction.mdx | 27 ++- packages/build/package.json | 17 +- packages/build/src/extensions/lightpanda.ts | 38 +++ packages/cli-v3/src/commands/login.ts | 13 +- pnpm-lock.yaml | 156 +++++++++++- references/hello-world/package.json | 1 + .../hello-world/src/trigger/lightpanda.ts | 149 ++++++++++++ references/hello-world/trigger.config.ts | 2 + 16 files changed, 684 insertions(+), 20 deletions(-) create mode 100644 .changeset/empty-dolls-judge.md create mode 100644 .changeset/rare-mails-fail.md create mode 100644 docs/config/extensions/lightpanda.mdx create mode 100644 docs/guides/examples/lightpanda.mdx create mode 100644 docs/images/intro-lightpanda.jpg create mode 100644 packages/build/src/extensions/lightpanda.ts create mode 100644 references/hello-world/src/trigger/lightpanda.ts diff --git a/.changeset/empty-dolls-judge.md b/.changeset/empty-dolls-judge.md new file mode 100644 index 00000000000..477cc9ef473 --- /dev/null +++ b/.changeset/empty-dolls-judge.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Switch to profile after successful login diff --git a/.changeset/rare-mails-fail.md b/.changeset/rare-mails-fail.md new file mode 100644 index 00000000000..ef4e9861b03 --- /dev/null +++ b/.changeset/rare-mails-fail.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/build": patch +--- + +Add Lightpanda extension diff --git a/docs/config/extensions/lightpanda.mdx b/docs/config/extensions/lightpanda.mdx new file mode 100644 index 00000000000..0408d45ad54 --- /dev/null +++ b/docs/config/extensions/lightpanda.mdx @@ -0,0 +1,60 @@ +--- +title: "Lightpanda" +sidebarTitle: "lightpanda" +description: "Use the lightpanda build extension to add Lightpanda browser to your project" +tag: "v4" +--- + +import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; + + + +To use the Lightpanda browser in your project, add the extension to your `trigger.config.ts` file: + +```ts trigger.config.ts +import { defineConfig } from "@trigger.dev/sdk"; +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; + +export default defineConfig({ + project: "", + build: { + extensions: [lightpanda()], + }, +}); +``` + +## Options + +- `version`: The version of the browser to install. Default: `"latest"`. +- `disableTelemetry`: Whether to disable telemetry. Default: `false`. + +For example: + +```ts trigger.config.ts +import { defineConfig } from "@trigger.dev/sdk"; +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; + +export default defineConfig({ + project: "", + build: { + extensions: [ + lightpanda({ + version: "nightly", + disableTelemetry: true, + }), + ], + }, +}); +``` + +## Development + +When running in dev, you will first have to download the Lightpanda browser binary and make sure it's in your `PATH`. See [Lightpanda's installation guide](https://lightpanda.io/docs/getting-started/installation). + +## Next steps + + + + Learn how to use Lightpanda in your project. + + diff --git a/docs/config/extensions/overview.mdx b/docs/config/extensions/overview.mdx index abba56694ee..412a11062be 100644 --- a/docs/config/extensions/overview.mdx +++ b/docs/config/extensions/overview.mdx @@ -50,7 +50,6 @@ Trigger.dev provides a set of built-in extensions that you can use to customize | :-------------------------------------------------------------------- | :----------------------------------------------------------------------------- | | [prismaExtension](/config/extensions/prismaExtension) | Using prisma in your Trigger.dev tasks | | [pythonExtension](/config/extensions/pythonExtension) | Execute Python scripts in your project | -| [playwright](/config/extensions/playwright) | Use Playwright in your Trigger.dev tasks | | [puppeteer](/config/extensions/puppeteer) | Use Puppeteer in your Trigger.dev tasks | | [ffmpeg](/config/extensions/ffmpeg) | Use FFmpeg in your Trigger.dev tasks | | [aptGet](/config/extensions/aptGet) | Install system packages in your build image | diff --git a/docs/docs.json b/docs/docs.json index b88d903a93b..6f091138878 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -78,6 +78,7 @@ "config/extensions/pythonExtension", "config/extensions/playwright", "config/extensions/puppeteer", + "config/extensions/lightpanda", "config/extensions/ffmpeg", "config/extensions/aptGet", "config/extensions/additionalFiles", @@ -362,6 +363,7 @@ "guides/examples/fal-ai-realtime", "guides/examples/ffmpeg-video-processing", "guides/examples/firecrawl-url-crawl", + "guides/examples/lightpanda", "guides/examples/libreoffice-pdf-conversion", "guides/examples/open-ai-with-retrying", "guides/examples/pdf-to-image", diff --git a/docs/guides/examples/lightpanda.mdx b/docs/guides/examples/lightpanda.mdx new file mode 100644 index 00000000000..9eab5311762 --- /dev/null +++ b/docs/guides/examples/lightpanda.mdx @@ -0,0 +1,227 @@ +--- +title: "Lightpanda" +sidebarTitle: "Lightpanda" +description: "These examples demonstrate how to use Lightpanda with Trigger.dev." +tag: "v4" +--- + +import ScrapingWarning from "/snippets/web-scraping-warning.mdx"; +import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; + + + +## Overview + +Lightpanda is a purpose-built browser for AI and automation workflows. It is 10x faster, uses 10x less RAM than Chrome headless. + +Here are a few examples of how to use Lightpanda with Trigger.dev. + + + +## Limitations + +- Lightpanda does not support the `puppeteer` screenshot feature. + +## Using Lightpanda Cloud + +### Prerequisites + +- A [Lightpanda](https://lightpanda.io/) cloud token + +### Get links from a website +In this task we use Lightpanda browser to get links from a provided URL. You will have to pass the URL as a payload when triggering the task. + +Make sure to add `LIGHTPANDA_TOKEN` to your Trigger.dev dashboard on the Environment Variables page: +```bash +LIGHTPANDA_TOKEN="" +``` + +```ts trigger/lightpanda-cloud-puppeteer.ts +import { logger, task } from "@trigger.dev/sdk"; +import puppeteer from "puppeteer-core"; + +export const lightpandaCloudPuppeteer = task({ + id: "lightpanda-cloud-puppeteer", + machine: { + preset: "micro", + }, + run: async (payload: { url: string }, { ctx }) => { + logger.log("Lets get a page's links with Lightpanda!", { payload, ctx }); + + if (!payload.url) { + logger.warn("Please define the payload url"); + throw new Error("payload.url is undefined"); + } + + const token = process.env.LIGHTPANDA_TOKEN; + if (!token) { + logger.warn("Please define the env variable LIGHTPANDA_TOKEN"); + throw new Error("LIGHTPANDA_TOKEN is undefined"); + } + + // Connect to Lightpanda's cloud + const browser = await puppeteer.connect({ + browserWSEndpoint: `wss://cloud.lightpanda.io/ws?browser=lightpanda&token=${token}`, + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + + // Dump all the links from the page. + await page.goto(payload.url); + + const links = await page.evaluate(() => { + return Array.from(document.querySelectorAll("a")).map((row) => { + return row.getAttribute("href"); + }); + }); + + logger.info("Processing done, shutting down…"); + + await page.close(); + await context.close(); + await browser.disconnect(); + + logger.info("✅ Completed"); + + return { + links, + }; + }, +}); +``` + +### Proxies + +Proxies can be used with your browser via the proxy query string parameter. By default, the proxy used is "datacenter" which is a pool of shared datacenter IPs. +`datacenter` accepts an optional `country` query string parameter which is an [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code. + +```bash +# This example will use a German IP +wss://cloud.lightpanda.io/ws?proxy=datacenter&country=de&token=${token} +``` + +### Session + +A session is alive until you close it or the connection is closed. The max duration of a session is 15 minutes. + +## Using Lightpanda browser directly + +### Prerequisites + +- Setup the [Lightpanda build extension](/config/extensions/lightpanda) + +### Get the HTML of a webpage + +This task will dump the HTML of a provided URL using the Lightpanda browser binary. You will have to pass the URL as a payload when triggering the task. + +```ts trigger/lightpanda-fetch.ts +import { logger, task } from "@trigger.dev/sdk"; +import { execSync } from "node:child_process"; + +export const lightpandaFetch = task({ + id: "lightpanda-fetch", + machine: { + preset: "micro", + }, + run: async (payload: { url: string }, { ctx }) => { + logger.log("Lets get a page's content with Lightpanda!", { payload, ctx }); + + if (!payload.url) { + logger.warn("Please define the payload url"); + throw new Error("payload.url is undefined"); + } + + const buffer = execSync(`lightpanda fetch --dump ${payload.url}`); + + logger.info("✅ Completed"); + + return { + message: buffer.toString(), + }; + }, +}); +``` + +### Lightpanda CDP with Puppeteer + +This task initializes a Lightpanda CDP server and uses it with `puppeteer-core` to scrape a provided URL. + +```ts trigger/lightpanda-cdp.ts +import { logger, task } from "@trigger.dev/sdk"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import puppeteer from "puppeteer-core"; + +const spawnLightpanda = async (host: string, port: string) => + new Promise((resolve, reject) => { + const child = spawn("lightpanda", [ + "serve", + "--host", + host, + "--port", + port, + "--log_level", + "info", + ]); + + child.on("spawn", async () => { + logger.info("Running Lightpanda's CDP server…", { + pid: child.pid, + }); + + await new Promise((resolve) => setTimeout(resolve, 250)); + resolve(child); + }); + child.on("error", (e) => reject(e)); + }); + +export const lightpandaCDP = task({ + id: "lightpanda-cdp", + machine: { + preset: "micro", + }, + run: async (payload: { url: string }, { ctx }) => { + logger.log("Lets get a page's links with Lightpanda!", { payload, ctx }); + + if (!payload.url) { + logger.warn("Please define the payload url"); + throw new Error("payload.url is undefined"); + } + + const host = process.env.LIGHTPANDA_CDP_HOST ?? "127.0.0.1"; + const port = process.env.LIGHTPANDA_CDP_PORT ?? "9222"; + + // Launch Lightpanda's CDP server + const lpProcess = await spawnLightpanda(host, port); + + const browser = await puppeteer.connect({ + browserWSEndpoint: `ws://${host}:${port}`, + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + + // Dump all the links from the page. + await page.goto(payload.url); + + const links = await page.evaluate(() => { + return Array.from(document.querySelectorAll("a")).map((row) => { + return row.getAttribute("href"); + }); + }); + + logger.info("Processing done"); + logger.info("Shutting down…"); + + // Close Puppeteer instance + await browser.close(); + + // Stop Lightpanda's CDP Server + lpProcess.kill(); + + logger.info("✅ Completed"); + + return { + links, + }; + }, +}); +``` diff --git a/docs/guides/introduction.mdx b/docs/guides/introduction.mdx index 12fc3e0faaa..79afeee86a5 100644 --- a/docs/guides/introduction.mdx +++ b/docs/guides/introduction.mdx @@ -71,6 +71,7 @@ Task code you can copy and paste to use in your project. They can all be extende | [FFmpeg video processing](/guides/examples/ffmpeg-video-processing) | Use FFmpeg to process a video in various ways and save it to Cloudflare R2. | | [Firecrawl URL crawl](/guides/examples/firecrawl-url-crawl) | Learn how to use Firecrawl to crawl a URL and return LLM-ready markdown. | | [LibreOffice PDF conversion](/guides/examples/libreoffice-pdf-conversion) | Convert a document to PDF using LibreOffice. | +| [Lightpanda](/guides/examples/lightpanda) | Use Lightpanda browser (or cloud version) to get a webpage's content. | | [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. | | [Puppeteer](/guides/examples/puppeteer) | Use Puppeteer to generate a PDF or scrape a webpage. | diff --git a/docs/images/intro-lightpanda.jpg b/docs/images/intro-lightpanda.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8fc2102bb088f84c172e50cae7f71e2933c53806 GIT binary patch literal 11853 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 HV&(q + ## Explore by build extension -| Extension | What it does | Docs | -|:----------|:------------|:--------------| -| prismaExtension | Use Prisma with Trigger.dev | [Learn more](/config/extensions/prismaExtension) | -| pythonExtension | Execute Python scripts in Trigger.dev | [Learn more](/config/extensions/pythonExtension) | -| puppeteer | Use Puppeteer with Trigger.dev | [Learn more](/config/extensions/puppeteer) | -| ffmpeg | Use FFmpeg with Trigger.dev | [Learn more](/config/extensions/ffmpeg) | -| aptGet | Install system packages with aptGet | [Learn more](/config/extensions/aptGet) | -| additionalFiles | Copy additional files to the build directory | [Learn more](/config/extensions/additionalFiles) | -| additionalPackages | Include additional packages in the build | [Learn more](/config/extensions/additionalPackages) | -| syncEnvVars | Automatically sync environment variables to Trigger.dev | [Learn more](/config/extensions/syncEnvVars) | -| esbuildPlugin | Add existing or custom esbuild plugins to your build process | [Learn more](/config/extensions/esbuildPlugin) | -| emitDecoratorMetadata | Support for the emitDecoratorMetadata TypeScript compiler | [Learn more](/config/extensions/emitDecoratorMetadata) | -| audioWaveform | Support for Audio Waveform in your project | [Learn more](/config/extensions/audioWaveform) | +| Extension | What it does | Docs | +| :-------------------- | :----------------------------------------------------------- | :----------------------------------------------------- | +| prismaExtension | Use Prisma with Trigger.dev | [Learn more](/config/extensions/prismaExtension) | +| pythonExtension | Execute Python scripts in Trigger.dev | [Learn more](/config/extensions/pythonExtension) | +| puppeteer | Use Puppeteer with Trigger.dev | [Learn more](/config/extensions/puppeteer) | +| ffmpeg | Use FFmpeg with Trigger.dev | [Learn more](/config/extensions/ffmpeg) | +| aptGet | Install system packages with aptGet | [Learn more](/config/extensions/aptGet) | +| additionalFiles | Copy additional files to the build directory | [Learn more](/config/extensions/additionalFiles) | +| additionalPackages | Include additional packages in the build | [Learn more](/config/extensions/additionalPackages) | +| syncEnvVars | Automatically sync environment variables to Trigger.dev | [Learn more](/config/extensions/syncEnvVars) | +| esbuildPlugin | Add existing or custom esbuild plugins to your build process | [Learn more](/config/extensions/esbuildPlugin) | +| emitDecoratorMetadata | Support for the emitDecoratorMetadata TypeScript compiler | [Learn more](/config/extensions/emitDecoratorMetadata) | +| audioWaveform | Support for Audio Waveform in your project | [Learn more](/config/extensions/audioWaveform) | ## Getting help diff --git a/packages/build/package.json b/packages/build/package.json index 6751f8bae25..78f3558e11d 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -30,7 +30,8 @@ "./extensions/audioWaveform": "./src/extensions/audioWaveform.ts", "./extensions/typescript": "./src/extensions/typescript.ts", "./extensions/puppeteer": "./src/extensions/puppeteer.ts", - "./extensions/playwright": "./src/extensions/playwright.ts" + "./extensions/playwright": "./src/extensions/playwright.ts", + "./extensions/lightpanda": "./src/extensions/lightpanda.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -61,6 +62,9 @@ ], "extensions/playwright": [ "dist/commonjs/extensions/playwright.d.ts" + ], + "extensions/lightpanda": [ + "dist/commonjs/extensions/lightpanda.d.ts" ] } }, @@ -188,6 +192,17 @@ "types": "./dist/commonjs/extensions/playwright.d.ts", "default": "./dist/commonjs/extensions/playwright.js" } + }, + "./extensions/lightpanda": { + "import": { + "@triggerdotdev/source": "./src/extensions/lightpanda.ts", + "types": "./dist/esm/extensions/lightpanda.d.ts", + "default": "./dist/esm/extensions/lightpanda.js" + }, + "require": { + "types": "./dist/commonjs/extensions/lightpanda.d.ts", + "default": "./dist/commonjs/extensions/lightpanda.js" + } } }, "main": "./dist/commonjs/index.js", diff --git a/packages/build/src/extensions/lightpanda.ts b/packages/build/src/extensions/lightpanda.ts new file mode 100644 index 00000000000..16c62a08b4f --- /dev/null +++ b/packages/build/src/extensions/lightpanda.ts @@ -0,0 +1,38 @@ +import type { BuildExtension } from "@trigger.dev/core/v3/build"; + +type LightpandaOpts = { + version?: "nightly" | "latest"; + disableTelemetry?: boolean; +}; + +export const lightpanda = ({ + version = "latest", + disableTelemetry = false, +}: LightpandaOpts = {}): BuildExtension => ({ + name: "lightpanda", + onBuildComplete: async (context) => { + if (context.target === "dev") { + return; + } + + context.logger.debug(`Adding lightpanda`, { version, disableTelemetry }); + + const instructions = [ + `COPY --from=lightpanda/browser:${version} /usr/bin/lightpanda /usr/local/bin/lightpanda`, + `RUN /usr/local/bin/lightpanda version || (echo "lightpanda binary is not functional" && exit 1)`, + ] satisfies string[]; + + context.addLayer({ + id: "lightpanda", + image: { + instructions, + }, + deploy: { + env: { + ...(disableTelemetry ? { LIGHTPANDA_DISABLE_TELEMETRY: "true" } : {}), + }, + override: true, + }, + }); + }, +}); diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index 687b6e8f6dd..953a0c796f2 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -14,7 +14,11 @@ import { wrapCommandAction, } from "../cli/common.js"; import { chalkLink, prettyError } from "../utilities/cliOutput.js"; -import { readAuthConfigProfile, writeAuthConfigProfile } from "../utilities/configFiles.js"; +import { + readAuthConfigProfile, + writeAuthConfigProfile, + writeAuthConfigCurrentProfileName, +} from "../utilities/configFiles.js"; import { printInitialBanner } from "../utilities/initialBanner.js"; import { LoginResult } from "../utilities/session.js"; import { whoAmI } from "./whoami.js"; @@ -283,6 +287,11 @@ export async function login(options?: LoginOptions): Promise { throw new Error(whoAmIResult.error); } + const profileName = options?.profile ?? "default"; + + // Set this profile as the current default + writeAuthConfigCurrentProfileName(profileName); + if (opts.embedded) { log.step("Logged in successfully"); } else { @@ -293,7 +302,7 @@ export async function login(options?: LoginOptions): Promise { return { ok: true as const, - profile: options?.profile ?? "default", + profile: profileName, userId: whoAmIResult.data.userId, email: whoAmIResult.data.email, dashboardUrl: whoAmIResult.data.dashboardUrl, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cecbf40d537..37ec88c236f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2105,6 +2105,9 @@ importers: openai: specifier: ^4.97.0 version: 4.97.0(ws@8.12.0)(zod@3.23.8) + puppeteer-core: + specifier: ^24.15.0 + version: 24.15.0 replicate: specifier: ^1.0.1 version: 1.0.1 @@ -11401,6 +11404,23 @@ packages: /@protobufjs/utf8@1.1.0: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + /@puppeteer/browsers@2.10.6: + resolution: {integrity: sha512-pHUn6ZRt39bP3698HFQlu2ZHCkS/lPcpv7fVQcGBSzNNygw171UXAKrCUhy+TEMw4lEttOKDgNpb04hwUAJeiQ==} + engines: {node: '>=18'} + hasBin: true + dependencies: + debug: 4.4.1 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.2 + tar-fs: 3.1.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-buffer + - supports-color + dev: false + /@puppeteer/browsers@2.4.0: resolution: {integrity: sha512-x8J1csfIygOwf6D6qUAZ0ASk3z63zPb7wkNeHRerCMh82qWKUrOgkuP005AJC8lDL6/evtXETGEJVcwykKT4/g==} engines: {node: '>=18'} @@ -21250,6 +21270,11 @@ packages: transitivePeerDependencies: - supports-color + /agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + dev: false + /agentkeepalive@4.5.0: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} engines: {node: '>= 8.0.0'} @@ -22718,6 +22743,16 @@ packages: zod: 3.23.8 dev: false + /chromium-bidi@7.2.0(devtools-protocol@0.0.1464554): + resolution: {integrity: sha512-gREyhyBstermK+0RbcJLbFhcQctg92AGgDe/h/taMJEOLRdtSswBAO9KmvltFSQWgM2LrwWu5SIuEUbdm3JsyQ==} + peerDependencies: + devtools-protocol: '*' + dependencies: + devtools-protocol: 0.0.1464554 + mitt: 3.0.1 + zod: 3.25.76 + dev: false + /ci-info@3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -23650,6 +23685,18 @@ packages: ms: 2.1.3 supports-color: 10.0.0 + /debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: false + /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} engines: {node: '>=0.10.0'} @@ -23866,6 +23913,10 @@ packages: resolution: {integrity: sha512-75fMas7PkYNDTmDyb6PRJCH7ILmHLp+BhrZGeMsa4bCh40DTxgCz2NRy5UDzII4C5KuD0oBMZ9vXKhEl6UD/3w==} dev: false + /devtools-protocol@0.0.1464554: + resolution: {integrity: sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==} + dev: false + /dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} dependencies: @@ -25658,7 +25709,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -26958,8 +27009,8 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} dependencies: - agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + agent-base: 7.1.4 + debug: 4.4.1 transitivePeerDependencies: - supports-color dev: false @@ -27002,6 +27053,16 @@ packages: - supports-color dev: false + /https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + dev: false + /human-id@1.0.2: resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} dev: false @@ -31065,6 +31126,22 @@ packages: - supports-color dev: false + /pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.1 + get-uri: 6.0.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + dev: false + /pac-resolver@7.0.1: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} @@ -32273,6 +32350,22 @@ packages: - supports-color dev: false + /proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + dev: false + /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -32335,6 +32428,23 @@ packages: - utf-8-validate dev: false + /puppeteer-core@24.15.0: + resolution: {integrity: sha512-2iy0iBeWbNyhgiCGd/wvGrDSo73emNFjSxYOcyAqYiagkYt5q4cPfVXaVDKBsukgc2fIIfLAalBZlaxldxdDYg==} + engines: {node: '>=18'} + dependencies: + '@puppeteer/browsers': 2.10.6 + chromium-bidi: 7.2.0(devtools-protocol@0.0.1464554) + debug: 4.4.1 + devtools-protocol: 0.0.1464554 + typed-query-selector: 2.12.0 + ws: 8.18.3 + transitivePeerDependencies: + - bare-buffer + - bufferutil + - supports-color + - utf-8-validate + dev: false + /puppeteer@23.4.0(typescript@5.5.4): resolution: {integrity: sha512-FxgFFJI7NAsX8uebiEDSjS86vufz9TaqERQHShQT0lCbSRI3jUPEcz/0HdwLiYvfYNsc1zGjqY3NsGZya4PvUA==} engines: {node: '>=18'} @@ -34474,6 +34584,17 @@ packages: - supports-color dev: false + /socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + socks: 2.8.3 + transitivePeerDependencies: + - supports-color + dev: false + /socks@2.8.3: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} @@ -35419,6 +35540,18 @@ packages: transitivePeerDependencies: - bare-buffer + /tar-fs@3.1.0: + resolution: {integrity: sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==} + dependencies: + pump: 3.0.2 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.1.5 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-buffer + dev: false + /tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -37796,6 +37929,19 @@ packages: dependencies: bufferutil: 4.0.9 + /ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xdg-app-paths@8.3.0: resolution: {integrity: sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ==} engines: {node: '>= 4.0'} @@ -38030,6 +38176,10 @@ packages: /zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + /zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + dev: false + /zustand@4.5.5(@types/react@18.2.69)(react@18.2.0): resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} engines: {node: '>=12.7.0'} diff --git a/references/hello-world/package.json b/references/hello-world/package.json index b6a8d799f4f..89dbeea9110 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -9,6 +9,7 @@ "@trigger.dev/build": "workspace:*", "@trigger.dev/sdk": "workspace:*", "openai": "^4.97.0", + "puppeteer-core": "^24.15.0", "replicate": "^1.0.1", "zod": "3.23.8" }, diff --git a/references/hello-world/src/trigger/lightpanda.ts b/references/hello-world/src/trigger/lightpanda.ts new file mode 100644 index 00000000000..830f6391a2e --- /dev/null +++ b/references/hello-world/src/trigger/lightpanda.ts @@ -0,0 +1,149 @@ +import { logger, task } from "@trigger.dev/sdk"; +import { execSync, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import puppeteer from "puppeteer-core"; + +const spawnLightpanda = async (host: string, port: string) => + new Promise((resolve, reject) => { + const child = spawn("lightpanda", [ + "serve", + "--host", + host, + "--port", + port, + "--log_level", + "info", + ]); + + child.on("spawn", async () => { + logger.info("Running Lightpanda's CDP server…", { + pid: child.pid, + }); + + await new Promise((resolve) => setTimeout(resolve, 250)); + resolve(child); + }); + child.on("error", (e) => reject(e)); + }); + +export const lightpandaCdp = task({ + id: "lightpanda-cdp", + machine: { + preset: "micro", + }, + run: async (payload: { url: string }, { ctx }) => { + logger.log("Lets get a page's links with Lightpanda!", { payload, ctx }); + + if (!payload.url) { + logger.warn("Please define the payload url"); + throw new Error("payload.url is undefined"); + } + + const host = process.env.LIGHTPANDA_CDP_HOST ?? "127.0.0.1"; + const port = process.env.LIGHTPANDA_CDP_PORT ?? "9222"; + + // Launch Lightpanda's CDP server + const lpProcess = await spawnLightpanda(host, port); + + const browser = await puppeteer.connect({ + browserWSEndpoint: `ws://${host}:${port}`, + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + + // Dump all the links from the page. + await page.goto(payload.url); + + const links = await page.evaluate(() => { + return Array.from(document.querySelectorAll("a")).map((row) => { + return row.getAttribute("href"); + }); + }); + + logger.info("Processing done"); + logger.info("Shutting down…"); + + // Close Puppeteer instance + await browser.close(); + + // Stop Lightpanda's CDP Server + lpProcess.kill(); + + logger.info("✅ Completed"); + + return { + links, + }; + }, +}); + +export const lightpandaFetch = task({ + id: "lightpanda-fetch", + machine: { + preset: "micro", + }, + run: async (payload: { url: string }, { ctx }) => { + logger.log("Lets get a page's content with Lightpanda!", { payload, ctx }); + + if (!payload.url) { + logger.warn("Please define the payload url"); + throw new Error("payload.url is undefined"); + } + + const buffer = execSync(`lightpanda fetch --dump ${payload.url}`); + + logger.info("✅ Completed"); + + return { + message: buffer.toString(), + }; + }, +}); + +export const lightpandaCloudPuppeteer = task({ + id: "lightpanda-cloud-puppeteer", + machine: { + preset: "micro", + }, + run: async (payload: { url: string }, { ctx }) => { + logger.log("Lets get a page's links with Lightpanda!", { payload, ctx }); + + if (!payload.url) { + logger.warn("Please define the payload url"); + throw new Error("payload.url is undefined"); + } + + const token = process.env.LIGHTPANDA_TOKEN; + if (!token) { + logger.warn("Please define the env variable LIGHTPANDA_TOKEN"); + throw new Error("LIGHTPANDA_TOKEN is undefined"); + } + + // Connect to Lightpanda's cloud + const browser = await puppeteer.connect({ + browserWSEndpoint: `wss://cloud.lightpanda.io/ws?browser=lightpanda&token=${token}`, + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + + // Dump all the links from the page. + await page.goto(payload.url); + + const links = await page.evaluate(() => { + return Array.from(document.querySelectorAll("a")).map((row) => { + return row.getAttribute("href"); + }); + }); + + logger.info("Processing done, shutting down…"); + + await page.close(); + await context.close(); + await browser.disconnect(); + + logger.info("✅ Completed"); + + return { + links, + }; + }, +}); diff --git a/references/hello-world/trigger.config.ts b/references/hello-world/trigger.config.ts index 33935a4a132..2b4a68912f9 100644 --- a/references/hello-world/trigger.config.ts +++ b/references/hello-world/trigger.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "@trigger.dev/sdk/v3"; import { syncEnvVars } from "@trigger.dev/build/extensions/core"; +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; export default defineConfig({ compatibilityFlags: ["run_engine_v2"], @@ -23,6 +24,7 @@ export default defineConfig({ machine: "small-2x", build: { extensions: [ + lightpanda(), syncEnvVars(async (ctx) => { console.log("syncEnvVars", { environment: ctx.environment, branch: ctx.branch }); return [ From 14dcc76f93cf2ce32f126a209b100966e613c759 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 30 Jul 2025 13:47:12 +0100 Subject: [PATCH 024/641] feat: run.ctx tidying and additions (#2322) * Cleanup context and execution creation, cache stuff, add parent and root task run ids * more efficient by using friendly IDs instead of doing joins * metadata.root/parent now reference current run when run has no root/parent * Adding changeset * try to make test less flaky * Clean imports * Another attempt to fix the flaky test * Fix usage by still passing durationMs and costInCents to the execution, just not the run.ctx --- .changeset/giant-plums-smash.md | 18 + .../app/presenters/v3/SpanPresenter.server.ts | 369 +++++---- .../app/routes/resources.runs.$runParam.ts | 36 + apps/webapp/app/v3/failedTaskRun.server.ts | 3 +- .../app/v3/marqs/devQueueConsumer.server.ts | 4 +- .../v3/marqs/sharedQueueConsumer.server.ts | 18 +- .../app/v3/services/completeAttempt.server.ts | 10 +- .../services/createTaskRunAttempt.server.ts | 6 +- internal-packages/cache/README.md | 3 + internal-packages/cache/package.json | 18 + internal-packages/cache/src/index.ts | 8 + internal-packages/cache/src/stores/redis.ts | 105 +++ internal-packages/cache/tsconfig.json | 23 + internal-packages/run-engine/package.json | 2 +- .../run-engine/src/engine/index.ts | 6 + .../src/engine/systems/runAttemptSystem.ts | 730 ++++++++++++++---- .../src/engine/tests/heartbeats.test.ts | 13 +- .../run-engine/src/engine/types.ts | 3 + .../run-queue/fairQueueSelectionStrategy.ts | 9 +- .../cli-v3/src/entryPoints/dev-run-worker.ts | 7 + .../src/entryPoints/managed-run-worker.ts | 10 +- packages/core/src/v3/isomorphic/friendlyId.ts | 1 + packages/core/src/v3/runMetadata/manager.ts | 90 ++- packages/core/src/v3/schemas/common.ts | 176 +++-- packages/core/src/v3/schemas/messages.ts | 21 +- packages/core/src/v3/schemas/schemas.ts | 18 +- packages/core/src/v3/taskContext/index.ts | 2 - packages/core/src/v3/usage/api.ts | 6 +- packages/core/src/v3/usage/devUsageManager.ts | 18 +- .../core/src/v3/usage/noopUsageManager.ts | 9 +- .../core/src/v3/usage/prodUsageManager.ts | 18 +- packages/core/src/v3/usage/types.ts | 6 + packages/trigger-sdk/src/v3/shared.ts | 1 - packages/trigger-sdk/src/v3/usage.ts | 13 +- pnpm-lock.yaml | 24 +- references/hello-world/src/trigger/example.ts | 4 +- .../hello-world/src/trigger/metadata.ts | 2 + references/hello-world/src/trigger/usage.ts | 35 + 38 files changed, 1445 insertions(+), 400 deletions(-) create mode 100644 .changeset/giant-plums-smash.md create mode 100644 internal-packages/cache/README.md create mode 100644 internal-packages/cache/package.json create mode 100644 internal-packages/cache/src/index.ts create mode 100644 internal-packages/cache/src/stores/redis.ts create mode 100644 internal-packages/cache/tsconfig.json create mode 100644 references/hello-world/src/trigger/usage.ts diff --git a/.changeset/giant-plums-smash.md b/.changeset/giant-plums-smash.md new file mode 100644 index 00000000000..7b23fb870ce --- /dev/null +++ b/.changeset/giant-plums-smash.md @@ -0,0 +1,18 @@ +--- +"@trigger.dev/sdk": patch +--- + +Added and cleaned up the run ctx param: + +- New optional properties `ctx.run.parentTaskRunId` and `ctx.run.rootTaskRunId` reference the current run's root/parent ID. +- Removed deprecated properties from `ctx` +- Added a new `ctx.deployment` object that contains information about the deployment associated with the run. + +We also update `metadata.root` and `metadata.parent` to work even when the run is a "root" run (meaning it doesn't have a parent or a root associated run). This now works: + +```ts +metadata.root.set("foo", "bar"); +metadata.parent.set("baz", 1); +metadata.current().foo // "bar" +metadata.current().baz // 1 +``` diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 7d3c219beea..0d9065e28a5 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -1,22 +1,29 @@ import { - type MachinePresetName, + MachinePreset, prettyPrintPacket, SemanticInternalAttributes, + TaskRunContext, TaskRunError, + V3TaskRunContext, } from "@trigger.dev/core/v3"; -import { getMaxDuration } from "@trigger.dev/core/v3/isomorphic"; +import { AttemptId, getMaxDuration } from "@trigger.dev/core/v3/isomorphic"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { logger } from "~/services/logger.server"; import { eventRepository, rehydrateAttribute } from "~/v3/eventRepository.server"; -import { machinePresetFromName, machinePresetFromRun } from "~/v3/machinePresets.server"; +import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server"; import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; import { WaitpointPresenter } from "./WaitpointPresenter.server"; +import { engine } from "~/v3/runEngine.server"; type Result = Awaited>; export type Span = NonNullable["span"]>; export type SpanRun = NonNullable["run"]>; +type FindRunResult = NonNullable< + Awaited["findRun"]>> +>; +type GetSpanResult = NonNullable>>; export class SpanPresenter extends BasePresenter { public async call({ @@ -60,7 +67,7 @@ export class SpanPresenter extends BasePresenter { const eventStore = getTaskEventStoreTableForRun(parentRun); - const run = await this.#getRun({ + const run = await this.getRun({ eventStore, traceId, spanId, @@ -95,7 +102,7 @@ export class SpanPresenter extends BasePresenter { }; } - async #getRun({ + async getRun({ eventStore, traceId, spanId, @@ -120,114 +127,7 @@ export class SpanPresenter extends BasePresenter { return; } - const run = await this._replica.taskRun.findFirst({ - select: { - id: true, - spanId: true, - traceId: true, - //metadata - number: true, - taskIdentifier: true, - friendlyId: true, - isTest: true, - maxDurationInSeconds: true, - taskEventStore: true, - tags: { - select: { - name: true, - }, - }, - machinePreset: true, - lockedToVersion: { - select: { - version: true, - sdkVersion: true, - runtime: true, - runtimeVersion: true, - }, - }, - engine: true, - workerQueue: true, - error: true, - output: true, - outputType: true, - //status + duration - status: true, - statusReason: true, - startedAt: true, - executedAt: true, - createdAt: true, - updatedAt: true, - queuedAt: true, - completedAt: true, - logsDeletedAt: true, - //idempotency - idempotencyKey: true, - idempotencyKeyExpiresAt: true, - //delayed - delayUntil: true, - //ttl - ttl: true, - expiredAt: true, - //queue - queue: true, - concurrencyKey: true, - //schedule - scheduleId: true, - //usage - baseCostInCents: true, - costInCents: true, - usageDurationMs: true, - //env - runtimeEnvironment: { - select: { id: true, slug: true, type: true }, - }, - payload: true, - payloadType: true, - metadata: true, - metadataType: true, - maxAttempts: true, - project: { - include: { - organization: true, - }, - }, - lockedBy: { - select: { - filePath: true, - }, - }, - //relationships - rootTaskRun: { - select: { - taskIdentifier: true, - friendlyId: true, - spanId: true, - createdAt: true, - }, - }, - parentTaskRun: { - select: { - taskIdentifier: true, - friendlyId: true, - spanId: true, - }, - }, - batch: { - select: { - friendlyId: true, - }, - }, - replayedFromTaskRunFriendlyId: true, - }, - where: span.originalRun - ? { - friendlyId: span.originalRun, - } - : { - spanId, - }, - }); + const run = await this.findRun({ span, spanId }); if (!run) { return; @@ -271,46 +171,7 @@ export class SpanPresenter extends BasePresenter { const machine = run.machinePreset ? machinePresetFromRun(run) : undefined; - const context = { - task: { - id: run.taskIdentifier, - filePath: run.lockedBy?.filePath, - }, - run: { - id: run.friendlyId, - createdAt: run.createdAt, - tags: run.tags.map((tag) => tag.name), - isTest: run.isTest, - idempotencyKey: run.idempotencyKey ?? undefined, - startedAt: run.startedAt ?? run.createdAt, - durationMs: run.usageDurationMs, - costInCents: run.costInCents, - baseCostInCents: run.baseCostInCents, - maxAttempts: run.maxAttempts ?? undefined, - version: run.lockedToVersion?.version, - maxDuration: run.maxDurationInSeconds ?? undefined, - }, - queue: { - name: run.queue, - }, - environment: { - id: run.runtimeEnvironment.id, - slug: run.runtimeEnvironment.slug, - type: run.runtimeEnvironment.type, - }, - organization: { - id: run.project.organization.id, - slug: run.project.organization.slug, - name: run.project.organization.title, - }, - project: { - id: run.project.id, - ref: run.project.externalRef, - slug: run.project.slug, - name: run.project.name, - }, - machine, - }; + const context = await this.#getTaskRunContext({ run, machine: machine ?? undefined }); return { id: run.id, @@ -342,7 +203,7 @@ export class SpanPresenter extends BasePresenter { isCustomQueue: !run.queue.startsWith("task/"), concurrencyKey: run.concurrencyKey, }, - tags: run.tags.map((tag) => tag.name), + tags: run.runTags, baseCostInCents: run.baseCostInCents, costInCents: run.costInCents, totalCostInCents: run.costInCents + run.baseCostInCents, @@ -405,6 +266,127 @@ export class SpanPresenter extends BasePresenter { }; } + async findRun({ span, spanId }: { span: GetSpanResult; spanId: string }) { + const run = await this._replica.taskRun.findFirst({ + select: { + id: true, + spanId: true, + traceId: true, + //metadata + number: true, + taskIdentifier: true, + friendlyId: true, + isTest: true, + maxDurationInSeconds: true, + taskEventStore: true, + runTags: true, + machinePreset: true, + lockedToVersion: { + select: { + version: true, + sdkVersion: true, + runtime: true, + runtimeVersion: true, + }, + }, + engine: true, + workerQueue: true, + error: true, + output: true, + outputType: true, + //status + duration + status: true, + statusReason: true, + startedAt: true, + executedAt: true, + createdAt: true, + updatedAt: true, + queuedAt: true, + completedAt: true, + logsDeletedAt: true, + //idempotency + idempotencyKey: true, + idempotencyKeyExpiresAt: true, + //delayed + delayUntil: true, + //ttl + ttl: true, + expiredAt: true, + //queue + queue: true, + concurrencyKey: true, + //schedule + scheduleId: true, + //usage + baseCostInCents: true, + costInCents: true, + usageDurationMs: true, + //env + runtimeEnvironment: { + select: { id: true, slug: true, type: true }, + }, + payload: true, + payloadType: true, + metadata: true, + metadataType: true, + maxAttempts: true, + project: { + include: { + organization: true, + }, + }, + lockedBy: { + select: { + filePath: true, + }, + }, + //relationships + rootTaskRun: { + select: { + taskIdentifier: true, + friendlyId: true, + spanId: true, + createdAt: true, + }, + }, + parentTaskRun: { + select: { + taskIdentifier: true, + friendlyId: true, + spanId: true, + }, + }, + batch: { + select: { + friendlyId: true, + }, + }, + replayedFromTaskRunFriendlyId: true, + attempts: { + take: 1, + orderBy: { + createdAt: "desc", + }, + select: { + number: true, + status: true, + createdAt: true, + friendlyId: true, + }, + }, + }, + where: span.originalRun + ? { + friendlyId: span.originalRun, + } + : { + spanId, + }, + }); + + return run; + } + async #getSpan({ eventStore, traceId, @@ -513,4 +495,83 @@ export class SpanPresenter extends BasePresenter { return { ...data, entity: null }; } } + + async #getTaskRunContext({ run, machine }: { run: FindRunResult; machine?: MachinePreset }) { + if (run.engine === "V1") { + return this.#getV3TaskRunContext({ run, machine }); + } else { + return this.#getV4TaskRunContext({ run }); + } + } + + async #getV3TaskRunContext({ + run, + machine, + }: { + run: FindRunResult; + machine?: MachinePreset; + }): Promise { + const attempt = run.attempts[0]; + + const context = { + attempt: attempt + ? { + id: attempt.friendlyId, + number: attempt.number, + status: attempt.status, + startedAt: attempt.createdAt, + } + : { + id: AttemptId.generate().friendlyId, + number: 1, + status: "PENDING" as const, + startedAt: run.updatedAt, + }, + task: { + id: run.taskIdentifier, + filePath: run.lockedBy?.filePath ?? "", + }, + run: { + id: run.friendlyId, + createdAt: run.createdAt, + tags: run.runTags, + isTest: run.isTest, + idempotencyKey: run.idempotencyKey ?? undefined, + startedAt: run.startedAt ?? run.createdAt, + durationMs: run.usageDurationMs, + costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, + maxAttempts: run.maxAttempts ?? undefined, + version: run.lockedToVersion?.version, + maxDuration: run.maxDurationInSeconds ?? undefined, + }, + queue: { + name: run.queue, + id: run.queue, + }, + environment: { + id: run.runtimeEnvironment.id, + slug: run.runtimeEnvironment.slug, + type: run.runtimeEnvironment.type, + }, + organization: { + id: run.project.organization.id, + slug: run.project.organization.slug, + name: run.project.organization.title, + }, + project: { + id: run.project.id, + ref: run.project.externalRef, + slug: run.project.slug, + name: run.project.name, + }, + machine, + } satisfies V3TaskRunContext; + + return context; + } + + async #getV4TaskRunContext({ run }: { run: FindRunResult }): Promise { + return engine.resolveTaskRunContext(run.id); + } } diff --git a/apps/webapp/app/routes/resources.runs.$runParam.ts b/apps/webapp/app/routes/resources.runs.$runParam.ts index 899dbb63f03..7b116b31c3c 100644 --- a/apps/webapp/app/routes/resources.runs.$runParam.ts +++ b/apps/webapp/app/routes/resources.runs.$runParam.ts @@ -76,6 +76,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { lockedBy: { select: { filePath: true, + worker: { + select: { + deployment: { + select: { + friendlyId: true, + shortCode: true, + version: true, + runtime: true, + runtimeVersion: true, + git: true, + }, + }, + }, + }, + }, + }, + parentTaskRun: { + select: { + friendlyId: true, + }, + }, + rootTaskRun: { + select: { + friendlyId: true, }, }, }, @@ -163,6 +187,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { baseCostInCents: run.baseCostInCents, maxAttempts: run.maxAttempts ?? undefined, version: run.lockedToVersion?.version, + parentTaskRunId: run.parentTaskRun?.friendlyId ?? undefined, + rootTaskRunId: run.rootTaskRun?.friendlyId ?? undefined, }, queue: { name: run.queue, @@ -184,6 +210,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { name: run.project.name, }, machine: run.machinePreset ? machinePresetFromRun(run) : undefined, + deployment: run.lockedBy?.worker.deployment + ? { + id: run.lockedBy.worker.deployment.friendlyId, + shortCode: run.lockedBy.worker.deployment.shortCode, + version: run.lockedBy.worker.deployment.version, + runtime: run.lockedBy.worker.deployment.runtime, + runtimeVersion: run.lockedBy.worker.deployment.runtimeVersion, + git: run.lockedBy.worker.deployment.git, + } + : undefined, }; return typedjson({ diff --git a/apps/webapp/app/v3/failedTaskRun.server.ts b/apps/webapp/app/v3/failedTaskRun.server.ts index 3935c21ddd6..f4b3c92ea66 100644 --- a/apps/webapp/app/v3/failedTaskRun.server.ts +++ b/apps/webapp/app/v3/failedTaskRun.server.ts @@ -4,6 +4,7 @@ import { TaskRunExecution, TaskRunExecutionRetry, TaskRunFailedExecutionResult, + V3TaskRunExecution, } from "@trigger.dev/core/v3"; import type { Prisma, TaskRun } from "@trigger.dev/database"; import * as semver from "semver"; @@ -129,7 +130,7 @@ export class FailedTaskRunRetryHelper extends BaseService { async #getRetriableAttemptExecution( run: TaskRunWithAttempts, completion: TaskRunFailedExecutionResult - ): Promise { + ): Promise { let attempt = run.attempts[0]; // We need to create an attempt if: diff --git a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts index 307a6be5d46..2bd80d465b4 100644 --- a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts @@ -1,6 +1,6 @@ import { Context, ROOT_CONTEXT, Span, SpanKind, context, trace } from "@opentelemetry/api"; import { - TaskRunExecution, + V3TaskRunExecution, TaskRunExecutionLazyAttemptPayload, TaskRunExecutionResult, TaskRunFailedExecutionResult, @@ -138,7 +138,7 @@ export class DevQueueConsumer { public async taskAttemptCompleted( workerId: string, completion: TaskRunExecutionResult, - execution: TaskRunExecution + execution: V3TaskRunExecution ) { if (completion.ok) { this._taskSuccesses++; diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 21544cc7564..075732544cc 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -11,8 +11,8 @@ import { import { AckCallbackResult, MachinePreset, - ProdTaskRunExecution, - ProdTaskRunExecutionPayload, + V3ProdTaskRunExecution, + V3ProdTaskRunExecutionPayload, TaskRunError, TaskRunErrorCodes, TaskRunExecution, @@ -1679,7 +1679,7 @@ class SharedQueueTasks { private async _executionFromAttempt( attempt: AttemptForExecution, machinePreset?: MachinePreset - ): Promise { + ): Promise { const { backgroundWorkerTask, taskRun, queue } = attempt; if (!machinePreset) { @@ -1693,7 +1693,7 @@ class SharedQueueTasks { dataType: taskRun.metadataType, }); - const execution: ProdTaskRunExecution = { + const execution: V3ProdTaskRunExecution = { task: { id: backgroundWorkerTask.slug, filePath: backgroundWorkerTask.filePath, @@ -1784,7 +1784,7 @@ class SharedQueueTasks { setToExecuting?: boolean; isRetrying?: boolean; skipStatusChecks?: boolean; - }): Promise { + }): Promise { const attempt = await prisma.taskRunAttempt.findFirst({ where: { id, @@ -1874,7 +1874,7 @@ class SharedQueueTasks { machinePreset ); - const payload: ProdTaskRunExecutionPayload = { + const payload: V3ProdTaskRunExecutionPayload = { execution, traceContext: taskRun.traceContext as Record, environment: variables.reduce((acc: Record, curr) => { @@ -1888,7 +1888,7 @@ class SharedQueueTasks { async getResumePayload(attemptId: string): Promise< | { - execution: ProdTaskRunExecution; + execution: V3ProdTaskRunExecution; completion: TaskRunExecutionResult; } | undefined @@ -1927,7 +1927,7 @@ class SharedQueueTasks { async getResumePayloads(attemptIds: string[]): Promise< Array<{ - execution: ProdTaskRunExecution; + execution: V3ProdTaskRunExecution; completion: TaskRunExecutionResult; }> > { @@ -1985,7 +1985,7 @@ class SharedQueueTasks { id: string, setToExecuting?: boolean, isRetrying?: boolean - ): Promise { + ): Promise { const run = await prisma.taskRun.findFirst({ where: { id, diff --git a/apps/webapp/app/v3/services/completeAttempt.server.ts b/apps/webapp/app/v3/services/completeAttempt.server.ts index d0b9d911b6e..8fe57f040d4 100644 --- a/apps/webapp/app/v3/services/completeAttempt.server.ts +++ b/apps/webapp/app/v3/services/completeAttempt.server.ts @@ -2,13 +2,13 @@ import { Attributes } from "@opentelemetry/api"; import { MachinePresetName, TaskRunContext, - TaskRunError, TaskRunErrorCodes, TaskRunExecution, TaskRunExecutionResult, TaskRunExecutionRetry, TaskRunFailedExecutionResult, TaskRunSuccessfulExecutionResult, + V3TaskRunExecution, flattenAttributes, isOOMRunError, sanitizeError, @@ -60,7 +60,7 @@ export class CompleteAttemptService extends BaseService { checkpoint, }: { completion: TaskRunExecutionResult; - execution: TaskRunExecution; + execution: V3TaskRunExecution; env?: AuthenticatedEnvironment; checkpoint?: CheckpointData; }): Promise<"COMPLETED" | "RETRIED"> { @@ -196,7 +196,7 @@ export class CompleteAttemptService extends BaseService { checkpoint, }: { completion: TaskRunFailedExecutionResult; - execution: TaskRunExecution; + execution: V3TaskRunExecution; taskRunAttempt: NonNullable; env?: AuthenticatedEnvironment; checkpoint?: CheckpointData; @@ -559,7 +559,7 @@ export class CompleteAttemptService extends BaseService { forceRequeue = false, oomMachine, }: { - execution: TaskRunExecution; + execution: V3TaskRunExecution; executionRetry: TaskRunExecutionRetry; executionRetryInferred: boolean; taskRunAttempt: NonNullable; @@ -648,7 +648,7 @@ export class CompleteAttemptService extends BaseService { executionRetryInferred, checkpoint, }: { - execution: TaskRunExecution; + execution: V3TaskRunExecution; taskRunAttempt: NonNullable; executionRetry: TaskRunExecutionRetry; executionRetryInferred: boolean; diff --git a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts index 60d7448b2e0..df5e4e2b744 100644 --- a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts +++ b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts @@ -1,4 +1,4 @@ -import { parsePacket, TaskRunExecution } from "@trigger.dev/core/v3"; +import { parsePacket, V3TaskRunExecution } from "@trigger.dev/core/v3"; import { TaskRun, TaskRunAttempt } from "@trigger.dev/database"; import { MAX_TASK_RUN_ATTEMPTS } from "~/consts"; import { $transaction, prisma, PrismaClientOrTransaction } from "~/db.server"; @@ -25,7 +25,7 @@ export class CreateTaskRunAttemptService extends BaseService { setToExecuting?: boolean; startAtZero?: boolean; }): Promise<{ - execution: TaskRunExecution; + execution: V3TaskRunExecution; run: TaskRun; attempt: TaskRunAttempt; }> { @@ -189,7 +189,7 @@ export class CreateTaskRunAttemptService extends BaseService { dataType: taskRun.metadataType, }); - const execution: TaskRunExecution = { + const execution: V3TaskRunExecution = { task: { id: lockedBy.slug, filePath: lockedBy.filePath, diff --git a/internal-packages/cache/README.md b/internal-packages/cache/README.md new file mode 100644 index 00000000000..e0f344f7885 --- /dev/null +++ b/internal-packages/cache/README.md @@ -0,0 +1,3 @@ +# Redis + +This is a simple package that is used to return a valid Redis client and provides an error callback. It will log and swallow errors if they're not handled. diff --git a/internal-packages/cache/package.json b/internal-packages/cache/package.json new file mode 100644 index 00000000000..63dd03a4b4c --- /dev/null +++ b/internal-packages/cache/package.json @@ -0,0 +1,18 @@ +{ + "name": "@internal/cache", + "private": true, + "version": "0.0.1", + "main": "./src/index.ts", + "types": "./src/index.ts", + "type": "module", + "dependencies": { + "@unkey/cache": "^1.5.0", + "@unkey/error": "^0.2.0", + "@trigger.dev/core": "workspace:*", + "@internal/redis": "workspace:*", + "superjson": "^2.2.1" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} \ No newline at end of file diff --git a/internal-packages/cache/src/index.ts b/internal-packages/cache/src/index.ts new file mode 100644 index 00000000000..e5844d910be --- /dev/null +++ b/internal-packages/cache/src/index.ts @@ -0,0 +1,8 @@ +export { + createCache, + DefaultStatefulContext, + Namespace, + type Cache as UnkeyCache, +} from "@unkey/cache"; +export { MemoryStore } from "@unkey/cache/stores"; +export { RedisCacheStore } from "./stores/redis.js"; diff --git a/internal-packages/cache/src/stores/redis.ts b/internal-packages/cache/src/stores/redis.ts new file mode 100644 index 00000000000..4f40133a4fc --- /dev/null +++ b/internal-packages/cache/src/stores/redis.ts @@ -0,0 +1,105 @@ +import { CacheError } from "@unkey/cache"; +import type { Entry, Store } from "@unkey/cache/stores"; +import { Err, Ok, type Result } from "@unkey/error"; +import { createRedisClient, Redis, RedisOptions } from "@internal/redis"; + +export type RedisCacheStoreConfig = { + connection: RedisOptions; + name?: string; + useModernCacheKeyBuilder?: boolean; +}; + +export class RedisCacheStore + implements Store +{ + public readonly name = "redis"; + private readonly redis: Redis; + + constructor(private readonly config: RedisCacheStoreConfig) { + this.redis = createRedisClient({ + ...config.connection, + name: config.name ?? "trigger:cacheStore", + }); + } + + private buildCacheKey(namespace: TNamespace, key: string): string { + if (this.config.useModernCacheKeyBuilder) { + return [namespace, key].join(":"); + } + + return [namespace, key].join("::"); + } + + public async get( + namespace: TNamespace, + key: string + ): Promise | undefined, CacheError>> { + let raw: string | null; + try { + raw = await this.redis.get(this.buildCacheKey(namespace, key)); + } catch (err) { + return Err( + new CacheError({ + tier: this.name, + key, + message: (err as Error).message, + }) + ); + } + + if (!raw) { + return Promise.resolve(Ok(undefined)); + } + + try { + const superjson = await import("superjson"); + const entry = superjson.parse(raw) as Entry; + return Ok(entry); + } catch (err) { + return Err( + new CacheError({ + tier: this.name, + key, + message: (err as Error).message, + }) + ); + } + } + + public async set( + namespace: TNamespace, + key: string, + entry: Entry + ): Promise> { + const cacheKey = this.buildCacheKey(namespace, key); + try { + const superjson = await import("superjson"); + await this.redis.set(cacheKey, superjson.stringify(entry), "PXAT", entry.staleUntil); + return Ok(); + } catch (err) { + return Err( + new CacheError({ + tier: this.name, + key, + message: (err as Error).message, + }) + ); + } + } + + public async remove(namespace: TNamespace, key: string): Promise> { + try { + const cacheKey = this.buildCacheKey(namespace, key); + await this.redis.del(cacheKey); + return Promise.resolve(Ok()); + } catch (err) { + return Err( + new CacheError({ + tier: this.name, + key, + message: (err as Error).message, + }) + ); + } + } +} diff --git a/internal-packages/cache/tsconfig.json b/internal-packages/cache/tsconfig.json new file mode 100644 index 00000000000..0104339620f --- /dev/null +++ b/internal-packages/cache/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "module": "Node16", + "moduleResolution": "Node16", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "types": ["vitest/globals"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "paths": { + "@trigger.dev/core": ["../../packages/core/src/index"], + "@trigger.dev/core/*": ["../../packages/core/src/*"] + } + }, + "exclude": ["node_modules"] +} diff --git a/internal-packages/run-engine/package.json b/internal-packages/run-engine/package.json index e509d2dede9..62f08378972 100644 --- a/internal-packages/run-engine/package.json +++ b/internal-packages/run-engine/package.json @@ -25,7 +25,7 @@ "@internal/tracing": "workspace:*", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", - "@unkey/cache": "^1.5.0", + "@internal/cache": "workspace:*", "assert-never": "^1.2.1", "nanoid": "3.3.8", "redlock": "5.0.0-beta.2", diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 8fa677a1c12..7657431140a 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -9,6 +9,7 @@ import { ExecutionResult, RunExecutionData, StartRunAttemptResult, + TaskRunContext, TaskRunExecutionResult, } from "@trigger.dev/core/v3"; import { RunId, WaitpointId } from "@trigger.dev/core/v3/isomorphic"; @@ -288,6 +289,7 @@ export class RunEngine { delayedRunSystem: this.delayedRunSystem, machines: this.options.machines, retryWarmStartThresholdMs: this.options.retryWarmStartThresholdMs, + redisOptions: this.options.cache?.redis ?? this.options.runLock.redis, }); this.dequeueSystem = new DequeueSystem({ @@ -1054,6 +1056,10 @@ export class RunEngine { } } + async resolveTaskRunContext(runId: string): Promise { + return this.runAttemptSystem.resolveTaskRunContext(runId); + } + async getSnapshotsSince({ runId, snapshotId, diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index f56c9a6ebb3..ce0f8abe4d5 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -1,3 +1,12 @@ +import { + createCache, + DefaultStatefulContext, + MemoryStore, + Namespace, + RedisCacheStore, + UnkeyCache, +} from "@internal/cache"; +import { RedisOptions } from "@internal/redis"; import { startSpan } from "@internal/tracing"; import { tryCatch } from "@trigger.dev/core/utils"; import { @@ -5,9 +14,16 @@ import { ExecutionResult, FlushedRunMetadata, GitMeta, + MachinePreset, + MachinePresetName, StartRunAttemptResult, + TaskRunContext, TaskRunError, TaskRunExecution, + TaskRunExecutionDeployment, + TaskRunExecutionOrganization, + TaskRunExecutionProject, + TaskRunExecutionQueue, TaskRunExecutionResult, TaskRunFailedExecutionResult, TaskRunInternalError, @@ -23,7 +39,7 @@ import { import { MAX_TASK_RUN_ATTEMPTS } from "../consts.js"; import { runStatusFromError, ServiceValidationError } from "../errors.js"; import { sendNotificationToWorker } from "../eventBus.js"; -import { getMachinePreset } from "../machinePresets.js"; +import { getMachinePreset, machinePresetFromName } from "../machinePresets.js"; import { retryOutcomeFromCompletion } from "../retrying.js"; import { isExecuting, isInitialState } from "../statuses.js"; import { RunEngineOptions } from "../types.js"; @@ -36,6 +52,7 @@ import { } from "./executionSnapshotSystem.js"; import { SystemResources } from "./systems.js"; import { WaitpointSystem } from "./waitpointSystem.js"; +import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic"; export type RunAttemptSystemOptions = { resources: SystemResources; @@ -45,14 +62,54 @@ export type RunAttemptSystemOptions = { delayedRunSystem: DelayedRunSystem; retryWarmStartThresholdMs?: number; machines: RunEngineOptions["machines"]; + redisOptions: RedisOptions; +}; + +type BackwardsCompatibleTaskRunExecution = Omit & { + task: TaskRunExecution["task"] & { + exportName: string | undefined; + }; + attempt: TaskRunExecution["attempt"] & { + id: string; + backgroundWorkerId: string; + backgroundWorkerTaskId: string; + status: string; + }; + run: TaskRunExecution["run"] & { + context: undefined; + durationMs: number; + costInCents: number; + baseCostInCents: number; + }; }; +const ORG_FRESH_TTL = 60000 * 60 * 24; // 1 day +const ORG_STALE_TTL = 60000 * 60 * 24 * 2; // 2 days +const PROJECT_FRESH_TTL = 60000 * 60 * 24; // 1 day +const PROJECT_STALE_TTL = 60000 * 60 * 24 * 2; // 2 days +const TASK_FRESH_TTL = 60000 * 60 * 24; // 1 day +const TASK_STALE_TTL = 60000 * 60 * 24 * 2; // 2 days +const MACHINE_PRESET_FRESH_TTL = 60000 * 60 * 24; // 1 day +const MACHINE_PRESET_STALE_TTL = 60000 * 60 * 24 * 2; // 2 days +const DEPLOYMENT_FRESH_TTL = 60000 * 60 * 24; // 1 day +const DEPLOYMENT_STALE_TTL = 60000 * 60 * 24 * 2; // 2 days +const QUEUE_FRESH_TTL = 60000 * 60; // 1 hour +const QUEUE_STALE_TTL = 60000 * 60 * 2; // 2 hours + export class RunAttemptSystem { private readonly $: SystemResources; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; private readonly batchSystem: BatchSystem; private readonly waitpointSystem: WaitpointSystem; private readonly delayedRunSystem: DelayedRunSystem; + private readonly cache: UnkeyCache<{ + tasks: BackwardsCompatibleTaskRunExecution["task"]; + machinePresets: MachinePreset; + deployments: TaskRunExecutionDeployment; + queues: TaskRunExecutionQueue; + projects: TaskRunExecutionProject; + orgs: TaskRunExecutionOrganization; + }>; constructor(private readonly options: RunAttemptSystemOptions) { this.$ = options.resources; @@ -60,6 +117,170 @@ export class RunAttemptSystem { this.batchSystem = options.batchSystem; this.waitpointSystem = options.waitpointSystem; this.delayedRunSystem = options.delayedRunSystem; + + const ctx = new DefaultStatefulContext(); + // TODO: use an LRU cache for memory store + const memory = new MemoryStore({ persistentMap: new Map() }); + const redisCacheStore = new RedisCacheStore({ + name: "run-attempt-system", + connection: { + ...options.redisOptions, + keyPrefix: "engine:run-attempt-system:cache:", + }, + useModernCacheKeyBuilder: true, + }); + + this.cache = createCache({ + orgs: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: ORG_FRESH_TTL, + stale: ORG_STALE_TTL, + }), + projects: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: PROJECT_FRESH_TTL, + stale: PROJECT_STALE_TTL, + }), + tasks: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: TASK_FRESH_TTL, + stale: TASK_STALE_TTL, + }), + machinePresets: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: MACHINE_PRESET_FRESH_TTL, + stale: MACHINE_PRESET_STALE_TTL, + }), + deployments: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: DEPLOYMENT_FRESH_TTL, + stale: DEPLOYMENT_STALE_TTL, + }), + queues: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: QUEUE_FRESH_TTL, + stale: QUEUE_STALE_TTL, + }), + }); + } + + public async resolveTaskRunContext(runId: string): Promise { + const run = await this.$.prisma.taskRun.findFirst({ + where: { + id: runId, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + executedAt: true, + baseCostInCents: true, + projectId: true, + organizationId: true, + friendlyId: true, + lockedById: true, + lockedQueueId: true, + queue: true, + attemptNumber: true, + status: true, + ttl: true, + machinePreset: true, + runTags: true, + isTest: true, + idempotencyKey: true, + startedAt: true, + maxAttempts: true, + taskVersion: true, + maxDurationInSeconds: true, + usageDurationMs: true, + costInCents: true, + traceContext: true, + priorityMs: true, + taskIdentifier: true, + runtimeEnvironment: { + select: { + id: true, + slug: true, + type: true, + branchName: true, + git: true, + organizationId: true, + }, + }, + parentTaskRunId: true, + rootTaskRunId: true, + batchId: true, + }, + }); + + if (!run) { + throw new ServiceValidationError("Task run not found", 404); + } + + const [task, queue, organization, project, machinePreset, deployment] = await Promise.all([ + run.lockedById + ? this.#resolveTaskRunExecutionTask(run.lockedById) + : Promise.resolve({ + id: run.taskIdentifier, + filePath: "unknown", + }), + this.#resolveTaskRunExecutionQueue({ + runId, + lockedQueueId: run.lockedQueueId ?? undefined, + queueName: run.queue, + runtimeEnvironmentId: run.runtimeEnvironment.id, + }), + this.#resolveTaskRunExecutionOrganization(run.runtimeEnvironment.organizationId), + this.#resolveTaskRunExecutionProjectByRuntimeEnvironmentId(run.runtimeEnvironment.id), + run.lockedById + ? this.#resolveTaskRunExecutionMachinePreset(run.lockedById, run.machinePreset) + : Promise.resolve( + getMachinePreset({ + defaultMachine: this.options.machines.defaultMachine, + machines: this.options.machines.machines, + config: undefined, + run, + }) + ), + run.lockedById + ? this.#resolveTaskRunExecutionDeployment(run.lockedById) + : Promise.resolve(undefined), + ]); + + return { + run: { + id: run.friendlyId, + tags: run.runTags, + isTest: run.isTest, + createdAt: run.createdAt, + startedAt: run.startedAt ?? run.createdAt, + idempotencyKey: run.idempotencyKey ?? undefined, + maxAttempts: run.maxAttempts ?? undefined, + version: run.taskVersion ?? "unknown", + maxDuration: run.maxDurationInSeconds ?? undefined, + priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, + parentTaskRunId: run.parentTaskRunId ? RunId.toFriendlyId(run.parentTaskRunId) : undefined, + rootTaskRunId: run.rootTaskRunId ? RunId.toFriendlyId(run.rootTaskRunId) : undefined, + }, + attempt: { + number: run.attemptNumber ?? 1, + startedAt: run.startedAt ?? new Date(), + }, + task, + queue, + organization, + project, + machine: machinePreset, + deployment, + environment: { + id: run.runtimeEnvironment.id, + slug: run.runtimeEnvironment.slug, + type: run.runtimeEnvironment.type, + branchName: run.runtimeEnvironment.branchName ?? undefined, + git: safeParseGitMeta(run.runtimeEnvironment.git), + }, + batch: run.batchId ? { id: BatchId.toFriendlyId(run.batchId) } : undefined, + }; } public async startRunAttempt({ @@ -95,35 +316,19 @@ export class RunAttemptSystem { throw new ServiceValidationError("Snapshot changed", 409); } - const environment = await this.#getAuthenticatedEnvironmentFromRun(runId, prisma); - if (!environment) { - throw new ServiceValidationError("Environment not found", 404); - } - const taskRun = await prisma.taskRun.findFirst({ where: { id: runId, }, - include: { - tags: true, - lockedBy: { - include: { - worker: { - select: { - id: true, - version: true, - sdkVersion: true, - cliVersion: true, - supportsLazyAttempts: true, - }, - }, - }, - }, - batchItems: { - include: { - batchTaskRun: true, - }, - }, + select: { + id: true, + friendlyId: true, + attemptNumber: true, + projectId: true, + runtimeEnvironmentId: true, + status: true, + lockedById: true, + ttl: true, }, }); @@ -142,21 +347,10 @@ export class RunAttemptSystem { throw new ServiceValidationError("Task run is cancelled", 400); } - if (!taskRun.lockedBy) { + if (!taskRun.lockedById) { throw new ServiceValidationError("Task run is not locked", 400); } - const queue = await prisma.taskQueue.findFirst({ - where: { - runtimeEnvironmentId: environment.id, - name: taskRun.queue, - }, - }); - - if (!queue) { - throw new ServiceValidationError("Queue not found", 404); - } - //increment the attempt number (start at 1) const nextAttemptNumber = (taskRun.attemptNumber ?? 0) + 1; @@ -190,11 +384,50 @@ export class RunAttemptSystem { attemptNumber: nextAttemptNumber, executedAt: taskRun.attemptNumber === null ? new Date() : undefined, }, - include: { - tags: true, - lockedBy: { - include: { worker: true }, + select: { + id: true, + createdAt: true, + updatedAt: true, + executedAt: true, + baseCostInCents: true, + projectId: true, + organizationId: true, + friendlyId: true, + lockedById: true, + lockedQueueId: true, + queue: true, + attemptNumber: true, + status: true, + ttl: true, + metadata: true, + metadataType: true, + machinePreset: true, + payload: true, + payloadType: true, + runTags: true, + isTest: true, + idempotencyKey: true, + startedAt: true, + maxAttempts: true, + taskVersion: true, + maxDurationInSeconds: true, + usageDurationMs: true, + costInCents: true, + traceContext: true, + priorityMs: true, + batchId: true, + runtimeEnvironment: { + select: { + id: true, + slug: true, + type: true, + branchName: true, + git: true, + organizationId: true, + }, }, + parentTaskRunId: true, + rootTaskRunId: true, }, }); @@ -222,7 +455,7 @@ export class RunAttemptSystem { await this.$.worker.ack(`expireRun:${taskRun.id}`); } - return { run, snapshot: newSnapshot }; + return { updatedRun: run, snapshot: newSnapshot }; }, (error) => { this.$.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { @@ -247,56 +480,59 @@ export class RunAttemptSystem { throw new ServiceValidationError("Failed to create task run attempt", 500); } - const { run, snapshot } = result; + const { updatedRun, snapshot } = result; this.$.eventBus.emit("runAttemptStarted", { time: new Date(), run: { - id: run.id, - status: run.status, - createdAt: run.createdAt, - updatedAt: run.updatedAt, + id: updatedRun.id, + status: updatedRun.status, + createdAt: updatedRun.createdAt, + updatedAt: updatedRun.updatedAt, attemptNumber: nextAttemptNumber, - baseCostInCents: run.baseCostInCents, - executedAt: run.executedAt ?? undefined, + baseCostInCents: updatedRun.baseCostInCents, + executedAt: updatedRun.executedAt ?? undefined, }, organization: { - id: environment.organization.id, + id: updatedRun.runtimeEnvironment.organizationId, }, project: { - id: environment.project.id, + id: updatedRun.projectId, }, environment: { - id: environment.id, + id: updatedRun.runtimeEnvironment.id, }, }); - const machinePreset = getMachinePreset({ - machines: this.options.machines.machines, - defaultMachine: this.options.machines.defaultMachine, - config: taskRun.lockedBy.machineConfig ?? {}, - run: taskRun, - }); - - const metadata = await parsePacket({ - data: taskRun.metadata ?? undefined, - dataType: taskRun.metadataType, - }); - - let git: GitMeta | undefined = undefined; - if (environment.git) { - const parsed = GitMeta.safeParse(environment.git); - if (parsed.success) { - git = parsed.data; - } - } + const environmentGit = safeParseGitMeta(updatedRun.runtimeEnvironment.git); - const execution: TaskRunExecution = { - task: { - id: run.lockedBy!.slug, - filePath: run.lockedBy!.filePath, - exportName: run.lockedBy!.exportName ?? undefined, - }, + const [metadata, task, queue, organization, project, machinePreset, deployment] = + await Promise.all([ + parsePacket({ + data: updatedRun.metadata ?? undefined, + dataType: updatedRun.metadataType, + }), + this.#resolveTaskRunExecutionTask(taskRun.lockedById), + this.#resolveTaskRunExecutionQueue({ + runId, + lockedQueueId: updatedRun.lockedQueueId ?? undefined, + queueName: updatedRun.queue, + runtimeEnvironmentId: updatedRun.runtimeEnvironment.id, + }), + this.#resolveTaskRunExecutionOrganization( + updatedRun.runtimeEnvironment.organizationId + ), + this.#resolveTaskRunExecutionProjectByRuntimeEnvironmentId( + updatedRun.runtimeEnvironment.id + ), + this.#resolveTaskRunExecutionMachinePreset( + taskRun.lockedById, + updatedRun.machinePreset + ), + this.#resolveTaskRunExecutionDeployment(taskRun.lockedById), + ]); + + const execution: BackwardsCompatibleTaskRunExecution = { attempt: { number: nextAttemptNumber, startedAt: latestSnapshot.updatedAt, @@ -310,59 +546,56 @@ export class RunAttemptSystem { status: "deprecated", }, run: { - id: run.friendlyId, - payload: run.payload, - payloadType: run.payloadType, - createdAt: run.createdAt, - tags: run.tags.map((tag) => tag.name), - isTest: run.isTest, - idempotencyKey: run.idempotencyKey ?? undefined, - startedAt: run.startedAt ?? run.createdAt, - maxAttempts: run.maxAttempts ?? undefined, - version: run.lockedBy!.worker.version, + id: updatedRun.friendlyId, + payload: updatedRun.payload, + payloadType: updatedRun.payloadType, + createdAt: updatedRun.createdAt, + tags: updatedRun.runTags, + isTest: updatedRun.isTest, + idempotencyKey: updatedRun.idempotencyKey ?? undefined, + startedAt: updatedRun.startedAt ?? updatedRun.createdAt, + maxAttempts: updatedRun.maxAttempts ?? undefined, + version: updatedRun.taskVersion ?? "unknown", metadata, - maxDuration: run.maxDurationInSeconds ?? undefined, + maxDuration: updatedRun.maxDurationInSeconds ?? undefined, /** @deprecated */ context: undefined, /** @deprecated */ - durationMs: run.usageDurationMs, + durationMs: updatedRun.usageDurationMs, /** @deprecated */ - costInCents: run.costInCents, + costInCents: updatedRun.costInCents, /** @deprecated */ - baseCostInCents: run.baseCostInCents, - traceContext: run.traceContext as Record, - priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, - }, - queue: { - id: queue.friendlyId, - name: queue.name, + baseCostInCents: updatedRun.baseCostInCents, + traceContext: updatedRun.traceContext as Record, + priority: updatedRun.priorityMs === 0 ? undefined : updatedRun.priorityMs / 1_000, + parentTaskRunId: updatedRun.parentTaskRunId + ? RunId.toFriendlyId(updatedRun.parentTaskRunId) + : undefined, + rootTaskRunId: updatedRun.rootTaskRunId + ? RunId.toFriendlyId(updatedRun.rootTaskRunId) + : undefined, }, + task, + queue, environment: { - id: environment.id, - slug: environment.slug, - type: environment.type, - branchName: environment.branchName ?? undefined, - git, - }, - organization: { - id: environment.organization.id, - slug: environment.organization.slug, - name: environment.organization.title, + id: updatedRun.runtimeEnvironment.id, + slug: updatedRun.runtimeEnvironment.slug, + type: updatedRun.runtimeEnvironment.type, + branchName: updatedRun.runtimeEnvironment.branchName ?? undefined, + git: environmentGit, }, - project: { - id: environment.project.id, - ref: environment.project.externalRef, - slug: environment.project.slug, - name: environment.project.name, - }, - batch: - taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun - ? { id: taskRun.batchItems[0].batchTaskRun.friendlyId } - : undefined, + organization, + project, machine: machinePreset, + deployment, + batch: updatedRun.batchId + ? { + id: BatchId.toFriendlyId(updatedRun.batchId), + } + : undefined, }; - return { run, snapshot, execution }; + return { run: updatedRun, snapshot, execution }; }); }, { @@ -1311,27 +1544,248 @@ export class RunAttemptSystem { await this.$.worker.ack(`heartbeatSnapshot.${id}`); } - async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) { - const prisma = tx ?? this.$.prisma; - const taskRun = await prisma.taskRun.findFirst({ - where: { - id: runId, - }, - include: { - runtimeEnvironment: { - include: { - organization: true, - project: true, + async #resolveTaskRunExecutionTask( + backgroundWorkerTaskId: string + ): Promise { + const result = await this.cache.tasks.swr(backgroundWorkerTaskId, async () => { + const task = await this.$.prisma.backgroundWorkerTask.findFirstOrThrow({ + where: { + id: backgroundWorkerTaskId, + }, + select: { + id: true, + slug: true, + filePath: true, + exportName: true, + }, + }); + + return { + id: task.slug, + filePath: task.filePath, + exportName: task.exportName ?? undefined, + }; + }); + + if (result.err) { + throw result.err; + } + + if (!result.val) { + throw new ServiceValidationError( + `Could not resolve task execution data for task ${backgroundWorkerTaskId}` + ); + } + + return result.val; + } + + async #resolveTaskRunExecutionOrganization( + organizationId: string + ): Promise { + const result = await this.cache.orgs.swr(organizationId, async () => { + const organization = await this.$.prisma.organization.findFirstOrThrow({ + where: { id: organizationId }, + select: { + id: true, + title: true, + slug: true, + }, + }); + + return { + id: organization.id, + name: organization.title, + slug: organization.slug, + }; + }); + + if (result.err) { + throw result.err; + } + + if (!result.val) { + throw new ServiceValidationError( + `Could not resolve organization data for organization ${organizationId}` + ); + } + + return result.val; + } + + async #resolveTaskRunExecutionProjectByRuntimeEnvironmentId( + runtimeEnvironmentId: string + ): Promise { + const result = await this.cache.projects.swr(runtimeEnvironmentId, async () => { + const { project } = await this.$.prisma.runtimeEnvironment.findFirstOrThrow({ + where: { id: runtimeEnvironmentId }, + select: { + id: true, + project: { + select: { + id: true, + name: true, + slug: true, + externalRef: true, + }, }, }, - }, + }); + + return { + id: project.id, + name: project.name, + slug: project.slug, + ref: project.externalRef, + }; }); - if (!taskRun) { - return; + if (result.err) { + throw result.err; + } + + if (!result.val) { + throw new ServiceValidationError( + `Could not resolve project data for project ${runtimeEnvironmentId}` + ); + } + + return result.val; + } + + async #resolveTaskRunExecutionMachinePreset( + backgroundWorkerTaskId: string, + runMachinePreset: string | null + ): Promise { + if (runMachinePreset) { + return machinePresetFromName( + this.options.machines.machines, + runMachinePreset as MachinePresetName + ); + } + + const result = await this.cache.machinePresets.swr(backgroundWorkerTaskId, async () => { + const { machineConfig } = await this.$.prisma.backgroundWorkerTask.findFirstOrThrow({ + where: { + id: backgroundWorkerTaskId, + }, + select: { + machineConfig: true, + }, + }); + + return getMachinePreset({ + machines: this.options.machines.machines, + defaultMachine: this.options.machines.defaultMachine, + config: machineConfig, + run: { machinePreset: null }, + }); + }); + + if (result.err) { + throw result.err; + } + + if (!result.val) { + throw new ServiceValidationError( + `Could not resolve machine preset for task ${backgroundWorkerTaskId}` + ); + } + + return result.val; + } + + async #resolveTaskRunExecutionQueue(params: { + runId: string; + lockedQueueId?: string; + queueName: string; + runtimeEnvironmentId: string; + }): Promise { + const result = await this.cache.queues.swr(params.runId, async () => { + const queue = params.lockedQueueId + ? await this.$.prisma.taskQueue.findFirst({ + where: { + id: params.lockedQueueId, + }, + select: { + id: true, + friendlyId: true, + name: true, + }, + }) + : await this.$.prisma.taskQueue.findFirst({ + where: { + runtimeEnvironmentId: params.runtimeEnvironmentId, + name: params.queueName, + }, + select: { + id: true, + friendlyId: true, + name: true, + }, + }); + + if (!queue) { + throw new ServiceValidationError( + `Could not resolve queue data for queue ${params.queueName}`, + 404 + ); + } + + return { + id: queue.friendlyId, + name: queue.name, + }; + }); + + if (result.err) { + throw result.err; } - return taskRun?.runtimeEnvironment; + if (!result.val) { + throw new ServiceValidationError( + `Could not resolve queue data for queue ${params.queueName}`, + 404 + ); + } + + return result.val; + } + + async #resolveTaskRunExecutionDeployment( + backgroundWorkerTaskId: string + ): Promise { + const result = await this.cache.deployments.swr(backgroundWorkerTaskId, async () => { + const { worker } = await this.$.prisma.backgroundWorkerTask.findFirstOrThrow({ + where: { id: backgroundWorkerTaskId }, + select: { + worker: { + select: { + deployment: true, + }, + }, + }, + }); + + if (!worker.deployment) { + return undefined; + } + + return { + id: worker.deployment.friendlyId, + shortCode: worker.deployment.shortCode, + version: worker.deployment.version, + runtime: worker.deployment.runtime ?? "unknown", + runtimeVersion: worker.deployment.runtimeVersion ?? "unknown", + git: safeParseGitMeta(worker.deployment.git), + }; + }); + + if (result.err) { + throw result.err; + } + + return result.val; } async #notifyMetadataUpdated(runId: string, completion: TaskRunExecutionResult) { @@ -1386,3 +1840,11 @@ export class RunAttemptSystem { } } } + +export function safeParseGitMeta(git: unknown): GitMeta | undefined { + const parsed = GitMeta.safeParse(git); + if (parsed.success) { + return parsed.data; + } + return undefined; +} diff --git a/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts b/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts index 9983415f514..c9654c612de 100644 --- a/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts +++ b/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts @@ -639,7 +639,7 @@ describe("RunEngine heartbeats", () => { containerTest("Heartbeat keeps run alive", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const executingTimeout = 100; + const executingTimeout = 500; const engine = new RunEngine({ prisma, @@ -726,24 +726,23 @@ describe("RunEngine heartbeats", () => { expect(executionData.snapshot.executionStatus).toBe("EXECUTING"); expect(executionData.run.status).toBe("EXECUTING"); - // Send heartbeats every 50ms (half the timeout) - for (let i = 0; i < 6; i++) { - await setTimeout(50); + // Send heartbeats every 100ms (to make sure we're not timing out) + for (let i = 0; i < 5; i++) { + await setTimeout(100); await engine.heartbeatRun({ runId: run.id, snapshotId: attempt.snapshot.id, }); } - // After 300ms (3x the timeout) the run should still be executing - // because we've been sending heartbeats + // Should still be executing because we're sending heartbeats const executionData2 = await engine.getRunExecutionData({ runId: run.id }); assertNonNullable(executionData2); expect(executionData2.snapshot.executionStatus).toBe("EXECUTING"); expect(executionData2.run.status).toBe("EXECUTING"); // Stop sending heartbeats and wait for timeout - await setTimeout(executingTimeout * 3); + await setTimeout(executingTimeout * 2); // Now it should have timed out and be queued const executionData3 = await engine.getRunExecutionData({ runId: run.id }); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 5884e0ab9bf..e15f90b1c54 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -58,6 +58,9 @@ export type RunEngineOptions = { automaticExtensionThreshold?: number; retryConfig?: LockRetryConfig; }; + cache?: { + redis: RedisOptions; + }; /** If not set then checkpoints won't ever be used */ retryWarmStartThresholdMs?: number; heartbeatTimeoutsMs?: Partial; diff --git a/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts b/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts index 5f2f031bdba..b67e77d1517 100644 --- a/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts +++ b/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts @@ -1,7 +1,12 @@ import { createRedisClient, Redis, type RedisOptions } from "@internal/redis"; import { startSpan, type Tracer } from "@internal/tracing"; -import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache"; -import { MemoryStore } from "@unkey/cache/stores"; +import { + createCache, + DefaultStatefulContext, + Namespace, + type UnkeyCache, + MemoryStore, +} from "@internal/cache"; import { randomUUID } from "crypto"; import seedrandom from "seedrandom"; import { diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index 4e220c9a192..8a148eaa718 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -484,6 +484,8 @@ const zodIpc = new ZodIpcConnection({ } runMetadataManager.runId = execution.run.id; + runMetadataManager.runIdIsRoot = typeof execution.run.rootTaskRunId === "undefined"; + _executionCount++; const executor = new TaskExecutor(task, { @@ -503,6 +505,11 @@ const zodIpc = new ZodIpcConnection({ getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000) ); + devUsageManager.setInitialState({ + cpuTime: execution.run.durationMs ?? 0, + costInCents: execution.run.costInCents ?? 0, + }); + _executionMeasurement = usage.start(); const timeoutController = timeout.abortAfterTimeout(execution.run.maxDuration); diff --git a/packages/cli-v3/src/entryPoints/managed-run-worker.ts b/packages/cli-v3/src/entryPoints/managed-run-worker.ts index 294e2c741a3..21ed6a265f4 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-worker.ts @@ -328,12 +328,17 @@ const zodIpc = new ZodIpcConnection({ resetExecutionEnvironment(); - initializeUsageManager({ + const prodManager = initializeUsageManager({ usageIntervalMs: getEnvVar("USAGE_HEARTBEAT_INTERVAL_MS"), usageEventUrl: getEnvVar("USAGE_EVENT_URL"), triggerJWT: getEnvVar("TRIGGER_JWT"), }); + prodManager.setInitialState({ + cpuTime: execution.run.durationMs ?? 0, + costInCents: execution.run.costInCents ?? 0, + }); + standardRunTimelineMetricsManager.registerMetricsFromExecution(metrics, isWarmStart); console.log(`[${new Date().toISOString()}] Received EXECUTE_TASK_RUN`, execution); @@ -483,6 +488,7 @@ const zodIpc = new ZodIpcConnection({ } runMetadataManager.runId = execution.run.id; + runMetadataManager.runIdIsRoot = typeof execution.run.rootTaskRunId === "undefined"; _executionCount++; const executor = new TaskExecutor(task, { @@ -689,6 +695,8 @@ function initializeUsageManager({ usage.setGlobalUsageManager(prodUsageManager); timeout.setGlobalManager(new UsageTimeoutManager(devUsageManager)); + + return prodUsageManager; } _sharedWorkerRuntime = new SharedRuntimeManager(zodIpc, true); diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index 7b4f8a7f3da..90fa31bd573 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -95,6 +95,7 @@ export const SnapshotId = new IdUtil("snapshot"); export const WaitpointId = new IdUtil("waitpoint"); export const BatchId = new IdUtil("batch"); export const BulkActionId = new IdUtil("bulk"); +export const AttemptId = new IdUtil("attempt"); export class IdGenerator { private alphabet: string; diff --git a/packages/core/src/v3/runMetadata/manager.ts b/packages/core/src/v3/runMetadata/manager.ts index 75cdd23500d..03f2d6f2445 100644 --- a/packages/core/src/v3/runMetadata/manager.ts +++ b/packages/core/src/v3/runMetadata/manager.ts @@ -24,6 +24,7 @@ export class StandardMetadataManager implements RunMetadataManager { private queuedRootOperations: Set = new Set(); public runId: string | undefined; + public runIdIsRoot: boolean = false; constructor( private apiClient: ApiClient, @@ -38,6 +39,7 @@ export class StandardMetadataManager implements RunMetadataManager { this.activeStreams.clear(); this.store = undefined; this.runId = undefined; + this.runIdIsRoot = false; if (this.flushTimeoutId) { clearTimeout(this.flushTimeoutId); @@ -54,34 +56,76 @@ export class StandardMetadataManager implements RunMetadataManager { // Create the updater object and store it in a local variable const parentUpdater: RunMetadataUpdater = { set: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.set(key, value); + } + self.queuedParentOperations.add({ type: "set", key, value }); return parentUpdater; }, del: (key) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.del(key); + } + self.queuedParentOperations.add({ type: "delete", key }); return parentUpdater; }, append: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.append(key, value); + } + self.queuedParentOperations.add({ type: "append", key, value }); return parentUpdater; }, remove: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.remove(key, value); + } + self.queuedParentOperations.add({ type: "remove", key, value }); return parentUpdater; }, increment: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.increment(key, value); + } + self.queuedParentOperations.add({ type: "increment", key, value }); return parentUpdater; }, decrement: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.decrement(key, value); + } + self.queuedParentOperations.add({ type: "increment", key, value: -Math.abs(value) }); return parentUpdater; }, update: (value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.update(value); + } + self.queuedParentOperations.add({ type: "update", value }); return parentUpdater; }, - stream: (key, value, signal) => self.doStream(key, value, "parent", parentUpdater, signal), + stream: (key, value, signal) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.doStream(key, value, "self", parentUpdater, signal); + } + + return self.doStream(key, value, "parent", parentUpdater, signal); + }, }; return parentUpdater; @@ -94,34 +138,76 @@ export class StandardMetadataManager implements RunMetadataManager { // Create the updater object and store it in a local variable const rootUpdater: RunMetadataUpdater = { set: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.set(key, value); + } + self.queuedRootOperations.add({ type: "set", key, value }); return rootUpdater; }, del: (key) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.del(key); + } + self.queuedRootOperations.add({ type: "delete", key }); return rootUpdater; }, append: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.append(key, value); + } + self.queuedRootOperations.add({ type: "append", key, value }); return rootUpdater; }, remove: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.remove(key, value); + } + self.queuedRootOperations.add({ type: "remove", key, value }); return rootUpdater; }, increment: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.increment(key, value); + } + self.queuedRootOperations.add({ type: "increment", key, value }); return rootUpdater; }, decrement: (key, value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.decrement(key, value); + } + self.queuedRootOperations.add({ type: "increment", key, value: -Math.abs(value) }); return rootUpdater; }, update: (value) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.update(value); + } + self.queuedRootOperations.add({ type: "update", value }); return rootUpdater; }, - stream: (key, value, signal) => self.doStream(key, value, "root", rootUpdater, signal), + stream: (key, value, signal) => { + // We have to check runIdIsRoot here because parent/root are executed before runIdIsRoot is set + if (self.runIdIsRoot) { + return self.doStream(key, value, "self", rootUpdater, signal); + } + + return self.doStream(key, value, "root", rootUpdater, signal); + }, }; return rootUpdater; diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index e56ec8da6d0..c80d6a8ce75 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -219,49 +219,19 @@ export const TaskRun = z.object({ version: z.string().optional(), metadata: z.record(DeserializedJsonSchema).optional(), maxDuration: z.number().optional(), - /** @deprecated */ - context: z.any(), - /** - * @deprecated For live values use the `usage` SDK functions - * @link https://trigger.dev/docs/run-usage - */ - durationMs: z.number().default(0), - /** - * @deprecated For live values use the `usage` SDK functions - * @link https://trigger.dev/docs/run-usage - */ - costInCents: z.number().default(0), - /** - * @deprecated For live values use the `usage` SDK functions - * @link https://trigger.dev/docs/run-usage - */ - baseCostInCents: z.number().default(0), /** The priority of the run. Wih a value of 10 it will be dequeued before runs that were triggered 9 seconds before it (assuming they had no priority set). */ priority: z.number().optional(), -}); + baseCostInCents: z.number().optional(), -export type TaskRun = z.infer; + parentTaskRunId: z.string().optional(), + rootTaskRunId: z.string().optional(), -export const TaskRunExecutionTask = z.object({ - id: z.string(), - filePath: z.string(), - exportName: z.string().optional(), + // These are only used during execution, not in run.ctx + durationMs: z.number().optional(), + costInCents: z.number().optional(), }); -export type TaskRunExecutionTask = z.infer; - -export const TaskRunExecutionAttempt = z.object({ - number: z.number(), - startedAt: z.coerce.date(), - /** @deprecated */ - id: z.string(), - /** @deprecated */ - backgroundWorkerId: z.string(), - /** @deprecated */ - backgroundWorkerTaskId: z.string(), - /** @deprecated */ - status: z.string(), -}); +export type TaskRun = z.infer; export const GitMeta = z.object({ commitAuthorName: z.string().optional(), @@ -277,6 +247,18 @@ export const GitMeta = z.object({ export type GitMeta = z.infer; +export const TaskRunExecutionTask = z.object({ + id: z.string(), + filePath: z.string(), +}); + +export type TaskRunExecutionTask = z.infer; + +export const TaskRunExecutionAttempt = z.object({ + number: z.number(), + startedAt: z.coerce.date(), +}); + export type TaskRunExecutionAttempt = z.infer; export const TaskRunExecutionEnvironment = z.object({ @@ -317,40 +299,144 @@ export const TaskRunExecutionBatch = z.object({ id: z.string(), }); -export const TaskRunExecution = z.object({ +export const TaskRunExecutionDeployment = z.object({ + id: z.string(), + shortCode: z.string(), + version: z.string(), + runtime: z.string(), + runtimeVersion: z.string(), + git: GitMeta.optional(), +}); + +export type TaskRunExecutionDeployment = z.infer; + +const StaticTaskRunExecutionShape = { task: TaskRunExecutionTask, + queue: TaskRunExecutionQueue, + environment: TaskRunExecutionEnvironment, + organization: TaskRunExecutionOrganization, + project: TaskRunExecutionProject, + machine: MachinePreset, + batch: TaskRunExecutionBatch.optional(), + deployment: TaskRunExecutionDeployment.optional(), +}; + +export const StaticTaskRunExecution = z.object(StaticTaskRunExecutionShape); + +export type StaticTaskRunExecution = z.infer; + +export const TaskRunExecution = z.object({ attempt: TaskRunExecutionAttempt, run: TaskRun.and( z.object({ traceContext: z.record(z.unknown()).optional(), }) ), + ...StaticTaskRunExecutionShape, +}); + +export type TaskRunExecution = z.infer; + +export const V3TaskRunExecutionTask = z.object({ + id: z.string(), + filePath: z.string(), + exportName: z.string().optional(), +}); + +export type V3TaskRunExecutionTask = z.infer; + +export const V3TaskRunExecutionAttempt = z.object({ + number: z.number(), + startedAt: z.coerce.date(), + id: z.string(), + backgroundWorkerId: z.string(), + backgroundWorkerTaskId: z.string(), + status: z.string(), +}); + +export type V3TaskRunExecutionAttempt = z.infer; + +export const V3TaskRun = z.object({ + id: z.string(), + payload: z.string(), + payloadType: z.string(), + tags: z.array(z.string()), + isTest: z.boolean().default(false), + createdAt: z.coerce.date(), + startedAt: z.coerce.date().default(() => new Date()), + idempotencyKey: z.string().optional(), + maxAttempts: z.number().optional(), + version: z.string().optional(), + metadata: z.record(DeserializedJsonSchema).optional(), + maxDuration: z.number().optional(), + context: z.unknown(), + durationMs: z.number(), + costInCents: z.number(), + baseCostInCents: z.number(), +}); + +export type V3TaskRun = z.infer; + +export const V3TaskRunExecution = z.object({ + task: V3TaskRunExecutionTask, + attempt: V3TaskRunExecutionAttempt, + run: V3TaskRun.and( + z.object({ + traceContext: z.record(z.unknown()).optional(), + }) + ), queue: TaskRunExecutionQueue, environment: TaskRunExecutionEnvironment, organization: TaskRunExecutionOrganization, project: TaskRunExecutionProject, - batch: TaskRunExecutionBatch.optional(), machine: MachinePreset, + batch: TaskRunExecutionBatch.optional(), }); -export type TaskRunExecution = z.infer; +export type V3TaskRunExecution = z.infer; export const TaskRunContext = z.object({ - task: TaskRunExecutionTask, - attempt: TaskRunExecutionAttempt.omit({ + attempt: TaskRunExecutionAttempt, + run: TaskRun.omit({ + payload: true, + payloadType: true, + metadata: true, + durationMs: true, + costInCents: true, + }), + ...StaticTaskRunExecutionShape, +}); + +export type TaskRunContext = z.infer; + +export const V3TaskRunExecutionEnvironment = z.object({ + id: z.string(), + slug: z.string(), + type: z.enum(["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"]), +}); + +export type V3TaskRunExecutionEnvironment = z.infer; + +export const V3TaskRunContext = z.object({ + attempt: V3TaskRunExecutionAttempt.omit({ backgroundWorkerId: true, backgroundWorkerTaskId: true, }), - run: TaskRun.omit({ payload: true, payloadType: true, metadata: true }), + run: V3TaskRun.omit({ + payload: true, + payloadType: true, + metadata: true, + }), + task: V3TaskRunExecutionTask, queue: TaskRunExecutionQueue, - environment: TaskRunExecutionEnvironment, + environment: V3TaskRunExecutionEnvironment, organization: TaskRunExecutionOrganization, project: TaskRunExecutionProject, batch: TaskRunExecutionBatch.optional(), machine: MachinePreset.optional(), }); -export type TaskRunContext = z.infer; +export type V3TaskRunContext = z.infer; export const TaskRunExecutionRetry = z.object({ timestamp: z.number(), diff --git a/packages/core/src/v3/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index 6c813b357e0..72a78deb3ca 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -6,12 +6,13 @@ import { TaskRunExecutionResult, TaskRunFailedExecutionResult, TaskRunInternalError, + V3TaskRunExecution, } from "./common.js"; import { TaskResource } from "./resources.js"; import { EnvironmentType, - ProdTaskRunExecution, - ProdTaskRunExecutionPayload, + V3ProdTaskRunExecution, + V3ProdTaskRunExecutionPayload, RunEngineVersionSchema, TaskRunExecutionLazyAttemptPayload, TaskRunExecutionMetrics, @@ -83,7 +84,7 @@ export const BackgroundWorkerClientMessages = z.discriminatedUnion("type", [ version: z.literal("v1").default("v1"), type: z.literal("TASK_RUN_COMPLETED"), completion: TaskRunExecutionResult, - execution: TaskRunExecution, + execution: V3TaskRunExecution, }), z.object({ version: z.literal("v1").default("v1"), @@ -368,7 +369,7 @@ export const CoordinatorToPlatformMessages = { }), z.object({ success: z.literal(true), - executionPayload: ProdTaskRunExecutionPayload, + executionPayload: V3ProdTaskRunExecutionPayload, }), ]), }, @@ -385,7 +386,7 @@ export const CoordinatorToPlatformMessages = { }), z.object({ success: z.literal(true), - payload: ProdTaskRunExecutionPayload, + payload: V3ProdTaskRunExecutionPayload, }), ]), }, @@ -417,7 +418,7 @@ export const CoordinatorToPlatformMessages = { TASK_RUN_COMPLETED: { message: z.object({ version: z.enum(["v1", "v2"]).default("v1"), - execution: ProdTaskRunExecution, + execution: V3ProdTaskRunExecution, completion: TaskRunExecutionResult, checkpoint: z .object({ @@ -430,7 +431,7 @@ export const CoordinatorToPlatformMessages = { TASK_RUN_COMPLETED_WITH_ACK: { message: z.object({ version: z.enum(["v1", "v2"]).default("v2"), - execution: ProdTaskRunExecution, + execution: V3ProdTaskRunExecution, completion: TaskRunExecutionResult, checkpoint: z .object({ @@ -720,7 +721,7 @@ export const ProdWorkerToCoordinatorMessages = { TASK_RUN_COMPLETED: { message: z.object({ version: z.enum(["v1", "v2"]).default("v1"), - execution: ProdTaskRunExecution, + execution: V3ProdTaskRunExecution, completion: TaskRunExecutionResult, }), callback: z.object({ @@ -792,7 +793,7 @@ export const ProdWorkerToCoordinatorMessages = { }), z.object({ success: z.literal(true), - executionPayload: ProdTaskRunExecutionPayload, + executionPayload: V3ProdTaskRunExecutionPayload, }), ]), }, @@ -835,7 +836,7 @@ export const CoordinatorToProdWorkerMessages = { EXECUTE_TASK_RUN: { message: z.object({ version: z.literal("v1").default("v1"), - executionPayload: ProdTaskRunExecutionPayload, + executionPayload: V3ProdTaskRunExecutionPayload, }), }, EXECUTE_TASK_RUN_LAZY_ATTEMPT: { diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 660f9dea380..bd32d848ff3 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { RequireKeys } from "../types/index.js"; -import { MachineConfig, MachinePreset, MachinePresetName, TaskRunExecution } from "./common.js"; +import { + MachineConfig, + MachinePreset, + MachinePresetName, + TaskRunExecution, + V3TaskRunExecution, +} from "./common.js"; /* WARNING: Never import anything from ./messages here. If it's needed in both, put it here instead. @@ -36,7 +42,7 @@ export type TaskRunExecutionPayload = z.infer; // Strategies for not breaking backwards compatibility: // 1. Add new fields as optional // 2. If a field is required, add a default value -export const ProdTaskRunExecution = TaskRunExecution.extend({ +export const V3ProdTaskRunExecution = V3TaskRunExecution.extend({ worker: z.object({ id: z.string(), contentHash: z.string(), @@ -46,16 +52,16 @@ export const ProdTaskRunExecution = TaskRunExecution.extend({ machine: MachinePreset.default({ name: "small-1x", cpu: 1, memory: 1, centsPerMs: 0 }), }); -export type ProdTaskRunExecution = z.infer; +export type V3ProdTaskRunExecution = z.infer; -export const ProdTaskRunExecutionPayload = z.object({ - execution: ProdTaskRunExecution, +export const V3ProdTaskRunExecutionPayload = z.object({ + execution: V3ProdTaskRunExecution, traceContext: z.record(z.unknown()), environment: z.record(z.string()).optional(), metrics: TaskRunExecutionMetrics.optional(), }); -export type ProdTaskRunExecutionPayload = z.infer; +export type V3ProdTaskRunExecutionPayload = z.infer; export const FixedWindowRateLimit = z.object({ type: z.literal("fixed-window"), diff --git a/packages/core/src/v3/taskContext/index.ts b/packages/core/src/v3/taskContext/index.ts index a9ef58cf6e5..b03b7ff4b6c 100644 --- a/packages/core/src/v3/taskContext/index.ts +++ b/packages/core/src/v3/taskContext/index.ts @@ -82,11 +82,9 @@ export class TaskContextAPI { get contextAttributes(): Attributes { if (this.ctx) { return { - [SemanticInternalAttributes.ATTEMPT_ID]: this.ctx.attempt.id, [SemanticInternalAttributes.ATTEMPT_NUMBER]: this.ctx.attempt.number, [SemanticInternalAttributes.TASK_SLUG]: this.ctx.task.id, [SemanticInternalAttributes.TASK_PATH]: this.ctx.task.filePath, - [SemanticInternalAttributes.TASK_EXPORT_NAME]: this.ctx.task.exportName, [SemanticInternalAttributes.QUEUE_NAME]: this.ctx.queue.name, [SemanticInternalAttributes.QUEUE_ID]: this.ctx.queue.id, [SemanticInternalAttributes.RUN_ID]: this.ctx.run.id, diff --git a/packages/core/src/v3/usage/api.ts b/packages/core/src/v3/usage/api.ts index e08c76929d8..c3a05fc9f3a 100644 --- a/packages/core/src/v3/usage/api.ts +++ b/packages/core/src/v3/usage/api.ts @@ -1,7 +1,7 @@ const API_NAME = "usage"; import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; -import type { UsageManager, UsageMeasurement, UsageSample } from "./types.js"; +import type { InitialUsageState, UsageManager, UsageMeasurement, UsageSample } from "./types.js"; import { NoopUsageManager } from "./noopUsageManager.js"; const NOOP_USAGE_MANAGER = new NoopUsageManager(); @@ -53,6 +53,10 @@ export class UsageAPI implements UsageManager { this.disable(); } + public getInitialState(): InitialUsageState { + return this.#getUsageManager().getInitialState(); + } + #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 80e1fdde213..510213b2602 100644 --- a/packages/core/src/v3/usage/devUsageManager.ts +++ b/packages/core/src/v3/usage/devUsageManager.ts @@ -1,4 +1,4 @@ -import { UsageManager, UsageMeasurement, UsageSample } from "./types.js"; +import { InitialUsageState, UsageManager, UsageMeasurement, UsageSample } from "./types.js"; import { clock } from "../clock-api.js"; import { ClockTime, calculateDurationInMs } from "../clock/clock.js"; @@ -45,6 +45,10 @@ export class DevUsageManager implements UsageManager { private _firstMeasurement?: DevUsageMeasurement; private _currentMeasurements: Map = new Map(); private _pauses: Map = new Map(); + private _initialState: InitialUsageState = { + cpuTime: 0, + costInCents: 0, + }; disable(): void {} @@ -54,6 +58,18 @@ export class DevUsageManager implements UsageManager { this._firstMeasurement = undefined; this._currentMeasurements.clear(); this._pauses.clear(); + this._initialState = { + cpuTime: 0, + costInCents: 0, + }; + } + + setInitialState(state: InitialUsageState) { + this._initialState = state; + } + + getInitialState(): InitialUsageState { + return this._initialState; } sample(): UsageSample | undefined { diff --git a/packages/core/src/v3/usage/noopUsageManager.ts b/packages/core/src/v3/usage/noopUsageManager.ts index 9e521444668..d044f1738d8 100644 --- a/packages/core/src/v3/usage/noopUsageManager.ts +++ b/packages/core/src/v3/usage/noopUsageManager.ts @@ -1,4 +1,4 @@ -import { UsageManager, UsageMeasurement, UsageSample } from "./types.js"; +import { InitialUsageState, UsageManager, UsageMeasurement, UsageSample } from "./types.js"; export class NoopUsageManager implements UsageManager { disable(): void { @@ -30,4 +30,11 @@ export class NoopUsageManager implements UsageManager { reset(): void { // Noop } + + getInitialState(): InitialUsageState { + return { + cpuTime: 0, + costInCents: 0, + }; + } } diff --git a/packages/core/src/v3/usage/prodUsageManager.ts b/packages/core/src/v3/usage/prodUsageManager.ts index 5d3d49c3d07..7cfd038d019 100644 --- a/packages/core/src/v3/usage/prodUsageManager.ts +++ b/packages/core/src/v3/usage/prodUsageManager.ts @@ -1,5 +1,5 @@ import { setInterval } from "node:timers/promises"; -import { UsageManager, UsageMeasurement, UsageSample } from "./types.js"; +import { InitialUsageState, UsageManager, UsageMeasurement, UsageSample } from "./types.js"; import { UsageClient } from "./usageClient.js"; export type ProdUsageManagerOptions = { @@ -13,6 +13,10 @@ export class ProdUsageManager implements UsageManager { private _abortController: AbortController | undefined; private _lastSample: UsageSample | undefined; private _usageClient: UsageClient | undefined; + private _initialState: InitialUsageState = { + cpuTime: 0, + costInCents: 0, + }; constructor( private readonly delegageUsageManager: UsageManager, @@ -27,6 +31,14 @@ export class ProdUsageManager implements UsageManager { return typeof this._usageClient !== "undefined"; } + setInitialState(state: InitialUsageState) { + this._initialState = state; + } + + getInitialState(): InitialUsageState { + return this._initialState; + } + reset(): void { this.delegageUsageManager.reset(); this._abortController?.abort(); @@ -34,6 +46,10 @@ export class ProdUsageManager implements UsageManager { this._usageClient = undefined; this._measurement = undefined; this._lastSample = undefined; + this._initialState = { + cpuTime: 0, + costInCents: 0, + }; } disable(): void { diff --git a/packages/core/src/v3/usage/types.ts b/packages/core/src/v3/usage/types.ts index 8655950df31..45fd32e63b7 100644 --- a/packages/core/src/v3/usage/types.ts +++ b/packages/core/src/v3/usage/types.ts @@ -7,8 +7,14 @@ export interface UsageMeasurement { sample(): UsageSample; } +export type InitialUsageState = { + cpuTime: number; + costInCents: number; +}; + export interface UsageManager { disable(): void; + getInitialState(): InitialUsageState; start(): UsageMeasurement; stop(measurement: UsageMeasurement): UsageSample; sample(): UsageSample | undefined; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 5d5d896621b..487c16308e8 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -1313,7 +1313,6 @@ async function triggerAndWait_internal { const sample = usageApi.sample(); + const initialState = usageApi.getInitialState(); const machine = taskContext.ctx?.machine; const run = taskContext.ctx?.run; @@ -65,12 +66,12 @@ export const usage = { durationMs: 0, }, total: { - costInCents: run?.costInCents ?? 0, - durationMs: run?.durationMs ?? 0, + costInCents: initialState.costInCents, + durationMs: initialState.cpuTime, }, }, baseCostInCents: run?.baseCostInCents ?? 0, - totalCostInCents: (run?.costInCents ?? 0) + (run?.baseCostInCents ?? 0), + totalCostInCents: initialState.costInCents + (run?.baseCostInCents ?? 0), }; } @@ -83,12 +84,12 @@ export const usage = { durationMs: sample.cpuTime, }, total: { - costInCents: (run?.costInCents ?? 0) + currentCostInCents, - durationMs: (run?.durationMs ?? 0) + sample.cpuTime, + costInCents: currentCostInCents + initialState.costInCents, + durationMs: sample.cpuTime + initialState.cpuTime, }, }, baseCostInCents: run?.baseCostInCents ?? 0, - totalCostInCents: (run?.costInCents ?? 0) + currentCostInCents + (run?.baseCostInCents ?? 0), + totalCostInCents: currentCostInCents + (run?.baseCostInCents ?? 0) + initialState.costInCents, }; }, /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37ec88c236f..5ccc631488d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -921,6 +921,24 @@ importers: docs: {} + internal-packages/cache: + dependencies: + '@internal/redis': + specifier: workspace:* + version: link:../redis + '@trigger.dev/core': + specifier: workspace:* + version: link:../../packages/core + '@unkey/cache': + specifier: ^1.5.0 + version: 1.5.0 + '@unkey/error': + specifier: ^0.2.0 + version: 0.2.0 + superjson: + specifier: ^2.2.1 + version: 2.2.1 + internal-packages/clickhouse: dependencies: '@clickhouse/client': @@ -1054,6 +1072,9 @@ importers: internal-packages/run-engine: dependencies: + '@internal/cache': + specifier: workspace:* + version: link:../cache '@internal/redis': specifier: workspace:* version: link:../redis @@ -1069,9 +1090,6 @@ importers: '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker - '@unkey/cache': - specifier: ^1.5.0 - version: 1.5.0 assert-never: specifier: ^1.2.1 version: 1.2.1 diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 8759953d28e..1eb7f18916a 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -57,7 +57,7 @@ export const parentTask = task({ id: "parent", machine: "medium-1x", run: async (payload: any, { ctx }) => { - logger.log("Hello, world from the parent", { payload }); + logger.log("Hello, world from the parent", { payload, ctx }); await childTask.triggerAndWait({ message: "Hello, world!", aReallyBigInt: BigInt(10000) }); }, }); @@ -107,7 +107,7 @@ export const childTask = task({ }: { message?: string; failureChance?: number; duration?: number; aReallyBigInt?: bigint }, { ctx } ) => { - logger.info("Hello, world from the child", { message, failureChance, aReallyBigInt }); + logger.info("Hello, world from the child", { ctx, failureChance, aReallyBigInt }); if (Math.random() < failureChance) { throw new Error("Random error at start"); diff --git a/references/hello-world/src/trigger/metadata.ts b/references/hello-world/src/trigger/metadata.ts index d9618f42074..b0d493b8df2 100644 --- a/references/hello-world/src/trigger/metadata.ts +++ b/references/hello-world/src/trigger/metadata.ts @@ -44,6 +44,8 @@ export const parentTask = task({ metadata.parent.set("test.parent.set", true); metadata.set("test.set", "test"); + logger.info("logging metadata.current()", { current: metadata.current() }); + await childTask.triggerAndWait({}); return { diff --git a/references/hello-world/src/trigger/usage.ts b/references/hello-world/src/trigger/usage.ts new file mode 100644 index 00000000000..caacbde2acb --- /dev/null +++ b/references/hello-world/src/trigger/usage.ts @@ -0,0 +1,35 @@ +import { logger, task, wait, usage } from "@trigger.dev/sdk"; +import { setTimeout } from "timers/promises"; + +export const usageExampleTask = task({ + id: "usage-example", + retry: { + maxAttempts: 3, + minTimeoutInMs: 500, + maxTimeoutInMs: 1000, + factor: 1.5, + }, + run: async (payload: { throwError: boolean }, { ctx }) => { + logger.info("run.ctx", { ctx }); + + await setTimeout(1000); + + const currentUsage = usage.getCurrent(); + + logger.info("currentUsage", { currentUsage }); + + if (payload.throwError && ctx.attempt.number === 1) { + throw new Error("Forced error to cause a retry"); + } + + await setTimeout(5000); + + const currentUsage2 = usage.getCurrent(); + + logger.info("currentUsage2", { currentUsage2 }); + + return { + message: "Hello, world!", + }; + }, +}); From 8b3187199813c2b3b0cdeecca3f6589f77fe1c8f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 30 Jul 2025 13:48:02 +0100 Subject: [PATCH 025/641] Usage billing alerts (#2323) * First draft billing alerts page * Budget alert form working * Don't let free plan users change the billing alert amount * Fix missing key in map in the form * Disable queues/org from admin API endpoint * Don't allow resuming if runsEnabled is false * Refer to "Billing alerts" not "Plans" Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Form missing dependencies fix Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Deal with thrown errors, fix for duplicating email fields * Added a RuntimeEnvironment organizationId index --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../app/components/billing/UpgradePrompt.tsx | 4 +- .../OrganizationSettingsSideMenu.tsx | 51 +-- .../app/components/primitives/Input.tsx | 2 +- .../v3/EnvironmentQueuePresenter.server.ts | 15 + .../route.tsx | 2 +- .../route.tsx | 303 ++++++++++++++++++ .../route.tsx | 2 +- ...api.v1.orgs.$organizationId.runs.enable.ts | 97 ++++++ ...ces.orgs.$organizationSlug.select-plan.tsx | 2 +- .../webapp/app/services/platform.v3.server.ts | 29 +- apps/webapp/app/utils/pathBuilder.ts | 4 + .../v3/services/pauseEnvironment.server.ts | 19 ++ apps/webapp/package.json | 4 +- internal-packages/database/README.md | 2 +- .../migration.sql | 2 + .../database/prisma/schema.prisma | 1 + pnpm-lock.yaml | 8 +- 17 files changed, 512 insertions(+), 35 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx create mode 100644 apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts create mode 100644 internal-packages/database/prisma/migrations/20250729155415_runtime_environment_add_organization_id_index/migration.sql diff --git a/apps/webapp/app/components/billing/UpgradePrompt.tsx b/apps/webapp/app/components/billing/UpgradePrompt.tsx index e9b0fc1c97d..8a3e098ba42 100644 --- a/apps/webapp/app/components/billing/UpgradePrompt.tsx +++ b/apps/webapp/app/components/billing/UpgradePrompt.tsx @@ -30,8 +30,8 @@ export function UpgradePrompt() { You have exceeded the monthly $ - {(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. No runs - will execute in Prod until{" "} + {(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. Existing + runs will be queued and new runs won't be created until{" "} , or you upgrade.
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ad6543756a0..7303142f3de 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,4 +1,5 @@ import { + BellAlertIcon, ChartBarIcon, Cog8ToothIcon, CreditCardIcon, @@ -12,6 +13,7 @@ import { organizationSettingsPath, organizationTeamPath, rootPath, + v3BillingAlertsPath, v3BillingPath, v3UsagePath, } from "~/utils/pathBuilder"; @@ -67,27 +69,34 @@ export function OrganizationSettingsSideMenu({
{isManagedCloud && ( - - )} - {isManagedCloud && ( - {currentPlan?.v3Subscription?.plan?.title} - ) : undefined - } - /> + <> + + {currentPlan?.v3Subscription?.plan?.title} + ) : undefined + } + /> + + )} View runs - + {environment.runsEnabled ? : null}
} valueClassName={env.paused ? "text-warning" : undefined} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx new file mode 100644 index 00000000000..c4d3327be83 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx @@ -0,0 +1,303 @@ +import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, type MetaFunction } from "@remix-run/react"; +import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { featuresForRequest } from "~/features.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { formatCurrency } from "~/utils/numberFormatter"; +import { + OrganizationParamsSchema, + organizationPath, + v3BillingAlertsPath, +} from "~/utils/pathBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { tryCatch } from "@trigger.dev/core"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Billing alerts | Trigger.dev`, + }, + ]; +}; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const { isManagedCloud } = featuresForRequest(request); + if (!isManagedCloud) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const organization = await prisma.organization.findUnique({ + where: { slug: organizationSlug }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + const [error, alerts] = await tryCatch(getBillingAlerts(organization.id)); + if (error) { + throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` }); + } + + if (!alerts) { + throw new Response(null, { status: 404, statusText: "Billing alerts not found" }); + } + + return typedjson({ + alerts: { + ...alerts, + amount: alerts.amount / 100, + }, + }); +} + +const schema = z.object({ + amount: z + .number({ invalid_type_error: "Not a valid amount" }) + .min(0, "Amount must be greater than 0"), + emails: z.preprocess((i) => { + if (typeof i === "string") return [i]; + + if (Array.isArray(i)) { + const emails = i.filter((v) => typeof v === "string" && v !== ""); + if (emails.length === 0) { + return [""]; + } + return emails; + } + + return [""]; + }, z.string().email().array().nonempty("At least one email is required")), + alertLevels: z.preprocess((i) => { + if (typeof i === "string") return [i]; + return i; + }, z.coerce.number().array().nonempty("At least one alert level is required")), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + try { + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3BillingAlertsPath({ slug: organizationSlug }), + request, + "You are not authorized to update billing alerts" + ); + } + + const [error, updatedAlert] = await tryCatch( + setBillingAlert(organization.id, { + ...submission.value, + amount: submission.value.amount * 100, + }) + ); + if (error) { + return redirectWithErrorMessage( + v3BillingAlertsPath({ slug: organizationSlug }), + request, + "Failed to update billing alert" + ); + } + + if (!updatedAlert) { + return redirectWithErrorMessage( + v3BillingAlertsPath({ slug: organizationSlug }), + request, + "Failed to update billing alert" + ); + } + + return redirectWithSuccessMessage( + v3BillingAlertsPath({ slug: organizationSlug }), + request, + "Billing alert updated" + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } +}; + +export default function Page() { + const { alerts } = useTypedLoaderData(); + const plan = useCurrentPlan(); + const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2)); + + const lastSubmission = useActionData(); + + const [form, { emails, amount, alertLevels }] = useForm({ + id: "invite-members", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + defaultValue: { + emails: [""], + }, + }); + + const fieldValues = useRef(alerts.emails); + const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails }); + + const checkboxLevels = [0.75, 0.9, 1.0]; + + useEffect(() => { + if (alerts.emails.length > 0) { + requestIntent(form.ref.current ?? undefined, list.append(emails.name)); + } + }, [emails.name, form.ref]); + const isFree = !plan?.v3Subscription?.isPaying; + + return ( + + + + + + + + + +
+ Billing alerts + + Receive an email when your compute spend crosses different thresholds. + +
+
+ + + {isFree ? ( + <> + + ${dollarAmount} + + + + ) : ( + { + const numberValue = Number(e.target.value); + if (numberValue < 0) { + setDollarAmount(""); + return; + } + setDollarAmount(e.target.value); + }} + step={0.01} + min={0} + placeholder="Enter an amount" + icon={ + $ + } + className="pl-px" + fullWidth + readOnly={isFree} + /> + )} + {amount.error} + + + + {checkboxLevels.map((level) => ( + + {level * 100}%{" "} + + ({formatCurrency(Number(dollarAmount) * level, false)}) + + + } + defaultChecked={alerts.alertLevels.includes(level)} + className="pr-0" + readOnly={level === 1.0} + /> + ))} + {alertLevels.error} + + + + {emailFields.map((email, index) => ( + + { + fieldValues.current[index] = e.target.value; + if ( + emailFields.length === fieldValues.current.length && + fieldValues.current.every((v) => v !== "") + ) { + requestIntent(form.ref.current ?? undefined, list.append(emails.name)); + } + }} + fullWidth + /> + {email.error} + + ))} + + + Update + + } + /> +
+ +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx index f42c77ad50b..0dbffffc4d6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx @@ -1,6 +1,6 @@ import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { type PlanDefinition } from "@trigger.dev/platform/v3"; +import { type PlanDefinition } from "@trigger.dev/platform"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts new file mode 100644 index 00000000000..6b1cf2d9939 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.runs.enable.ts @@ -0,0 +1,97 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { + type RuntimeEnvironment, + type Organization, + type Project, + type RuntimeEnvironmentType, +} from "@trigger.dev/database"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { createEnvironment } from "~/models/organization.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; +import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; + +const ParamsSchema = z.object({ + organizationId: z.string(), +}); + +const BodySchema = z.object({ + enable: z.boolean(), +}); + +/** + * It will enabled/disable runs + */ +export async function action({ request, params }: ActionFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { + id: authenticationResult.userId, + }, + }); + + if (!user) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!user.admin) { + return json({ error: "You must be an admin to perform this action" }, { status: 403 }); + } + + const { organizationId } = ParamsSchema.parse(params); + const body = BodySchema.safeParse(await request.json()); + if (!body.success) { + return json({ error: "Invalid request body", details: body.error }, { status: 400 }); + } + + const organization = await prisma.organization.update({ + where: { + id: organizationId, + }, + data: { + runsEnabled: body.data.enable, + }, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const environments = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId, + type: { + not: "DEVELOPMENT", + }, + }, + include: { + organization: true, + project: true, + }, + }); + + const pauseEnvironmentService = new PauseEnvironmentService(); + + // Set the organization.runsEnabled flag to false + for (const environment of environments) { + if (body.data.enable) { + await pauseEnvironmentService.call({ ...environment, organization }, "resumed"); + } else { + await pauseEnvironmentService.call({ ...environment, organization }, "paused"); + } + } + + return json({ + success: true, + message: `${environments.length} environments updated to ${ + body.data.enable ? "enabled" : "disabled" + }`, + }); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 4871a2d0b74..90095f342cd 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -17,7 +17,7 @@ import { Plans, SetPlanBody, SubscriptionResult, -} from "@trigger.dev/platform/v3"; +} from "@trigger.dev/platform"; import React, { useEffect, useState } from "react"; import { z } from "zod"; import { DefinitionTip } from "~/components/DefinitionTooltip"; diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 40d0c87bf48..138fa287dc8 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -8,7 +8,9 @@ import { defaultMachine as defaultMachineFromPlatform, machines as machinesFromPlatform, type MachineCode, -} from "@trigger.dev/platform/v3"; + type UpdateBillingAlertsRequest, + type BillingAlertsResult, +} from "@trigger.dev/platform"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { MemoryStore } from "@unkey/cache/stores"; import { redirect } from "remix-typedjson"; @@ -467,6 +469,31 @@ export async function projectCreated(organization: Organization, project: Projec } } +export async function getBillingAlerts( + organizationId: string +): Promise { + if (!client) return undefined; + const result = await client.getBillingAlerts(organizationId); + if (!result.success) { + logger.error("Error getting billing alert", { error: result.error, organizationId }); + throw new Error("Error getting billing alert"); + } + return result; +} + +export async function setBillingAlert( + organizationId: string, + alert: UpdateBillingAlertsRequest +): Promise { + if (!client) return undefined; + const result = await client.updateBillingAlerts(organizationId, alert); + if (!result.success) { + logger.error("Error setting billing alert", { error: result.error, organizationId }); + throw new Error("Error setting billing alert"); + } + return result; +} + function isCloud(): boolean { const acceptableHosts = [ "https://cloud.trigger.dev", diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 93610360bc9..cfb77f3437d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -459,6 +459,10 @@ export function v3BillingPath(organization: OrgForPath, message?: string) { }`; } +export function v3BillingAlertsPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/billing-alerts`; +} + export function v3StripePortalPath(organization: OrgForPath) { return `/resources/${organization.slug}/subscription/portal`; } diff --git a/apps/webapp/app/v3/services/pauseEnvironment.server.ts b/apps/webapp/app/v3/services/pauseEnvironment.server.ts index de0216989d4..99e588ca7df 100644 --- a/apps/webapp/app/v3/services/pauseEnvironment.server.ts +++ b/apps/webapp/app/v3/services/pauseEnvironment.server.ts @@ -27,6 +27,25 @@ export class PauseEnvironmentService extends WithRunEngine { action: PauseStatus ): Promise { try { + const org = await this._prisma.organization.findFirst({ + where: { + id: environment.organizationId, + }, + select: { + runsEnabled: true, + }, + }); + + if (!org) { + throw new Error("Organization not found"); + } + + if (!org.runsEnabled && action === "resumed") { + throw new Error( + "Runs are disabled for this organization. Your free plan has probably been exceeded. If not please contact support." + ); + } + await this._prisma.runtimeEnvironment.update({ where: { id: environment.id, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 8c9f4996269..80b7c9614ba 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -113,7 +113,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.15", + "@trigger.dev/platform": "1.0.17", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", @@ -278,4 +278,4 @@ "engines": { "node": ">=16.0.0" } -} \ No newline at end of file +} diff --git a/internal-packages/database/README.md b/internal-packages/database/README.md index 8dcb9c2dacd..62260d17982 100644 --- a/internal-packages/database/README.md +++ b/internal-packages/database/README.md @@ -23,7 +23,7 @@ pnpm run docker ### How to add a new index on a large table 1. Modify the Prisma.schema with a single index change (no other changes, just one index at a time) -2. Create a Prisma migration using `cd internal-packages/database && pnpm run db:migrate:dev --create-only` +2. Create a Prisma migration using `cd internal-packages/database && pnpm run db:migrate:dev:create` 3. Modify the SQL file: add IF NOT EXISTS to it and CONCURRENTLY: ```sql diff --git a/internal-packages/database/prisma/migrations/20250729155415_runtime_environment_add_organization_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250729155415_runtime_environment_add_organization_id_index/migration.sql new file mode 100644 index 00000000000..aed11ac9ee2 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250729155415_runtime_environment_add_organization_id_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_organizationId_idx" ON "RuntimeEnvironment" ("organizationId"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 58e1b11003b..211ff2b355f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -300,6 +300,7 @@ model RuntimeEnvironment { @@unique([projectId, shortcode]) @@index([parentEnvironmentId]) @@index([projectId]) + @@index([organizationId]) } enum RuntimeEnvironmentType { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ccc631488d..258ebd571ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,8 +441,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.15 - version: 1.0.15 + specifier: 1.0.17 + version: 1.0.17 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -19571,8 +19571,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.15: - resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==} + /@trigger.dev/platform@1.0.17: + resolution: {integrity: sha512-cR05nn8HnP03h/bmRN6O/EKgvQncbs3Y/7fp1QboEDWn6rJTRrWJpZVrA3ZQ32SIW1qvHuZLcB1OVaEsJk2wjA==} dependencies: zod: 3.23.8 dev: false From 748565c9dbe14ee97285e11f4d1076778f3007be Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 31 Jul 2025 11:14:46 +0100 Subject: [PATCH 026/641] Fix backwards compatible execution by allowing additional properties to passthrough (#2327) --- packages/core/src/v3/schemas/common.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index c80d6a8ce75..2928995606b 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -311,7 +311,8 @@ export const TaskRunExecutionDeployment = z.object({ export type TaskRunExecutionDeployment = z.infer; const StaticTaskRunExecutionShape = { - task: TaskRunExecutionTask, + // Passthrough needed for backwards compatibility + task: TaskRunExecutionTask.passthrough(), queue: TaskRunExecutionQueue, environment: TaskRunExecutionEnvironment, organization: TaskRunExecutionOrganization, @@ -326,7 +327,8 @@ export const StaticTaskRunExecution = z.object(StaticTaskRunExecutionShape); export type StaticTaskRunExecution = z.infer; export const TaskRunExecution = z.object({ - attempt: TaskRunExecutionAttempt, + // Passthrough needed for backwards compatibility + attempt: TaskRunExecutionAttempt.passthrough(), run: TaskRun.and( z.object({ traceContext: z.record(z.unknown()).optional(), From 59bfce86dbf674937a67fcc39f7560f585b34a75 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 31 Jul 2025 12:07:42 +0100 Subject: [PATCH 027/641] Docs: Fixes incorrect concurrency pricing on the limits page (#2324) --- docs/limits.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/limits.mdx b/docs/limits.mdx index 51e0dddcc53..bcb29f54a9d 100644 --- a/docs/limits.mdx +++ b/docs/limits.mdx @@ -13,7 +13,7 @@ import RateLimitHitUseBatchTrigger from "/snippets/rate-limit-hit-use-batchtrigg | Hobby | 25 concurrent runs | | Pro | 100+ concurrent runs | -Additional bundles above the Pro tier are available for $10/month per 100 concurrent runs. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more. +Additional bundles above the Pro tier are available for $50/month per 50 concurrent runs. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more. ## Rate limits @@ -69,7 +69,7 @@ Additional bundles above the Pro tier are available for $10/month per preview br | Hobby | 50 concurrent connections | | Pro | 500+ concurrent connections | -Additional bundles are available for $10/month per 100 realtime connections. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more. +Additional bundles are available for $10/month per 100 concurrent connections. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more. ## Task payloads and outputs From b153324cd95177bea269809364a7b1fc6dd130ae Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 31 Jul 2025 13:22:02 +0100 Subject: [PATCH 028/641] fix: importing from runEngine/index.js breaks non-node runtimes (#2328) --- .changeset/big-garlics-own.md | 5 +++++ packages/core/src/v3/schemas/messages.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/big-garlics-own.md diff --git a/.changeset/big-garlics-own.md b/.changeset/big-garlics-own.md new file mode 100644 index 00000000000..3df6d66f742 --- /dev/null +++ b/.changeset/big-garlics-own.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +fix: importing from runEngine/index.js breaks non-node runtimes diff --git a/packages/core/src/v3/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index 72a78deb3ca..ebe3dc39b3c 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -19,7 +19,7 @@ import { WaitReason, } from "./schemas.js"; import { CompletedWaitpoint } from "./runEngine.js"; -import { DebugLogPropertiesInput } from "../runEngineWorker/index.js"; +import { DebugLogPropertiesInput } from "../runEngineWorker/supervisor/schemas.js"; export const AckCallbackResult = z.discriminatedUnion("success", [ z.object({ From 134942b2dd6a2675a3c0a149eb43816538f319f0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 31 Jul 2025 14:12:31 +0100 Subject: [PATCH 029/641] The schedule HTTP API now accepts deduplicationKeys as well as schedule IDs in the URL (#2329) --- apps/webapp/app/models/schedules.server.ts | 37 +++++++++++++++++++ .../v3/ViewSchedulePresenter.server.ts | 6 +-- .../api.v1.schedules.$scheduleId.activate.ts | 18 ++++----- ...api.v1.schedules.$scheduleId.deactivate.ts | 17 +++++---- .../routes/api.v1.schedules.$scheduleId.ts | 9 +++-- .../v3/services/upsertTaskSchedule.server.ts | 5 +-- 6 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 apps/webapp/app/models/schedules.server.ts diff --git a/apps/webapp/app/models/schedules.server.ts b/apps/webapp/app/models/schedules.server.ts new file mode 100644 index 00000000000..58e4d9a870c --- /dev/null +++ b/apps/webapp/app/models/schedules.server.ts @@ -0,0 +1,37 @@ +import { Prisma } from "~/db.server"; + +export function scheduleUniqWhereClause( + projectId: string, + scheduleId: string +): Prisma.TaskScheduleWhereUniqueInput { + if (scheduleId.startsWith("sched_")) { + return { + friendlyId: scheduleId, + projectId, + }; + } + + return { + projectId_deduplicationKey: { + projectId, + deduplicationKey: scheduleId, + }, + }; +} + +export function scheduleWhereClause( + projectId: string, + scheduleId: string +): Prisma.TaskScheduleWhereInput { + if (scheduleId.startsWith("sched_")) { + return { + friendlyId: scheduleId, + projectId, + }; + } + + return { + projectId, + deduplicationKey: scheduleId, + }; +} diff --git a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts index 3bc1a2b457a..f0e955fd04d 100644 --- a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts @@ -4,6 +4,7 @@ import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server"; import { NextRunListPresenter } from "./NextRunListPresenter.server"; +import { scheduleWhereClause } from "~/models/schedules.server"; type ViewScheduleOptions = { userId?: string; @@ -63,10 +64,7 @@ export class ViewSchedulePresenter { }, active: true, }, - where: { - friendlyId, - projectId, - }, + where: scheduleWhereClause(projectId, friendlyId), }); if (!schedule) { diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts index 59a82e6a776..7eb281c0520 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts @@ -1,8 +1,8 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; -import { truncateSync } from "fs"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; @@ -34,10 +34,10 @@ export async function action({ request, params }: ActionFunctionArgs) { try { const existingSchedule = await prisma.taskSchedule.findFirst({ - where: { - friendlyId: parsedParams.data.scheduleId, - projectId: authenticationResult.environment.projectId, - }, + where: scheduleWhereClause( + authenticationResult.environment.projectId, + parsedParams.data.scheduleId + ), }); if (!existingSchedule) { @@ -45,10 +45,10 @@ export async function action({ request, params }: ActionFunctionArgs) { } await prisma.taskSchedule.update({ - where: { - friendlyId: parsedParams.data.scheduleId, - projectId: authenticationResult.environment.projectId, - }, + where: scheduleUniqWhereClause( + authenticationResult.environment.projectId, + parsedParams.data.scheduleId + ), data: { active: true, }, diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts index 7498160a79a..e9b2997116f 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; @@ -33,10 +34,10 @@ export async function action({ request, params }: ActionFunctionArgs) { try { const existingSchedule = await prisma.taskSchedule.findFirst({ - where: { - friendlyId: parsedParams.data.scheduleId, - projectId: authenticationResult.environment.projectId, - }, + where: scheduleWhereClause( + authenticationResult.environment.projectId, + parsedParams.data.scheduleId + ), }); if (!existingSchedule) { @@ -44,10 +45,10 @@ export async function action({ request, params }: ActionFunctionArgs) { } await prisma.taskSchedule.update({ - where: { - friendlyId: parsedParams.data.scheduleId, - projectId: authenticationResult.environment.projectId, - }, + where: scheduleUniqWhereClause( + authenticationResult.environment.projectId, + parsedParams.data.scheduleId + ), data: { active: false, }, diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts index e994852537e..b9fc8e2caff 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts @@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime"; import { ScheduleObject, UpdateScheduleOptions } from "@trigger.dev/core/v3"; import { z } from "zod"; import { Prisma, prisma } from "~/db.server"; +import { scheduleUniqWhereClause } from "~/models/schedules.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; import { UpsertSchedule } from "~/v3/schedules"; @@ -36,10 +37,10 @@ export async function action({ request, params }: ActionFunctionArgs) { case "DELETE": { try { const deletedSchedule = await prisma.taskSchedule.delete({ - where: { - friendlyId: parsedParams.data.scheduleId, - projectId: authenticationResult.environment.projectId, - }, + where: scheduleUniqWhereClause( + authenticationResult.environment.projectId, + parsedParams.data.scheduleId + ), }); return json( diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts index 6d5fa7d4955..f7fa8f4d184 100644 --- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts +++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts @@ -7,6 +7,7 @@ import { calculateNextScheduledTimestampFromNow } from "../utils/calculateNextSc import { BaseService, ServiceValidationError } from "./baseService.server"; import { CheckScheduleService } from "./checkSchedule.server"; import { scheduleEngine } from "../scheduleEngine.server"; +import { scheduleWhereClause } from "~/models/schedules.server"; export type UpsertTaskScheduleServiceOptions = UpsertSchedule; @@ -37,9 +38,7 @@ export class UpsertTaskScheduleService extends BaseService { const existingSchedule = schedule.friendlyId ? await this._prisma.taskSchedule.findFirst({ - where: { - friendlyId: schedule.friendlyId, - }, + where: scheduleWhereClause(projectId, schedule.friendlyId), }) : await this._prisma.taskSchedule.findFirst({ where: { From 1feabd732e6feffaa6d083fc77a9d2c6780a91be Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 1 Aug 2025 09:20:10 +0100 Subject: [PATCH 030/641] Docs: Adds a Reduce your spend docs page (#2330) --- docs/docs.json | 1 + docs/how-to-reduce-your-spend.mdx | 169 ++++++++++++++++++++++++++++++ docs/images/billing-alerts-ui.png | Bin 0 -> 98179 bytes docs/images/usage-dashboard.png | Bin 0 -> 287412 bytes 4 files changed, 170 insertions(+) create mode 100644 docs/how-to-reduce-your-spend.mdx create mode 100644 docs/images/billing-alerts-ui.png create mode 100644 docs/images/usage-dashboard.png diff --git a/docs/docs.json b/docs/docs.json index 6f091138878..2d99921a960 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -167,6 +167,7 @@ "group": "Troubleshooting", "pages": [ "troubleshooting", + "how-to-reduce-your-spend", "troubleshooting-debugging-in-vscode", "upgrading-packages", "troubleshooting-uptime-status", diff --git a/docs/how-to-reduce-your-spend.mdx b/docs/how-to-reduce-your-spend.mdx new file mode 100644 index 00000000000..53bac280491 --- /dev/null +++ b/docs/how-to-reduce-your-spend.mdx @@ -0,0 +1,169 @@ +--- +title: "How to reduce your spend" +description: "Tips and best practices to reduce your costs on Trigger.dev" +--- + +## Check out your usage page regularly + +Monitor your usage dashboard to understand your spending patterns. You can see: +- Your most expensive tasks +- Your total duration by task +- Number of runs by task +- Spikes in your daily usage + +![Usage dashboard](./images/usage-dashboard.png) + +You can view your usage page by clicking the "Organization" menu in the top left of the dashboard and then clicking "Usage". + +## Create billing alerts + +Configure billing alerts in your dashboard to get notified when you approach spending thresholds. This helps you: +- Catch unexpected cost increases early +- Identify runaway tasks before they become expensive + +![Billing alerts](./images/billing-alerts-ui.png) + +You can view your billing alerts page by clicking the "Organization" menu in the top left of the dashboard and then clicking "Settings". + +## Reduce your machine sizes + +The larger the machine, the more it costs per second. [View the machine pricing](https://trigger.dev/pricing#computePricing). + +Start with the smallest machine that works, then scale up only if needed: + +```ts +// Default: small-1x (0.5 vCPU, 0.5 GB RAM) +export const lightTask = task({ + id: "light-task", + // No machine config needed - uses small-1x by default + run: async (payload) => { + // Simple operations + }, +}); + +// Only use larger machines when necessary +export const heavyTask = task({ + id: "heavy-task", + machine: "medium-1x", // 1 vCPU, 2 GB RAM + run: async (payload) => { + // CPU/memory intensive operations + }, +}); +``` + +You can also override machine size when triggering if you know certain payloads need more resources. [Read more about machine sizes](/machines). + +## Avoid duplicate work using idempotencyKey + +Idempotency keys prevent expensive duplicate work by ensuring the same operation isn't performed multiple times. This is especially valuable during task retries or when the same trigger might fire multiple times. + +When you use an idempotency key, Trigger.dev remembers the result and skips re-execution, saving you compute costs: + +```ts +export const expensiveApiCall = task({ + id: "expensive-api-call", + run: async (payload: { userId: string }) => { + // This expensive operation will only run once per user + await wait.for({ seconds: 30 }, { + idempotencyKey: `user-processing-${payload.userId}`, + idempotencyKeyTTL: "1h" + }); + + const result = await processUserData(payload.userId); + return result; + }, +}); +``` + +You can use idempotency keys with various wait functions: + +```ts +// Skip waits during retries +const token = await wait.createToken({ + idempotencyKey: `daily-report-${new Date().toDateString()}`, + idempotencyKeyTTL: "24h", +}); + +// Prevent duplicate child task execution +await childTask.triggerAndWait( + { data: payload }, + { + idempotencyKey: `process-${payload.id}`, + idempotencyKeyTTL: "1h", + } +); +``` + +The `idempotencyKeyTTL` controls how long the result is cached. Use shorter TTLs (like "1h") for time-sensitive operations, or longer ones (up to 30 days default) for expensive operations that rarely need re-execution. This prevents both unnecessary duplicate work and stale data issues. + +## Do more work in parallel in a single task + +Sometimes it's more efficient to do more work in a single task than split across many. This is particularly true when you're doing lots of async work such as API calls – most of the time is spent waiting, so it's an ideal candidate for doing calls in parallel inside the same task. + +```ts +export const processItems = task({ + id: "process-items", + run: async (payload: { items: string[] }) => { + // Process all items in one run + for (const item of payload.items) { + // Do async work in parallel + // This works very well for API calls + await processItem(item); + } + }, +}); +``` + +## Don't needlessly retry + +When an error is thrown in a task, your run will be automatically reattempted based on your [retry settings](/tasks/overview#retry-options). + +Try setting lower `maxAttempts` for less critical tasks: + +```ts +export const apiTask = task({ + id: "api-task", + retry: { + maxAttempts: 2, // Don't retry forever + }, + run: async (payload) => { + // API calls that might fail + }, +}); +``` + +This is very useful for intermittent errors, but if there's a permanent error you don't want to retry because you will just keep failing and waste compute. Use [AbortTaskRunError](/errors-retrying#using-aborttaskrunerror) to prevent a retry: + +```ts +import { task, AbortTaskRunError } from "@trigger.dev/sdk/v3"; + +export const someTask = task({ + id: "some-task", + run: async (payload) => { + const result = await doSomething(payload); + + if (!result.success) { + // This is a known permanent error, so don't retry + throw new AbortTaskRunError(result.error); + } + + return result + }, +}); +``` + + + +## Use appropriate maxDuration settings + +Set realistic maxDurations to prevent runs from executing for too long: + +```ts +export const boundedTask = task({ + id: "bounded-task", + maxDuration: 300, // 5 minutes max + run: async (payload) => { + // Task will be terminated after 5 minutes + }, +}); +``` diff --git a/docs/images/billing-alerts-ui.png b/docs/images/billing-alerts-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..54c97fcdb755cec54cdb9781a819f3112420a91a GIT binary patch literal 98179 zcmYIv1yEaS*L8)K0;NzW7N9s3FU6riksyWOPJ$LExN9ly?pEA_7YSONq6I<%1SwG5 z-Qn;1-P`+TCduT?b7t?o_FB(g=OhVJR+PcT24FvU@Bmj%R!a53gU4A99y~gI_VmvY zshiI~{=8w?%j!5icz{FrzvsgT>6xT|4nA~Jm63Q*IZCnl=L3ehI7s}#gPIte8>1%= z9{Q2WNr|hwKis*+`bOECwjSY|wr=r(kRaV03q<`v;t^Nq4^ni~!RH!C->QG-)Olrd zRmdq)uc~}e*KK~UxxA%pu?+&5U)`H(o0~Fy`?|A_G=PgQfUNhq#A68pg7kHd*Q29N zm-og419M(EP7Tq4L8JFfeCGQUbblTzREs1A^!M9#N&vY?(jQU^EIP?@dR{DY@%KE^ zVwYM^7AJYwlh?@^T)S`Z!+yQFh%&j@J!tt>@fiJp?c=dh{QYojFg?+2Le+P!cL|oW zCynx9BCi{mCmyL%;N8d4Q($byxr&gyD8K%6c_uo-A>z;+3CgrhFwEd_LB(#4yLh>Y z$`h>0w;TB0G&Jzdzgj;yWS3$wExZLJ7tx4Nw3pN6hiGS^*Soi?+vXA`pRCp2u<)V{&H;no7> zi%GZ6t?Aq;ta~VP^Ri3I7CDgEsLds(^uW>&&pgghY`xr-{UE4Lmp*haJLA2b&y4Kt zyF^@Y@-(J$&dat{#`hhC*EXsxqYR_;XTHgUUu$}cWXru8SWOGb7(GP?O-lsq>uzry z38$zEP1XhDua1@n_3L(5nrp7WT8&C`z>I=J&$e~rW6{yadRr`}Z3)(8MGyN=&Z%MC zdnsOwk;%*^lN*zX(~beawB#%ch%op9#=|JD`)XbIZlS3_y|Q!9@cd-IvWFPkKue0r(sQ`r z$krl#eg5mX7fe8tSoP;-TLro*v#81U%(S-d+Xk{}(Sl?q{3Dv?kgtg<7h;S`oFxnM zsX=Asr&o>+Dw?D85SyncllUuIa0T~l0hn=nlzsIbiYTngE%KYfyh{=2#qt7Y*fq&u zZNp1)vBya$#fz*&iB+8d(XTpD&g~PktsVDVm}f`|88<6Y>U}#$!m(Hx8k2JkFf*66%re{&Q|@g zE89sg-h*sd#h43oxze0-q)qGhvND_}UWD$O2JB}s@FpgGOW;#*7bD>*yRKz~zDOZO zDLR<4BIeJsb_p%dYfc~C&9+wlhL?9tGCQ&pwSdQ&4>d@|&zN7*Bq;XOt9=F9Wb5>q!&wmGK>=e#YG*DO1s1DYLRT zuUtU&rLL((HSx?F!d@4ZH;k1!{Ao4V>JG2}G^p40byKjNaS+rj{Ke;C5!d#Bhn!>v z-^u4WMf{9$zH+szvW(gdDVCkaPU5JnbQ6CfqND|Oa<}7~cmP;z!6q531%3R6w{3@l zzX59LwrYumKWV;hvQ!q9j@&^oB( zvDK<;Ga*hI;j{M5En&lO>_w!`U9-&sB4tzc@rVod zZ^!4c@S$ZWi$|QJ8l&e!dX*rEPrwzEURT-EF`G9-j4Xbu$Ldb>VU@WjB4sA6_ROPk z!B?ttbpU{DucX#{wLK}h)$nf$rfKD8;qRlKn8WEhH6E7UZfE$&IkSgZtE36wxp3ci zIbX&TJ?cq$9QPFW{LTL0_RytWBx1hqXEV1d>h1VZa*s zQa8N6dwN}El5^rK^+yDdCaQ#lK)P4W65g`VEZx$*a0CacI6Znir;m14+QzRHW&zZY(A7(LP$D+W;}7H)Vb zb=?C9>#-EnIa<8#)5GNHigdVkj@*H5tI?~A#qIiMtCO#womT0DTK#-IyPR>MI78)G zSSk=r(O~B6&W%f4kBzMrdfSYP+p>P%mR^ZQQcZO$vVsE9h2=dvaY!G<)SG|#322+ z(8M{8xiT`WC8)uvNgqGM-oPhBoEEoSL5);qXNep|zI#?^NkxfX(6<{uRojLA`^pfY~ihbt^&<8VZh0mLOIQeGgi z2p=q%UIp4YU&u`y2$t>zd_zW( z7`8t;MZdCS)3}g}Gu!T@t~`tj!fkb-^K_fNFC58Py25VpT#5fVjXTy@-tK){?n74O zk>9+R$wwz4qvS48ZxmPZKuOhrucyjoJvQFHCp5q>bdk>I#FMjnbJaDU+M1n36GDIIU*zSsDYg@)4K6P zAt3pToCRxx0)yX{CX-y@t~MxND$8@PEmi&b1IDucd}M&S(94^e$oZdbPAv{9m%@Vb zvl3ptvfTxmRWR?4dDPGB-JV&}%CEIiKKE$5(`!%b^eU&7lMGesyoCv!j$zMT=ekIq z=3v|8Q(LGOJSJ+-I)5d&wK_`dnoZiYpgnqTSz_f~xLDflE_%y1p)Z8Fr`WPu+(sp; zs!GI(X#!Z<)~RC#ek|Y;#bgaRx}K7V(X@iR#K@}|;X^GUOv6;lznVmaD(-XHjbWSV-%yiVrn2Ji&BZq|j>{jx! zfp}JP&m`D_KWgU6-w1$5C><;)kISCS2PvMSJy?t|#mI_2=_C)dC zhaaFSkmm(WZnd@IWWm#tyAeR?z2I`9M-DBOI3VQTlyQd)gwBT$mt*DcU< zKJ_D}aa`4@1drz^cx7ZU_P{svj3kB0A*3`+=vpX$@*4S7A;i3U)!)E2&%DALAL~m; ziEnkPywgg+VIs>?=P%di<_w)R8nsO8N9(dIX+KM%57EKzXir@f zks2$?LSs47r+(nd+x@GExGtjRnYL1TxF^^vwP3rs(0R6Y0EX?K9r`o)QXS4GVq>2V zT2(J;rVMe*6ZV_<`nps}4La#P|x>8i!+pcHY`Y) zYl#x=ZD9RCE#`w|($$J*%J{D!dPqu@+Q1CQ0rZKQK2q*A(j^+rxp%v_McZP;bpl#O zptpIZ`Xn44t&b<`;-+dG7iK=bd%JYIJ87CX0-9l=TnNK@`d+9J57kK3Rt+QS(_4s* zRZytTcIRiz*Z}h&ZEEZ#e>#era{`iVGU}v=qx7$9qpF@iV&V$I5of%6!jZeQl5SC$ z($FlpJXhKxpqEbHAoCd=WN|^nbyHm zZ=NoaXwGv)Rb5(BLHnUCq^FiT_RE8^sT(87$DIxG5W0yRgZPQ4{>3h$i4+FHKK7v7 zn<3gmmg>1w?82x0L*dx!H-dG;8q_+ZP5{7!_TH)9+=WZtqCr)2r5Uf*i@a>lLrzXR z2BpkZRTeyXf(bL6AS@1+2LFA}&#f6XTqm};-a5h>hj2soT3su~Y|l3$df)m}CY5U;{6`<~-%0;Noj&{c}F68|e;Mh~El-y5HL1 zpUV*~o>1fQYy@vyMDzPQzv3pG(=Q9xJg-o?JUp$btjT0p$i1Ep_>K5(f8a`swNm(d z_GU=J{~gm+;}~L zQGh6%>P$Mm_QJ3FLC z+9~A8>WC++SEA|+6En9JE%K|kEsG^xT z2rEBx@O~m62pgw@y@M7))GTdUIRhk4F=gQ>ALm=@laD}j;FQT7?wd|lwEXmh=!gbl zNz`!AjMt-4QB{Q7V;MbDFdj~^-7C>ElWVYvh{Gd}lV{}nDPdgk#!AVIH1+{*rBJ7U z)%wry6qT)c!rmCJZ&3pcKl4}gT+b-l9biL$IPQsD9gAUy!5?9Y`@87oUeqx3wG6-q zMGO}FMIa_RRXk3(z#C1ik0b^C9o=oRqcDvpML+57GRsUyM9@~aFXZ@XI=oG2acH?G zJ6mhYf#6^qsAlM36Ip6taupGb<$)Dn)$CMT9Wih8e5w{WCR{%QGd(&TO49I@MzDFtdOt~e`Y<6VTEKHf1U5Ep4}n*xv5>zI9i|Dg6qnZT3K>)C-qp{XFx9+K=y9_`H+S)a z<0l-TE3+jZxCtV(KJ%WB@No$Cvng;w)dxW^ zwutOs_L<9qhm^^H@SDR5;dRYI9b6(bHGOS2mBL(_}w%y;J6da`oNBL1m-V0nGS^~3^N&J3NKs&V=Ag1rUC)8tlZbc&EkH#zrgaXg*_=I2GuZ@5w{f}ggM z6OVqbI+^RJFODmZ7B^dm?}zziF{~EZsnI0K8ojaKl>ca%qN>7`G$+?3+7OMm3hO*| zL19}ho5)3I@c8Ww(vAlnW}bzfwHGFxK?(Gga1E^6OP9|Y*Q@t2d~WHNpECg4 zXKIb}gxI&h309tS4&2Ob1_YDz;wsHHSqt9z6W3Dd2=T*adX4;`Ltvw-*wk+Rqk@~c zU>Dmok!UDM3$Fa?LZ}@-Z}Z$wdz?-@kWW{B1w`CYMv29gL)5L+&qc>;B3Bb<4iVFL-bF}&B)f8A# z;P_$4J8MeBZ$q(XW9eAp{OX1)cdkvlPPwQ(aFk@{06ot)NuzN65^3*W6wDYAPBF*0 zwp2~bJcFu>ALM~^A56iXHbEe|#>RMiH_M}_ZS4Zf59;gXQO>56$O(N!?Op;S@G(H5 z&8)(8OLS*Ymqr^3SIF-(FMX-&I9ab$n=LVxM$9=Wwc`|jQAvh1A^>t&3vvTY=$%U( z;<+pfnPiUDx>7YA>oTOnH+tpXWR#}HN4QM!@;5|J@&5(}eIr8LrRtE9ta)t*WWcny zYjJg-;Nj}ox%(T0Ta<8OQyC7e8SE@1G0Gv{O7$k|uo^BO4`aNf0K`;MEW;JK6oD0K zK*3Wx5xP5%BVuN~P=fR27;;O-D~Qv}Oi-!t6g0*C(01~-dL6pMn_Tks1w)zuzw4rM zgqwX9b~`Ld|98=kh>E6cHi`*kmafv~LmX@E!xvmjO{e`l;v4SrPDY0(s8=&DOx#pf zAnCL;R+;#Utuj&D)v8?S#95dQk~0vnfOO((%Y!`52@+iu4nyz=#6uOeVT&NG3LkTC zaZFTPRA%Ad0<^7>7uGdp+t(NNK`4Uec-~u)Tws<7XN2!#SWei7SL-umQJ}@peSzQE zm%QRJNWhlV;mz{gLrN>bb!}RIB*@EpY!t(YGTTMXXAG`QkLR1&!2m>W1ozxe?q8c`qbrgHN_TWPLfHuy&CQ{ep1B&475_IvL`0t?x z2k1%Oi-^XQO0K1(^;vSufR$I5#E!jKCl{0kulvce#IWwSUoDP9P)3L-q1;QKn)GM= z19LlMayTxIDHenSvNQ5D-q38#c@CU0_mnY3u-VGDeo&Zg?A&pN31bvN{EViuMPP3n zOTC4lb&>TUoEZqSmRbWW0}6O+UNS|=T2NRBWYUNASqnxK`Mt^1c%y+r=9LA%4)Oxb zKKH?Q?roAbWZV-_wc-h{Istd{@ic*6x~La(IQRz=Wc->y5Vfl$!l`9S8+YKY<^+|C zMJ*eqhqi8|{0p5Aq#v`5h2o(1cgKExD%g4Vb#9LkQg65IxIYw zLy6F6@+iD|;{hG#0*jmLpBKJ4+834&@U;e^PZDRY*Kta$5l@X)db~31EM(Rjiju%JZ&5`s(4)sa8Q0 zN&K7?QH1yL7a;<+sc7y7IL9Z0hnBC4_T*#SWsTX7s(9qk4{8++ZJj8m8Lt`F7&78k*_KPV zS~yz<&<}EJ70aC0%$DYjnb6Db*aF|&^|U>2(_lJv@%LK4@FG|*=VWc?uC(Y$UH0vV z$I_pUxdlm^ew+Kyy&oQL~ND1n&8*DcI4+ z8hlGw)E1u88=uON)1o$R>4tx}PjF6K_BwxZqbj;`@YB2{E4mLwVVB_S5M87hG0_aO z1nhWeNz_=lzktvzWxG((#q)^Tq4KIPD$oTEGLpoAT4V;S=4O+vLT;r%&Rt1Xr>x%t ze}ZwM^t76_i<~<9T_NHFXT$cWr2^B?HY`EF45)qK8Xl)O=tbK(R$RBcHDp*SjT0y9 z0y7L)nQjc11wWQuy?h%*fTAxBw^Yk>YjF;#H66XK4$^|q2Js?zn&NvP`FLT=v&hwN z+7ZQa%hA6og_)c6&YBw`w`l$y&ehsw^yx=k4zXTAoHiAT#aliCcwz+9PA;F2#I&1l zNHwnMcAniPMAFie#6*a&#cwK_Q|DFDO!%Ywqi>aEzP561>)naZ(@3oP1Uk&GYw=b9 z5W3lt!tc^CQ-=zQpEo;(N8zFQjS(&mQ=7m{fhphAbRq|BxAz99WT6yVCN@HHqW&VT zF#WH_llnsn^niTLxR!!w7;#T(+c{FGFzS{s0}}}qa6*P4bXY2Q8`-lXl&Z$`Ccimr zkFNo~rLEb85E@!~^Y5&s?Y8{@x4S#UH>XGt^AgNEC9P|Lf{M3!qG6H10Jdi;_xR!4 zrF`wxGd?Px?P1X`_=A<=e?IGxCm(X4*iTQNL1qyywjb3*web*W6J(KNZV%j?kUMyS z{X(d^w*VhR%Q*Mwnz?2n%H>_NI}oR+HD*8tOP+IU9`s`NtKdMs?)aN2-O*^hVdJ>T zHCTTd6^s4wch^Amt~30oz;M{nz{T zrxnAc!asD_E+Y89E2uD&Wt`!M@yKD11AOqt9L@4v_`ogD5~7Dj%MP_bY;Qq_^Ld>= z*a>VGfTYJQ`d@MTzPEn{epOG?Y983Z1Y~~ESnKXu=uXDC=gTnOc8nWI9U4_fNxpBD z5k#U!0J2;?W(>0nEzkFn@CNjz4m4A`+FPEkeDgUgDom*`{I`w$cn5E+P>2Q4|uiqXPQS|DNgS7re8^tsR7g=h}bZUrHrQ9*dUdZUQIcg zCh`#<*24EM;H*G##V?p3i>JYQDxYkd>{R=ZE>n{i@6>*4Z9*U-!AfT9po=Q)#;3UqTaT)V3TVQLJPVF$d-(4Nw5HMq>XEmV)>0`|tb3n@7!*|c`}(8C z(gJq@bLXK2wr!S)s(vrxgh;${NTxGZgw(TbcF04p$MO(#M$MM*Dm;cR5Xj zihAMEG&%*`M#-^gXytoz^yC((c$S^_pse@W@+;2Lu{v|(OLpAtq|`3asmqX;vn;eK zw`sEGJp0s6s}-N3*v~Ar*Az_2@8?q{-6c)vrD{i78Os;y-WkE1;(1`4U9L&4k`9aW zf?b!*jPUiE{D$F{U`O|7nRwsa#u0ta8M|CbY_>MUlSzqduE$drs;v6{?KKpDxCY0% zFFhC$gq}s-zcmx4pRxr?C*!=HG~+IYz@|zhy$eepzpbDypHca`4A`-yH8c`jZaMBHZ zrSFY+e|Ty6q?XcaIkWV1PE#P%%joiG6DYa7Z|XgwGE=JCQ8xBzOF!0utWtL@URL|U z!}a#?gluZR(k3Dipx}bLp`2|a`e~e-*g#UCy-AUrc>=#^I_?OkY8;2xvX-M=W{>fj zh!!_m)tR^%dJ?B7kx!AMu>2X+VEfYTshEt7#YvD5A2ag$9e$!A!B)xWp8O@y2Fly6 zHpVE(j1&CkyDFEZ>#YU}Ey`tX!$t4>l)iLKe&3NYIH!kJ@VCk;MN#!PH|c@KCYvxU z%D13%`K=*YRYCh|T`j=%q3EGYauqso|FA)Yw%enupEW*~_r3Y4M{+PqD`$Z=YvhT9 zTQp(z>czatm~vz5;pIwzi`Z>eQJu5jTpjuSR^QUcb%g?>8O|)uIK|%2B)PrXuA2q^ z2JK>nCyW!-H@v-V9WkWt;~u}>FYys5IDZ_YsC;B$@h-zz%pTO1bisN{Sl zVX9eG>pJV@;|g;R0BWJ21=b35T*>q_Q+$hqSujz~+wv|AF3`ajRCY1Atb85XI)`b$ zW7X2U!uzIp%E?QGT>l`8k~2)IIPADc9H^ap@`Ag;ho${4diIwJl*9C)uCEXJ%mK0c zau!~+2bh}Banj^rO-!(t5~c*MvBizney-6dwf-&;5x&qgtOt!H8|Bqxk9CF67SRtX z$b_&6-T&H}ZZDCcE59@gVz<3;p>>iNeA2_k5g8XoL2#(QJ3wx_i;rpbx*k-4nCXHk z9BMZ8M9I1Ax$~A|I18hG9pgensjO?>cv7|DK2Qww{q=~cBOK`O;eC2lwG{KN=c)Rf|N z-*AUnlU>NX7p*F7Vv0aBx^%XjC|;BdhE?P*uZa^IXc1rrinmR?L@vqJmlQRMB+Pm? zx>zr-)$Hs9krPc(^Gx!5%n`r~U@JhcJGDKKO{ZK~(^Vy2n3YQbFz5aJ2$Aq<`*bZ7 zbz#ZxyqMnT-W*(f=2?^*lq|QD6nE*=qE?_nCmJXFnqL1^UMoZ80w?U{lG(Mn(C@ay zqaLs9iz^<)!?W*8SMLm5$>*rW8bl}-S5uHUL^L+rhk3)BK0MM}>{LlkJ)lr}^&hIx zj2_%5<1`;kw~w@O?(#61xcz}cTtmoj_O4UYH=}5QF+xu1f;WCHGaJWDXTc@%#_K5M z;R@9V=AMMZKTIxL(c!p?cnOclv(ty5o?FYOGz zEE#L{fr%JtFPR^;6J0m;SIRfj3CSCCZ?oxC&V9nIM7MSLc;z|2zRiZ049a~QW+o?c_)x^gWdqH}!Mq~+F9l=`_`zq=Oz3pQNq~>t5}yueeTzpf zJx6$?Amz@l#?35)0_ zqDST+2p@!I5d2Lc3(zB4FpAI8P9j%fQF36d=`73PdDed4uyb!;Los`nOM$M_h^Crj zt_#h%fPsCNOp~ppW^77!+W*+eN_;Zo2)4t|l) z*)218fR$i+@-6M%Wvz!}XP}%*)*mby_iw65^2NQkf`OP{Z%e=MSmKG$*qYno`JnWI$dXFQD2S<#5=>ct(0( zqhxkqeqnAcgzLdXBSahB(-DF9D|6K_tM8dN6485eZ&;}=^=9A~mj9NDe{`6ybbtK2 z&+ZmJcd>g9?9d81A?hXtD=Ujxf2sSTd8>82cyE4W-XUZWesFDzh+|P}lpMhxj-HF3 zK`xl}yEPp;L*ZcHZxlP{je_R=kysg;?Jtfo{3bP?E=A^AK&oz447ZPK<>^Wb3^n{N zvoYzA-S_0~hf?#6|2?Fk^~ULM^^UdqxUUl5Z{aYTfZMQ z0hqh*w??-t>+=yhyc+I|t2z!`2GSpQa+dEI(C&G~6)kVsrR-2#v#WrCs8b!3r&_&6oC zK7FzjgitS2D{8{}K$w6tk-#E!-g+KS0q#)$x1_+67u3`_w>gXTou)=#6(T%@H4Kqf zE|Z1s6X|M&W&FI+%2xB%kO5t5059KX$)6wY&u)^2j znC(bdCYR+_pWT{QiO#ExjN!dRQ%a$kVg$_3cK71tOyd#n-@Z(=d}ajSwDfCmTPf3+ z+%xv!okfbiENvQ)=>4j+ty{h#7)3dmw&XT4;D0Lb1oQ&`m$N9cwLz9Wv!)5A%QxS3 zP%oko6JO-If_Tz61wAqaXqObJ*x*$eaQwyFlSxmrtvI_z=MfGQ?d0qxzY8DryU~^;QPC z5UL_@e!5=w;ywTol6UcO8AUQ7-oP^PzcS(NzWhtIwv^c)gftw=c}_NfEHiEgV?sj@ zTu%ay#-FJ=?f!UfthU%h)~mw^F!q#gxr=Q7xa8k63(g&nE*#eNt%Hi08eIEq8+OxP z94I*NCBpK%rOCc5504bq1K2Y8@+x#Cl_-e*p4LV}O3!se&}os&@Q2MDUt_g>$P)UD zY+ab70E4|u$+Of__0`#vh5rH~yDuG;q^i(Drj(Os+oRth&s_Rn;H;Q(ikzf~qj&|lmidsELI)Lmqo zZHcn7aMW2~GSnbPbt{PbTnYYW+_qs4!pNVZvP}mS(|`irGzitIv48CYR=89)DCfOR zKq8jY92EXy5sUv}V9YaK1Ecsy9BM4*DI@=g((Q|~WB=t=)DBYmyT zdhHPp?mE&BTYdiV z8^wrbKB_B=IKx%ou(XSZ}6@9UZ&&U8#UFkmtzJF|0|8 zHE*Ze- z$!6VeBV-$N;*Im!%867$dIE9ZnS3Nx`rD8;@+z{5%ZVqr@5a@ci6;0$K`VI(GilYG zrzv_SHiN13*;V+JlaGxg>sZi5o#Y{fIIuZ{oBezo5 z(-rzFgHXh#s|3GZ7&JtI&C*b=7yT~mM}ESf(N6XsVgVjN;mOPhbw z;GF*Eg#8gV1CR4x3=UNS(<>^p#noK0Mk+FdIL#lchpZ+41k79ijnw0uN2uX#h78?Y zTV{({mr_9MvJ+1J@Mis+sVB3`iqO!Ix_ObJv!}{tztfe6mb0hgWT`vD-&SW>P!o_x zZ=Z}?_mBU)x> zc5k7K<1(ZQf9+xDX~c-Sq!3OqNhygJ@Pj6=Y-t9y(*BZ zb{0CEcO|A@FbrH&iV7aL2Gj@(Lv1@fC2y*F=ebf}Q&L%5g~Q-L_3 zp*(tal7UTNSCzDr`JtZ4J>_gp7j+OWou! zF2|$aa)S21zS_*xGK?+icHf4)pkzs4QX1cHaobM@W$QIWDj7&%-&T~p3wz2XbS3c{bSsv}S z#Q#7xFry@lc++R?xs|(pm>9xV_l);a7AFoyLuW;72A(Hk3;BC9}rs`$C-%ZZHy%fB* z9r3?>o-Z-*2#rGWhhj@AQ-%Mibd5U|u9 z&QQ(%&E@gw_2-A{L96Ro{M2k>-WT@shs12!_<^QgUV0otL)){nvvUqt>v?LUSVf9i zfTQ2}e9ifEYv{#x`TN&mzF^<8x*Y6(W=O$z%oh{68A(sQT0Yp&IkPnLPadLyC+*?Dd!1su@2~Gk&vY7XA+}53 zD(m*!Tci{*_uCr5SEt*Y^{sWk2I9IUJQdR$ez>(CQ{MWXY`hHrhglQlhYQttgX5S5 zM7Xln-4jAfo2{1ULnded!(`O9Liod6PiiqYrecN7M7KoLVecD1bHlw!JaqnCICGYXluTDu&qok*F%y z<#{uyqrxPHmwlv&Kcx&dK=lfXPj=Bt($N%gIL9_bZVB{noShbd2}k< zudM2471Q1UqbY?MGx+V2@kB2^R5anEe)Wl zwq0xoW&2){)xT-L#yE6AL5huqh_n7EF`j?H9fE}EsLj4Mk&=ld&0X}nzh3+!MsOMG zi}#n_i+YrP!k;hCwIHb?+HU)8i&z~wUVDO<2i^5ol-C9hy+rTqe?1@*cFjE5oXn26 z%&N6URo>{g&b+nzXX0FHFPl6HBg%c-%8P@6czt#ltZah4L>6_(gUBrI*_&_aIxk~z zxV!P7G5jgK-^#8eda1X`>^ASB6>4++QxY$jT2lK3&PEs^)2Baa!Z%t)BX3VEyGbWI z?yoz>o~#yCQ*>P{ctX!)KX^LO<06A7EwfQE&$)E!p^It|Hg&;8UZ^KW)p7xJ$dDGjYsafVv^JouL!S*=PYQUWK$|r+9;bKo+?k}>0kkbjFBlpI3&T)2HSa6 z0@a!gVF*1$YR3Pdb+ahLIh;6>N#y>92L7x~cG7W#7i9sspY*#@YKGq5t^b&JTTH^~ z(K~696^3QlS7iw}lmW6>&!;C>`FX-s4cTS9IzMs?r#q$FGE}Xe`WDOn>i$Zl_arhS$U-LLDTnQT7yXMhB|KK zK~(?jI#gN`KdE1C6XJ->gz4L8)mXw(4+5<@djod%z^eh?nQ~R?4h@F>!M6K7d<1*d&zpDL{dyJr=07#^?HgA z)S?%^0)#j>YD{EYq9@RH_wmE2T>0pAV7vD!s{wBEho(%?v*~=esY^t7o$%g8?Qph; zUgPyFYHz1u#<@I3TfLahTYmf^SnkrJXwz9w2(!>^!sV9xf&2tjOkKzAJYZmi$lR-3vU|9#3 zFVVRB{d~vuwvgL|*@x-2n{kh z-5sD}mp_!gnEHnp??XHhbd}R$~Aqcii6Sw_3`EDHIAw{F!^0<$0;m=TJ5i$ybO}&F(t|?iG8l_Q@WB(#RLw;@P z8LlHDJK^tZGHiy?b{!BVftI0QTyn(fbPguRk7@!s+P8h)#^FT6-Pq$;xr+an^njp$ zloa}hRm;Rv0!I_RigKuj!#n^qJT~-kvBn0kmF0}^c;3?Z& zFB2`N%BF-gPux1LDc2SM@sU)1bI#1ZsYSBuqcuhf#Tg@N@vo9WDCL+Q3+p9gPb&K{ z@3R^6?d0w*UY{#x_|YJ%hPuyTguzXI#He&KQu?9)+lH5UWIc~~7#nC^L#q646|>4d z-CP`Ak*hU*{}}n_dfbeE&R&u=WN4@e%k-7aT$4(WJ+~ngN^eoq%Je-Q?zW$Oqdztf zwBKtrBP|7%>TWw4e3G7G(jOi7M*%X92frTvrTfQF)pGsXushI!)@(SLavo!&bk)>B zgC#-U)YT6W7adJDri#ZY>KHrz8yLR&12K=E1qZxoeV`Wi{^FM;T!5V|@OXWU6O=ze zN*?!h7xB z>ZD?qKq1P1^TT_5rfO$UmJqLjo1FIDpKaL9gDvvSl3HvXal1notxLEV0hqAaAofGch%~fCxW}Z>1hW(u##@X9 z{`Ny$of_jHU4gj!KRzHu7trZ6b}O#?|C`Q4tH8lUV{b79&3u= zNv+uV9&4xccah3GON*nEDcfj0U+_Bi(HbS1W;nI3fP~j_=qXB>N)dM6ESN7encHC&y!RH`v|*Vw{cIhp6Bk5RT=9(8>t+r^SOSJB}u5F zFB1hQtn9yd@SCuQeC;tX(cqbyRMY9h5JQo`snf0UDx?P|&4E!bMri1t346YdG++&< zi5|zoUuR;Aj}bZ3=I~AlS*?-Xv&vkL@jdzMeT{b8PGrUJ{qqOuJjWmLZmaFr?VLH6 z!xiu+XPzSWt0`lw|BtP&3W#gznuXvF!97TVySoNL(BQ7Y-5r9v1PuX(;BLX)2g2a) z?#|$TC*L`o|K&ady?Za#>Rw%4Rr@%O{hO!?=GZ^y_lzh`;`>=Z?Rz&tb7Q!ImsE>y z6fL>MrdH7JP09Qps}(-N_7avktGh7MH-*0$Lfh{{FH2?%cWAMmRY3a`r*Jj**t!V-2Az6I;w}V|xxSLr{yn_$o}q=bg5FKf_3-fV5rvomt(SG0E;` zoCttiz%j?UO`C=Ss_T5S-x;(^v>zEp=B1R|N_mUUm=0O5#vC&n+j&-fRLZ|UTpz0) zQdbb;Nnix8M&g1loVCBB9p)h1g|X$7s+X&Zp|hdiXlq8hpRVN37;U)^K^E_#6@1LB zLaZpfwuDvC&q#$VMW}%Iqx+9Pucu2j_DOO-!7N1>)dBCmz^}oh%KnQPw?%9noHFwe z9pEW*l@EB!BPTWtzW%ZTW0$~ssL(Dm7pUj%67$u4*;UexM(RjMta!Tcj z(ch@Zz(1}7e;@24I-ZkFGJ_j2P((pg}q3Pqj>o6dod*4)eR zWALI4LnnK)<7R?U zR%Hb8HW?HwEXzd-SjZ##D8TGz$S}!jrRtVSS&(yd=_373YcD_Svlz9;zqo*%~;#Bh}Ad(xfV+8IG z_MYl7Zn|K+;#$3te@=!tzw_2vzEJ!1Gd4JwHNTU=tgEl`Q+9s^_RhyG+iq7 zELcmb)(xnN%(c2K&@;D{yrK5~Aq`*X}{{UMuav$|N6FR8!^n^*KFE`qy;`yvG zCF!==?4@`r|HhA^Em^WWeMU7+6DD6~N(XN|2$eBICR~fbGQmT|f(;7ii*?OUG+G?E znpfa$;;_2`av zq376Dk7DA;mEectxl&PqD1c=b}6C_b?SqTul#lHDz2%l7@Wa+^a;`(GRHq zdFy!nIIPJ8ez_-Jg-tVQB>y(+a)RcW*Qi=XW~EO08)FfQBlltZnAt@aGJk&L=bwB1 zc{T4v{=vt_iTk~pdw-9K=40heU2|-FUs}nkX+C~=9KZ0LSL|;8*rU{JBRY})>*R3c zMn@EmnXGGNUnC>wGMD(yqTX`j(N(XO;=kAt+5fqm`;u0$WvsKSPh6I+;B!y)-pI$C zm-97s^rMSMiuYquk_|=g8A`)PnuC(@R9^AsPxdqsx>m<;9%cy!KpTl`_CFWsR3d7J!t4O0mW=Op-+#(%6O!pp&gB!BA9a5baY>zgmC=n=4OA zp-p(0M$9tl!Hi4Z#Z6N1cKr2!0(l;GqUm^R2IO}7BxU!7U8F#T^046KC=khXJRl+1 z4_Pb2tI%e0M+sL`TVuwhIRfZ~Y98!k)YZEYi^lSU>%K zeDb(twCl;(cL5xnBcPL%`D2g^(*|cCBND)-E(|K`rF@XmvpHvxFI$8lC|i%&JqIaSf}Y9-h__|>PwgbaE^fkO_^8Cp7dr;;>y~n!jwTfm z`5r9X;wtn!65B2Gz0S4xye`8tESa<1^HIrs+RJ?Wn{4LJvN0jAVy4Hb1B}glr&Di} zunodPx!Ytj-lBzoRPFU}`abJ~dfYFA)?e4Y1%SH_aKYLJE1H`Q^^Gz4fK7tKkgr+60)mSG~e^%?)rONyF1l86-;{4 z_e(>Khs-YLt!DWhARW(r$W!xKTkwjp2q|<=2muT!oA1{$*QUAFs9k9_PTe~VIA&fA zwYyZHk_#($^kdj0;knE!#FG1MwH8BTEe6+8J8@QRSb@RE#|r zcfdmyf9jlzoG6yb-|gwyO!-b>0iVM3zp6pD$Q1;}2y{!v!H=j6Z&qewks!0LE5AN znL1aDg*~_{K5LI~U!SfrM6G%Xosyc^I@fo5OL|e6^v8`7uVuyS2{fp{Z6l`~WX|)VH00O|ToX)h5OCXT-D0%)=EsiFiFqn!Qv(VWvxKeIGq`UOlAS8DCQZKw>p`zhvdQ(I~Is?M+T7WLD<4`m+Ob(^iN|I_8fzZYys!m%9ebqt?p@6;~@8aU()m)3)al8Zq#+0pq+md7|Fve-c6NXbh2#n7GHkO4* zqa5X`K4Xyb%Z2V*N(0uLuaB1BlhS}T@xG@%jS48b*{l}KicofRz<{ln6Afr4BG|b1 zbP2d^8p%+exV^#fFhpoEosFf{n;(kOamj@8d>`tWTut-5;4NR zfbVkMo7xmxj#lbw?2a&jxM<{TpGQW$53A8Vo%Y58LM`_c_+W+F3MDRl$ax)pL8|vt zoI$=p$MXV~DyZ+F-Zq`M0Hdw9(E#&)I+n-({&AEFd-(jDXLQLYW=~pg&VZGX9qNB(EWkx!N zEcb@ov~}E<&v7I81ZB;b*JvqEkSPL{j9n6__#+QAKDUy-8&udbYGAKNm0@Sk#&3-Zcx^cNhY+F6kw7qG)%%pGxHOp9 z)}IyMWbQd=`gE728sqa~ZVo_rGp99yFguz4@o`*J3TD5Y#{dntAA)L|u54=*kIl#H zSUFZ!w#~%i`rPG<>_t(*jR4sPaR!vJ_&R(!K%T*wNpwFuH{%Um2Z z?fMz;GfNVkyx07_#Y9@VZ#gO;U(t5*VLx&8bQkafv1vjFonCYw3TMxT1Nn73-Hzty zWmkM#sCPu&K@@xYLYeG$;%RIb###?#oyOYUj#gxZhtA0Rknh)nHnnF!L8iLTtX^^prd)`ughW9oFb0soI?Q5dn6`~ z;l2Hul}zhBOG1FtI%m^u4(RLhJy(5pW+C<{rsRC=<4Vqogz%lzLHxz&?C((a$Gu82 z#(eS^x-tQ$+bRe)gmK88#Nr|fy_LpNScLI>c9eMEmTcaDHTRq#yEB;Ax|AV8ji2cm z)_1P!axyYnZPdeW{)H|7@)UH)#i>q#8M^C<=C(6~uY1}OwOWP(bo&4jIj~5=Zq<;A z9S{(5Vt7l`|0xaweS%b*&c6MT(crlv(lw)n$v$5AF^YWAJ7HF>sDINtaPyZ>+siY1 zooe42VHZc#F~aVH+t!;c(G|>IS}R`z9h{c{zSFWlQsmI zv9#35yZX&{0q)3kGIDO`{{4M6{o;nJEMb|Dh{G4y$L_fC)wjR_HC#a#qNG@bb~=CW zEUvc!QQXWf#h;3N?8sedzg|$>bDtvJTY~WrF>oG^xqs@ZE8QOEF&IBBPvNNDRWH-T z8)U1%I7u#aDmzJ;zq_bRU^4-R9iEMH#@>L!{3jT7TGa406CO!4Ko(0E>r{2RRQ42S ztA2-Eq<}ZgC8;{LqqW-1E$Iql2NzS(Se{P~=SQGRtxo5t5w4{Rv!1UCe@hqJPkp3U zady6VU5_c#Bfzw7X!uVWTvvpiJZ4tyYQtw8oh;V)8AXX8pH`n0HSM%+%E)t%svY?s ztXdy-(cWEtYdd?K^vrK=>$*N(5Qq%j`q4V8WZ;x5W!=~2xXHgvmW&)YWQYoT@}9m3 z+OtO)21}P$!hqL)hXv{JqlERuRxy17v=`TSdbkP6fOku2j&paf%pHnlih8v41-|Ux z@+H~)xkMz$BZ979UsUylZlf6(OPr-c0JQo@tl^SaC^z{OPc8w5?;Fv*B>3}HcjY27 z^#gFS{c?k2f^hFo%gzEZIP@^t%YST96IA)qZOL&31Huiw^ymtKho(1)mTfY;b@072 zoUZj*r1SKO&qBO)z6`IcT@T^1p0Qar57U+?yFwExpWV6RvRz=jqMl1Mz}FYtq>&qf z+$Rjg8-aVVJ5XnS9zbe%N%2x$ym4g6Ai8=l<2w(dgD#XKX4FXa9SYWL$aa5RN_j?O zC8nl;yPe7n%jHon0`bOOwX{DO(fjhGo=HbV3{v5u*&u+k2PsL><;F;Q%RB$^xWs7% zLCbq5BOxw(&N;xdo|RF@-i9pP71_xJh~6FjKbw~iHWH9LZpd|jwOPePZ}AftRgNwG#YKi&bwk~JeiB2S_c_WQu!FeLJ?I}nxYMpdE zU8|K_YBh?W;pKYn^D7gBd07sCBdFgh?s->;wz zWI1kr*z*ni(cfa;8&1e>IiWhd_>o0q7F=&=1?VU;G4lPLW2vE^BE6MKhdrh2#?Hck zxM6bfZra>pXGr!^`(|tAPkp^#GpVoy_(JgNVb$dz6N)XwJK!-8zc*GFbxq&EYkr&F zt(3Dw)^I!+5q!%Yr;sU(mU83hub7{aZFhzzob&|qa038WH|v;^;>Mm2nu;!IymHap&T4oSImAsZfor)up`*hl8PfH`r_947_g*$p}Bm0 z+WwGoJ(Yun#1p}Q&p3r_FUGv!(*t$V$_r0F)UfKM$AH$TpWP*7K7xB!Z8ru~rNWmy zh8||Jon1bYX>vF~t+N46ni%?5nLN0;j&?@nCtF*>dWG(e==@4%^LjhGE9AFF+KX~l zQd*HXp4-0%T0}4kCj{KAwZ-df80L3@_9pl)>excN&a0-}@M6$}{QWSWtSj3ycKhwh zlg5b~4ZB?V@$iKpSTSevM0!Up-Zt9Zu%{w35i*1kP;=c&NpASw^XV$}YCC9i>3*K2 zBnn+$Q@F!?q?!5@7m)AybWhAu3fX~O{aNGvxQc~fUjE`^LFWmRACNa#V0uJ9l84k zO;~f-T+b(>^?U*JFiIh6}RCWd+G!a+_Z7+8yjyN|#>L z12DhgqMq15mtRPV7OY5S&*I83T4GKC>x-Np`L=cskR*v&dp0L-hhJ|Vwh4=SQ?@kL?E2ww?nb7UnB!1gD_Is=eE(_U{n-Ved_E( zBO|Oz>LzmZ)L*q7IgPUC1pEQVvHQhfXB;|PU;NIGnjVb!bb$CS*obsFdm5~s=lInx z4xm!{+nuZ7=4-p#{X3EMx`2BGQXJZ-Dp<0iRXBeSpmw35e9!s0pI8sc9hAm;S`G6E zXo0h7W6*lq`^{;bdi}>RWPWT1mhs}>^rI%>PFuqvlkeb}RZ*6IVjQ)F5PXCoKHYC{ z0FK8AXm3ehyEyna}lF15U% zW_q`nun~XKT(c|U=6Xl|u&#~YYD&}AUX_n@6lUL6@*?xcjt&8ru^A%uS9ejdUJnKP zaaDVr*-j3Hqy(T`2J6R!!^l+!`)*mpKx7t7!K1w=1P(AVlF)9rXc)3K*wxIA9yWQ_Hq7r+>VeaQ=8)lr%S|oXNhijgT>PvB7?N4Z))+22}n-6XCb+To)I7 z%CA`iUph_ycs$jEJuSdbl;=qbe&`8|aij)KPWACmy={^b&^NHV(wnXj+JUFUa_sQ$ zE=I}1hvL8|Bbm$nP%e_TYJMp$e>O1}@y^#0vmX9Ay&&cYq1%AcEAfTEHO$SDrx-hw zL1Pn_+aM@d*riTE2P2?|`lYY|zqx+gM;f9?hnC|qMlWr@8O3uVFYVAxYl$A$$pSPZ zLLy7dOe@!P&i9oJ+W48|6;Gl9Mm`eGwtfaC3Rm+n0mcV_K~3{@LXv%y6Pa;lqwyYX@Rn)jj5h`9Cl08selnN1T_8^RhJ#ocaCZO zcNF4l&wAP`K~C4tk*4|ln{ugI;i)}$Et`1NH`VuYJh`RK@67gp_vO1-^t&?W^+y+v zzDQj;l^&<551N+l+gy2C0ZlzKgTYVhVWn5R&uPm)-0^Z^uQS~N0AY;aBDG13KbIUi z;n)DTL7Yvm^hcDLBJc7T2H4fbrNOj$DC|7887?Q|9#;oNAj*52L-Kc(M*^O`FiKjERpU4?erY*uTDKITD$*4l(5&*wNSZ?B^J6^IB*MahNR21h3-jTI+F#(X^*M#bX?)EPaGck4tH&8*zsvH+SYzg)&nuW1^^i|;+d zSq7TEwr5#CV_jncK(`0e49)NP6+-|?HC(vo*Zn0miuk;%B^Kd`-;@G>SlgfKH2CIv zHBDd3F>L=%b^#1tOd;0JL$cnWgJqbh;+z9zb{BF|uTyZjzVv+8mR?aHFT(-*7FB_1-ec_17ihiR(BUaL+ZFTX^3YxX$Bzc& z-v03Q+&W|8o%%4|qCh|g?kTPq7Hj*+Ch-E>b3VJz*WH-?_ff0r<4B+ny~vWFjMiPW zUl5jfajh3q%(ri1`toUB+1!|h0iqul4B!gs@>>Q+^tM066n#4H+G#(Z5BGbz4|QaR z-2G1iWui|F_gDQ5^;rDt9NjKIvDqCBd1C@{~=(bLfs@{(!FN*t6*d{r-}+h!ul! zMdB>f8UbnmB=xAY75=Pmt$}6kBB+FoqNzZLp-WwXny{k7c+l@6_>6=&Pjj;H5KN_MxXuO52MCk8$K<1&*TFFP)dq-BjxJ#r0ZZD zS32GIrK0cNz>a^{b?N>Ihi@A_+^5M08!E;px=BoFyc|Yu(XY+nAS9aAW72;xb87jv z?a^f{(aEjW3FAf6^?E-?9dr;HNa9wp8q@uq^PhvVGP91%Zn2BDyWGRN(7BAuO_0W~UP5)_BU)5OgNqf^V%1R7+{D;Cj%Eb}S@yX_ zo25oX;#6Wz=P>Zy@_*T?tb^X_|8z3!4Wm(^yK?yh0b>Ea?~ji+@8`NY9x?}vfKW$X z%upId99$C0O)lc2V?E#WYPaLh)}){ia<5<>yQN<+%g|n7Lgo6hR5q+hibac)>pcjZ zTOzvL>nW)CV&U0lKbV0*oK()F^q3lrGnr_?s*}z?59}v>^nxyjf3V3`;%`d2ovvhO zxsq3m5ZCs9y?*aJUEm`pF_J zgXM~URxeSB-L}?jpXDo_LzIh<-K>PD3&D>P*y3*jm^Uup%M(coJHSmOP;XmXPeD(t ztOycFG4#Au*wzwB{)-h|*Vgfzpn{>ll&GX~B1?rq36_sRQpjv^hUtJP8jLZD9BmSe z801RO<%szewkzCtVFKve_)!TbUBkTjG0WwO8L%zY8(`kduiy^SF*l_~VWUVG8(>Nd zhQE@*AI3)0*b;$dDck$`;u*G?0osva8Vxe?G#|h4zik7wB-{F~P%YcHp8w`F(V(tf z^?2%Q&Hb9{rru>sxD91;cwDQ#X*w=DT708ODz3fI_icu znLU18UT^-lgUq{71r!2j!#B0R%omXur(w7dPA2XKTuNaI%SqdOwrcGxv!mcvk1z7J zO+YtE-@$Fu*<D6n(3iI3_C$iWwJJE;DFQ7Z7Cy!i%B9qY{*{ z%*lJ2Bb9xn#lU>otN!>6dMvRzy0DZ4fK1RIHc;TeAbxkTNd{qsTI?KDG4WUa`hstw z>+Xt-O}zevQz;1l?MipZ8V8%jkDL8TMFoU%!NWo)v_fLZx-xiTiPQVB>>1;#o+zK$ zZy|kkR<4TB{zV0uyF3op_+96+Zfi8!<^yS*6`bG6{dyc(87pk!9p2OA(g;i3jwJ^CG zfXSpRj9Gq>a;0K#g~_o@i~IwV?1&mL&w>4UWF|l$@U)Nr#ph6`!>*i^3MsW?f6_eE zmJJ?xoEFQA)Y<#K>}Ij&bI3lFAi)&I4aHEQ%Xwewo+tWla@Tr~+d%*XP8^SXy9UTH z3O1o4sn5D8;YeYu7n3kt&p6bzp50-|nbXUgwzljYz?vy9ocV6qR}PKfBnzXvThJKl zoB()E9md1wL=cN?jlvP#j$ES)|Hj)Akp4xn>(R#WrQP?p)wcJ+bzmy0uKuXVYOI>| zj>1y-i495v`KiA1wA$?96GUer?Eum}q}Vjwhk6CY2P*e+RY)7MRX6IdOwt?kLKV!^|5 z-JX53v$brBa2(NGvhPK)B0cD``3hXC1CU!$zc5m4}d?6ZK!!CxBY8YRB|(= zFDe@8H3iSz6;?2>uRdwiDz7wmuO@+uItmMlw}eZiPWvTb=leMp5*X9$*|+=sz{4swFRXhW{>m4j7R z%(dZfrPPdCjSb-^2cXc7S(Tqjr;}ZO^XX%zFS}+4ipGrH@vMxV1HWaR(B$VPn`HGY z8WCKL>5Y>xtzY_Qq=Qhd+mEfz<)0ZwVKzjD1{axbGe#)Hwl<|VE{z#^%RdU^GDt*m z`v|zu?4a}x%BfkBK+QHSNL{!l%;@38QWSn_i6umuUZDhly#wGc$c?|Ck3K6+lb_B>RPr2aSh$ zO@t@e=;8+FC4hB2DYZdbx~uj3vV4O3@BXIrWps$i=0_eG);#7NdN+deCp5=I<3daC zXYr0N`rcP?e0r@HBi8IzmlkM_v7g=gugmYp480IFVQN}=T8YXpT&kRB-AEZw*y5y+ z^@#}F-OrEsj9SO&|@aOi5-)v2>9X+1ZETF?jDG z;{Qe8bK2GG*l=?4-rsV#`znWB>YCAg5?x`?AY8c;ygsRcqB3 z(e%iQhh-v>%1jpsEYq#iEf~$n$2Ox1hapO@I#?GLn<^-z$XP8GK3r}KokSZv3~XKc zMG*|1t2D@Jbzg-q_?G{*H2u@lk92lqlOL7s_fmDi-3!5BH~kmjNu5A#_17u>IIk|9 zEv&u5!ck`6Duo9EutG1zy>=}#3>@ywJHu77&OJ|S2<)rbE&0^POOGnnMN(L_%gPjX zgAU18@&Il1axa73l{WT{3D8`Kc@2m9nW!UJAD0HYha-QdxP73x_{m(hWC3i?(U*EhTILEm@5qbj1ZkdRFRhxkR8IhGZ>E8>7 zS7V#!)`2aVA1Qlj#h+*)=n*t-oj#dHB9fv}iRinK_PT&8yugc%`;R^)Tye@*OeoA;R3kKA6KfOUaf%bgQ_mIHH?@vOHs`_nj zS-KK`C#Kt@pn@3+_YiWZaed}XD)#lhu}lK~P_N9#A`l2@s#u$G9IoTp-`aA}D4SN; zX|t}hG9=b~t7N49(R}k9Yl3Ob>HZ)G-?=)3uhQ(`>z@dPM?lE~VxUDUxmLg25%U-f zYZG@Mbyo-+nptb`L(APu75ezrm{M9+)Sra-vJ5wf6i~s=H1Of#_FSMDCo`PaXL2}e zX5_uDgdpi)MdsoZ5wbg(QjAnl;px^rF_S>P6Rl87A(>orY_Wh(@AS6p>ItWB>S&8{ zNN`pS0mZ3-_wly!SEWW7$ZeVTK_F^2-1m@`2t*)sr204lgjoafnDkgYf#7 z3i5tWt78$1ga3L-B-@F@Fml@&{DoyZEzgoiR4kIXJwGKiWqJ4xYH3+9jzgZmuiZP>z?&DFeZuZ}(QggYsJYrG%g7tT2cRKX~RmU4Jq@!dDT`0nN?u#E}D zS0ct82*-SI7PpIkW@KNsy6xhoW26SkGY*@WEUJNXQ5i!KHB!O()Ygv5&ym&*J-nO3e&b>e;K8ln67? z?~@--qE&lGJtooMsxz*GoXVqW_k)qpj&Vd$7u8-ncH9GRO8PCknp&xT}KPJov7!*0_R`fVkRbe$k22p1?2z z&x?kwniq^@DQ?1hvhL4Q*^3q70ZIYmU=fUl3fjN|J95}O&P3VxICn@TsK>@lfTj6w zE&EVlHoI8QxhK#-yKMLr74t&kFzZUsV6k~C9Ik5QB?Y!;v>Cc2gd_|MA{gE<{7hmn zzE`r@EJn(9j$O|^SN=YSHeJ}7%d*P1$8YJRW&Kr}TtDq`_Pwd{?BkJDmt^xWgz8JF zWLY~k#W^o2SjXtTHDleF9i|L>7KX=&l0>0P-U)nnr*%<#F0H*UYk;)0rKsL(D|L(w zd6b6mluA8sjSDg7bA&F~90aUKiL3l{1UDPAXsxRT)M1i3lTni9CFrMMOad?!9GzS6Udv{XHG*y-N1@lhqut|MT^lu0mH<6`dkSL zu*GO70Tfd~XGLNrq}vFm7)ki#O9jcc&?iWM>2e+Jg9k!Se?ffcPY8n-DHeNB3;cWE zI;+_}V7R{P6&z$gfs;IlWe!xMsC0NSbnF*E9iGofPhea)JNZfgJIndIx%|5O+oya@J% z6m;N7X~YPP!HY2;nB)c%deX3~_o2>Q5Tk zj2;4rlJ-O!0+&2cP=PXTg@LC?kY)sxU-;dxF%R+Fbr*!QedNX)N~JgSyMV?NH+)Ss zR&x-DgzDC?*8R!XqR~-D+Z#B*p3!N;6k&{^| zReje`U>TAj?l(kUgEYdPrMixNTvp>gd*&iz=gt-x>rJQh?Dea7F*KUN3J5~JZsSUl zL8zY5`=Y-Gl@gFV`I;{dZ!9)%T3+t#FX#KXM1#prb~0See?L+wAp$e6YRN3?T3z8! zWYV<(EhFSpPcZ8VKNjG<&3Elgb-uxKmBm*HiXplr3wa<=us9mD(1T(`wF379Gq+oC zAzk+a?IOB4Re7zsHdAW(1O#M5&BGdf(`(|sXfEVSHqbPB{BqJUaE4;OGdsa2sVY%4hI&PgyD`7mG*EOup^J&Cmt7-+=tNR+T*UlnXzv`az zFp~&_#)xAcsb2XWejB6B-f{F^8p(_4;#uUbNHpk5E-IxaBCdo)pY6qpGz!L=jc7en zJ{{A!D*fh-+D5hn`LM6`m6ph@G`*s*_RaoEeaq$L{70DT`Ha+@yu}L%c{_U$X5){0s8ERCZ%*vs)<+92IvcB(?aL|3B~Tbjev8RTidlbI2!T zkCBBifx^aIrIyFgCtv(#!8VwJm|h2;_*6N)@s~LF8!Q+kEb$Sy8T(r$2$?Rwt;vCH zxz#c^ei8&kJIqt`ZczwcA^x^_kl?Hgw4ru)YApyI!*60H>`vZajqXkrxv_kLy}Vz>sJkx>==!KM0ju(xLc zfj_Kp)1p1p7CJU@(M z4nKPD^yQ1Xz(<*-=cg5{F3s~eA+mT8|7^OIc!Tlln+n&RU*EsEhyS>iQg6RlbfqKl)UHc6U_8`EQmdj z%N5I-Ip*$3q6{W96mze*lQu;Xk z9CO6t9tZoSM=I@8%WBb&*_t2p?}e4SJH=n)l~II)|A`hUVX}F7K{4Ix6Y7&(b@hRa z{gzA0Fk{qIF@=yQ&42@2K{C&U~`G%!R z)Wxs&Z9?+OnOQ0c-9HF}3m35{LTLqC%zDGIARuIgXPxhZ+bfUX8H2oS20m|Ew3hj2 zxeA~w9BILod1L@ZL!;?D-H6vh&==Wq;2Si|_KG*;w6Obhvq}TeU%$FK@uhx|Fr`z7 z=h4?*;K0pPCSSz!XRF`<9brU{C^Vfkhx}EEQ9Q0RyGA{(A&A7U03j+q`F)7&Sn%m` z^0(*3hkxghVXe8YNyCkO_+F=Pq#}XZNMcT~I`{K%6{Eq=@wR#nyN)N>`x^)!=Kf|z zM`V$K>Ujm?RshaC^^q=+OJn>er18#il$uIyQk*O0GdO^nV>lrm&}nn^Zy$v%x%>#4QMN^V{uMTdTuQ z&o85_cDYOo|Ib+V?%{o=fP2c!Fu)f=s1^LAUZMLQ!f9r*?Xa15VuFFVdsIi0i|9{Y zK$Lx;6sGi^XA@v_@}EQ+OKqhB<_;f#3xoyToG+XDKLNr&wc&*R3(RWSin=KfgPVj@ zus_5l({c`RK98}o-c?4bs1K>M^_-%KxSJ}p``+&RFgTtoz1@XCo+NUYxVROF!mQ-J z*zwTvqS^5D*Uscl&}NRc3F0c?Cqp6fL20$Q9YysNP@5!76s z!KrZ&VkYQw|4)$@tTQ8R)YYcmY#ZuCrV+Y5#!6T zeHJb0Zu;tD0feQm>vx$;J}Ar5rn>N)+To)K$11to=&0^eq1&Vk5!pFvwce60Ungcg z<}G<_#v~M2-ulj)rtaUoTxLBhH2mv;mQf1XW?VtamKdwPTMI+Mt6J))FL_%wBeo68 z1^%nv_MUd{muRB!m=Z?)pD&zx(0B$Q%IKS}joko(pgqBt#|!7f4MT`gNoFMEp1MVW zxoPVyA)iy8&#|?8NuATb+S`F7I+cs#u)&Ud4S%}7A3}yX`~Z8j)a0yoW;g%)V;@T| zc`svG((1#?3!*xZ&v|E{=u7SvL~dE$5(;AJ(4BnqegAy12U)=NU}~wkNxZzg>YD;U zP;2ro)xSwR=?_kkaOTv$b(m;4?qF1)(JkHl)8J<}RCkf>5FCgq~O0yq!@F zM^!PeAx3Foegpc~>y} z9I^y&mYkc^oFe{e@z6Ipc8AkT{U5FlD^tFlfefD6S9c1*j~6ThDvCn+Uz|0{wTm}S zATcJXNZpVuWFC;ivc;vxeaV3m&tT05aM~uO+dt>|t+sh<*j;7N;~NO- zEGzQZ{+?;OyV$i>`Xwbw4FSOJ+Mk8|9zB>vXM?vP{zhNEpaA2tiu~C^v2)&5)FbNS ztTNa<3~`Je+*F(N%P2zFv>%z-6{Y$`%EWOx`~IEIgg0vh3QjP&OpuIn^mGC+)cd=e z00xa4`^;xk_T6l2jIW=eLp}}Vr;ecKSg&=8@wZO|RmU5S{Sy1_uwKV+Z<=hoSfAnt zky|Rhh`XrsIc7zHT^ol}fn%A7g%U6{7(1?#KR02qWo>!Ux3*QeoFJ+h0G;a@uZ zp0->Ps9gp={BH}{S^_qvam(AG%9g=@DhEVQi+aB;kaSG+Xl89l$YU;u$Ie7rrZuT( zwhGYZ`Q9aRKMvEhSC$p}b)y&QuV=<*IMN+(FA1O!MJ~?i_RPq2we8`gW$X0i`BdU8 z2gH&^1M$^K^fQ3i_Ee2l=(VUCQZ^rZ?xMv*m=P9TqDhNpc$4Z~1S*=!=0iW2M@3#T z{_TcFnBB!Ns5 z-}hbC$0)pFLu+87qA;ERIlezd;s<$U1}e$4!g0+%>bHF?WC=z+p?}PUF9XJgcfPs zL6=Xq!QG&SDR}G8{~CrH->0kDS9>(R{-W)LYBX+;q>bRaQ(o%~wMm%)Od+Yd66_z- zcMv^a9T>sK#^&Y%+4R9Jt4|d!(GY-qr@uoH(h1N(_IkArYq{gU zjRQn~RtxD6G*ehu%IYzME`I?omfa8a!pJ?hj0pK%G?z7%Vdc7cl{MX;c)w`=tK+BG zg$K%4Yr$f@W#Hu?7NupId3%t%@S)~qxb*I3DB=roA6`W*aBHRo_2he6^P<_^&6CLU zYH&v8uO35)jTOJbU(+)^I{~vHf~TnJ?0;L+2|eK_jTg00g-mA4Ea3yTwA#8|`0{#P zi#2b0q>Vo6;=FK_shMm0a&IP^Y~Uh$l&9JyPwuQAW2bMlo_osz-busjNc36`5%QnO z>ztZx zKE8@l11uJBs%I2czzHD&<>~Bj&-F5Mb7@k2(&c@uqeFI|mtWodU?l-{`V1e=KhwyZ z%jeFPNO6S_sxG+@`0?YyzBNA6ejRY6X2vhftc*Av=~Szf$(0{h{Q47LGSQ);LM(`~}{>WDZ#9R5J>T(j@J=}$xwqEgt3%vi*#3TK+_Xgvyen`Vi{G{7qqAu9g1 zWtNENOr=nU%}801i0}2N>Y++A!*p4bqnT|Pgfw41!O9fL<%!2YjfAIQjr5;$Z62T( zZ4s56pag&70XXuagX~0Wyai=xg&OzBVvC|7KW2k z4-%^?Qd<5zC~PZ_flmGBQG6%?b4Nwf)FAe!u{<{~)@T3m*31v*@bcK*@O7y}*(IM* z+;w1aB163skZo--(~s{~bscuP6LbFgf5q2egewq4S~Z;=6}j#iI2{E4sv)Z3u1=AE zRLD#%LtmHUv|*z5^M0FDteLeqG*W3ioV_w=nHv8Faj3pleqnJ@&Eelu+=zk;Z^nh( z)Lsdpo~^fde4O#_#$`Q^2(CGS*N>NJIu##H9s6xgnQYy*5l&8#+3!1nlRCWr#@t-Q zq9qqCu3nJO)h9`N(_GY%pZ)qVI?SQO7|bBM$v?ek85yMPp7QBJPlJjR}7YUCb&DPy3{{$Z|&JYbgXL zRo7&SYMXsjUBu)VP$rtx3gZbNqVutRO`%KeGC8@{HumVv=w)-09@E)ujW(>L|K#Nq z39##FX&Wv5kP7D>`Q&mh!^f6V^SyKH|2!*WhqP!m*E7T%q`;8R{e?w4=i25m5hGAg z7Jl;fZb2nrReJe1@B{rw(XE`^_hrw_Pa=p44+0>=eHU#N82I-mh?XCXCI&sV(^wrN zbse|sAf{lFuP50!>@>^Tp*`?B-eqpfpTx_Y;YxVJ)s!D1d2z@?yK4&bEYtrkuYWm6 zz?k%Rw#T-={hq>JBS4NHSXlPVO4KkDhV^>~^t;qX0LK!Ufd=UG!41=5W7$TYlPqaw zgpAGVQZtR+^d~A2=l^5sE5owvx~5e?N(7`kr9(pEqC>io5b2a|X^`$NY3c561xX3% z?r!OZZ}TYk`yC$sqOLRc-fPXwni1{{!O8vgFe9zPP^av>F+ebrxX%T8Loh^cIdV!V zn;HY^Z8mjw?e9+#fc;sr$K7Sa960s$`xD;|V;IlB29rxK>KDBbPq@Nboli}8L}(wH zBns)oEZu9+>sA>2!klWb-@%y)|JV4f!;^U0SSLs*Ads(46cYC{8`mzdq^QB*Dj6Nv zJyUeEe}_=}(MBQ!jK8iAwyIIcBr2%!>UUo<-v^l*tadY1m~C-YSg$nrT`z#L8d&sw z&*|cG{PXRmo$p3y?g-fKXfiZxk|yxFH6W+}^pwk?w?L_I6*vgS^e3^ibI*ewK>vpq z%$37rnGP^P-)=z*XCm*S`1>G?M^9DLii2? zW%E>1xScBQc%4u32%&Uedc9Kq2~4N_?Z?}}0DEt3%PD;VyJc#SbQWwg8t`q3eCXI7 z`oQXd*5d&#=~Aqn)}z?cZcxaUU@B+f2wDT@D;oXM`gL54$_God)r-Rwp80=I^(&dUFL;NT_{&F@EkqiMZqj_0uIb_crp|7Lzpur=5Nk^5$&_m~pJJ zh#I3s?rz($j04jl>od}E_C)S-(?t8_D8Y-s&Mj8R|K(UpW`o(u=Fm~UrMqIsG#J5N z%g;?6Zsi!LvtC(^`)DCA@4P$7bh+=)ig#veY8uUVUC9?sTT&+ofk0LRDO2Sf)B|Md z8XtTW82IGv{!%tJ`8v31d~J>2zi~^cwj9!NYkY&FU63_eKvto$z;UDMqM&IDQ=P&yHKU zK3Y5IUCkfS?_NAMxPT$s76u+YfNQ~Q5y_}Tm{lar#CI9<-nKGa#IKOjd)g9pB3ow|1y zU3_t%q82K}i0cCwiOIA`2sR05Tva5O58waar-$CjK^8#_pVGdi!=tUd(F%I*rbw1k z`$fSjl^Mky_KfKF0gI6)9hOWIwOhtF{<;sAt(OH&2_N~{&FAH6`DP`a{*1>;E`vDl z7?D{t9g5rrf9ES-ga$yEA`^V=6wt2r05ev}>mSU)Oc}s;QyV4u$;Mp=qdN)L=iT26 z>~nUC?Y8Y|+aoXJ0cukEJU!IZ*U<;%d09hSXbt7{Kezh3?hF1Z8LKND&$58+I$3tT zAV=fvQ-cg4L8cnC6&^*B+yUT(nX|$P7De%~EpR{8j1R}Z09(veNv*4>h8o?z*SuvHKh~-cNC%$5)v8)K$I*Sr*spn_EI9}a%JMwe&8Nxn z*hUexLkKj0yO#So<|GZUs!)0g?|xdS^>+5TvP6Kj6#x*gI`Z)pItKZTqjf&JRH7%rN3@!ptnXTKLQeTQ(R$A~gCA!_jjx0GOQ? z78YPtKhYET0k*@${aR2i6Ch9b4Z~q+JfY354N?|pAn{|;JRO!~RI+w0c)X*$x;L!J zodZ)ihEp-m%ROzTg~(^r%?v3JQO!t1%=2b+!!T@2zC&u4n_oZJ0@#?YD<`m=2dV!F zRVe2$+IIz;&6gJhp9L83wc^oY^NO%N;E5q%s8Fa{LC{s;OFV38F9V??tjE|)jnUnA{M?V=qeyAy2cQ*J7o0*sLB*El0VlbDA`PdX-ON9w%y zLkQOPhkayU^G)yUu#@1t`%d+63xF1s5vibG1A4axb-d-lx~%$@MU7QtGkEXT%DV_h z@+t`rN~QzIsc(vTP`XgsE?C=%DihwEth|Y4$RN13b#$0gocnKM)065cNHE5pso*X1 z`!)M5As?#52RON8eH|RJ2*60VT8>iGyZ94nvj!yNy|h^C;}UrM-H1XVYpy$?D*7Wi zr!+MT&1RZ&cL5B+VkNwZg+!UaXH5pF$lE&ZiLL6hkd70t7q&KRP~h2HbC;2N8Lqq) z;O?xFC!Yf?mexI~n7q+4hO)w!e0k@Jf1aVMnVlkq)yH;fh=#h!(Dnj8Z6Q>g`G+s- z&-kiM)JBXj1t%!1eMp91p+ws3d4!NB#)w*wR1E^5;3L6un zA%-eHQ#b`bG2s&u$JzSLHn$oaO}ozJe%Taer=!)JYakUCx(9Z&wM$pPV|qsmt_xUL zv(7$m8w%(0ZM2d-`?p2jSi>g{lXskVtFWCOnuk_~=L|H<&*4+Ey7WDm*~U=E_t%qR zrB+HP=77>cwKFU&Zvh;iiqjLY$X_GE~`*ij)(BB#DsOTq-Am#s_$;y0VIt0 zi^S@w7uMyLr0()szZcTdEHCk}O3P8K-+sl{9kgFMk-p#dd))sPdlBJ;(d54BQnM)1 zl-#E)!nrBAio1M)eQEQpxjx$mL+OH%0!P|U zpn6l|Yds9L+?28W0csQP%{!3YpjB z&)_+8)vN`)?Octb3REkY{CacbharV3&6MW#j*Xwr44BO(Vd@u6C)U0)8|+S6+i+mX z(W$JhA{N7F zJslq~2;XT+2@neos4RO=szB$n0?gVpaYx{><~PzjG#{*|ADlA*ez=gK&TpsbvRB6Q3)$+%evAZvUWi0y~+2g{S!DTbu{{+9?4@psyzNMW2Ysh+IFs;<00&L`wutct9 zcFnmB|GnKE)MB~l!Nl2rr09I|Zig*q@l(!>w*9OPFPU8j?fCQHDHLpIh?#f5F}tpN z81dp)aS9M{Mt-Cc-S9H0ExLR{di2e;?CMrw^}`^VEO)x{g9oXSFD38fOWl#i1VNw6 z!>`(4!~mEd-w4mWUy8f#k#%Pl&Vea=^?-sN?SS=R$AJC{A8|&Ys;xID9F`OtQ_0Mm zU+{^5zqa}5kkQjlqvadmeT-%AO5tPa>MlIjVXw3?#r@yYA|kN(l7swIALEmnV<&~h zolLZvkYS@kdbJRTbUQrnT#2A_XM?@zi8oUUct~JM?Pxry)UE-UE|TwdOV+-RqEjb~ zkVlQ)vrN4W0ZwxDp)Rdm0GE*+X#srQX#r_7;zpuu&Ap2lybc(S?Xz}>aIfzv`n3)r z7&RxBVDOIA{E>z6p$0x;QP`8UTRHfC){(19lXQDKIBh{sZ{JqQn-S;2D z9*9sm!t$n}`orEX0yT8;MQN%laGU z?CnKmnb#YzRBHyA=VQ~w*Ua`!omtN4LFKTAZ&S&#FBzGglIQvZjCV6vY0;zsbPu<7 zSSPIC1#ub|KF7~BI_%_9en-Nx^>=^&ctQ?vkM|m9ac}uxB$>?p6sEQ?X!L_0K9KMJ zcutRD%J6nO>(SajYeF&b5={3qiDT-*bWFY}NC@T0BCr41_sTlm^j6+S@jv1=kjY@^ z)2)7}^+FTVh>yW?2Hd+wF^w9f;;%_j|8<5ax`_;ILk+AUbHJ=sYBq-2SC-+wc&rEudDLX${$%P=UX6U`7~=I523i_pf)ogUi-KZ9f~=YF2EO8a;{0z_g$AuO6~L!tQu zPfcPAdV)e(^NR7%O`xWR2Wt>=qyK)0)fJeTKRjJOhs#Xn?BvbSR@QffPt@pCCe-xq zuacfTHJLS|4oqJx(v_kBWYfRDPDJ$EPxNDIGDTBu5&7%4xWU(~W*-BwN7q-Tx0gG0 ztXx>C)ys{XJUdR9|Ly*jX?@j8rVpUNfx-@b>>OYPOu685?>TPA;BJ4pE{M;NWyn z`zn09@U#EIxFT!0PLkiyWOzu2yx%0MruxzO%?2|gqjFrwmr}#jB}C%0Wf?jVf4BR4 zwAad>xb_XwwvtAM7zMqr`Eo|kVOccG6=cUeoAPnc)!}3@HUEB$0cGX`1PK{LW!glF zB|{Aq5~CQ?S{Gu$V*C6tl5fUQ=g=`PyO>f^zLnJU5&iuUF~&~ZH*OKrx=!1$=DNHx zh02w~7yUkpj~PpF)RC+glN{Ca2n81XmwW^xfny((6MCcLxE|j~Y3g7ZzHt^N14Sbf zsik1Nby=p){%VDU&Btd9C!hT_|MRE^MrL+64z$FvopUV-uc`hO8vVozgq*GF{=m^?G=$4{tD8v7KA*hy67D3l{9 zERb)IJbgHF9zU=cInrJgmBMf^yw`+azKn30zTSoTy zP0Je_b|j`se9q}x^xrh!<@AUBc=Dgj0XIrmL^7pzB&O3ov0I7FzTEuRU7e1>i{k&# zQ&Oo9+H9nu>}P3jsQy1}IM^}MFphI5|E^Y?GenNF3PtQu>;PvBV_zyg+JJ*Nz>3X# z$dkVKcty{oIG@meLPzU1>3gprH4XnE;k~-C&ZSgao?OH9aGLjxNmW zeaR>srUW^&$1hSxa}a&NLKA}|7_wB`7GPT&N;kF=tm3SzdOmrS!U)=(MN73#E{I$F zlA6vKGr0Y#3mY4+nX>#u#$(!v*H%8!X8b?F$oH2WK2?2x*qL-v2Pzgv>2XEjj#jvk zsvo5f5j34)Yf?9&SaC+ehgTzj)R{GX>lhKLQrPHtXuSDfwn~HPYDwNXIfijYpraw zO*if&nalKA$WQ^NFM%9?_CF9BK4+>7+)CFos(FKXc?~*Yb=}=jk@e2{HQnizh+@ zQ1L^ijlhQPe|ne#cfid^*Wv;GW&IeWXB;QVq1mi&F5GkDKu%l%OPYtzQKmJZ46ucS z_!PFOYSVZi%IL2D{yV+>=;nYD{B%6%nE!f&bF#m2Pn706rRcO2VKOOa2&DVE7aFd6 zKgr28=RlIer2U=0{(SxbddLa*8211K3)jL01ja}t<{a_eb)D;jbP~YwUucstTqU<-*hbVfE%u6$7@Cb35me}_L=T_PnpIkVG=>a z{KXSBX7~&=dH-4Hv;yT@lB0tJkn)-ZKcP`8_fVYrEC6TiNFt!gL8@)utC=|wd58hv z%FM9cpRKpKTsyWwTzKlV8mT2=xw>75)t$?GiUXMb{txsJP!%(NO1J=slf6bDJ|sY;K0cd1d0!|oj@sk=R6+*)Y&N8SO| z+^tzp)=d|P#wm$8aNXcCvEFX}Eh78(QnrLU3@EPP70o}BVvP;surXVd#JU~~iwThl z4o%^QbTw{f1fN`&x}OHPuHY@iSg&;UoNTe^pKdD+(}3!5@H~Y9l;|yCNVkjL$ohk+ z74OjXFhJ=T&A3pw0V404q`tlp7%yEQ1{h91%mxOt%UK7FD+oSG{CHC`ehUUeqlql- zM{I@iydHeHb7Y$3a-@?e%+1YBsDHxh$t_86+pbITETjwhA03xkn5671w6+3=2Soth zKFDDPM`|^BZ>-GYS1^}e%>XaNo5|A&cVOty`%M4}q%9N!6xMIJ0g*A8;vwAVSlh+D z#6H{pA;?GeJcN4=?eqqZ%`5_gcHY#{U7e-GV|z3|F)S31efyM%)28l!>Y}Z$L*O^< zv^xNBW~Z6IrRo0e+6kBa*H%{H_5Kf@G)~*N5Ri~_wm+A&ap~_Ijw?N%lw;0pIp4&+ zAR+;#;tfC-peDj*X_5L_OT;?$0qvcx1~w@T#am}s>TkFiVOi|Bj}vMT9psLuE3h#`-sxRCMnFo zyK!&fcFtq>%=+qVkJ95R*P}oXRu>t`IRsIoavjQPJ;7)##bN&C4A94jcSTrCSIL8% z97cf4${!x?Rt|OxA@gMI5SM)iGPDzR06aH8Wamhau!1$wO!M9!2`^`bgoJK-dYBu@ zbgj5{K87;&q8QNIbM@UVlYs-?G!T!=U!wY#M+Vt?0vwudr*9L(07BLS{=c^qwEA9s zadD_8J#u(jdY|whh|qLgasnW)`>Qy;KDuF!Prr#VL>^48KTaR~PxNy4E?6@TmQWQd z?tEuLb_6LBI5P=zIvr&N4S=qKp*3eAChTeYS^c0JFIE}WHtfc1m2q_H!IHnnhgS|| zq${oZXgGLM(tzS9hFk)(UyO2f&{U~P=(0PJ zy&~|*{q8s|p0>1!`JR_1KxTGCMbqguql0XGjpch0hrQ|iH9Zj1;oMrlkL|I+b#Af& zCAC$ z)RJ}8vTo@)0G01sJ`RbYs%{Q&wdM%;X@B6dSrxPBpU%V_m1L+V$HI$VYrQ|$yW}h~ zc&V!c!1som=(MJHFpP9+;<}kgAX7^WYQ&M4dvC!rC1(Xh9ViaL)9x((@V3>dlzG?x zkT$gS-Kq7gnrkcw?x_<9JXmcrGHJP$yHvT~X3SQuWV3;L5kM9~+g~9?U&BEdR;m5t z8BZ4!@(usQ+hs8FHnM}qVWll@C%CI=Kc;j=;I+edZ?t+Ke#-+WkE1|YSHXA72Ozg- z`0zLU6B0yJWO*18=yJG2A8w7>{dTL6#)B5hS;bjdxc6|MT%RKok^(i?`BBq+=FUc6 zLgor)v4NclL!L_BJJe;&^XHeRK)IEn=sAa=F?ar;`3bTy^S4`Zwk}2q07i->=NTUDf>jD|eYA?Uo0>sE)9^{J!|S!=7*%dRKXpT-sET z7h@Ph9Yd)aT+Y)wlFwrEM1jfc1yj53s`;Dm|=`=zYX zn+LVT+QLnehsHE=hS}C|_9gFN!NnI0)L9Uma+J+Dmo5k|_-x2dzyS(NfEWb&oR1|w zA37Smo8nU`#Dv}e-hX+RZ{SMtEb6GW&fa2p0SievNt!yKbUB%}-hVKzFZLd%cTl!~ zzl*+6Yfbprp&3zaI>E|)50oDH*DJFqRoCYSBWpmRV>QwWdyIW*K0S4#tZFcqCNFO(>=P}NQ-`O+vnoaCb%81Z7Fs-Sz}xvxnJVDf#oq$ z2fM+{!n$&A?b>Ad3eEhI!pn*E#{H|$DVsiH_)hgU+4M=a>wW#<*9P(Im4d1N!}lY? zVGlWELX|&i0^<&sCT7f(!*zJlNt@IGKUc7@M?|=0sR0?Ir1AXe7i)`8y9?G`@r|z` zBss((+Vx(O*MHjWbqG311+#S2a|SZi>aqB4mY|LRn-CZG!yFySIY0(3&?wDKS#1f# zn8b<_UdFn60Oi~&NU=k!-dWy5a>NB(TkS3zRYdNoukpkq7gY=0LJI=!N zV3h4zD3K%si$Ogvt`yI*DLsGF$A#`EA@s|VnU5Pyv1pgjrkg;GGerzR&BoL&S{z7e zl;~=|1i6Zc_;oMZk#O~-BSljgU6(#LW2#zP6A>5igJZ^y3$7Z}2`u&*XyYS9_ZR*) z%Rbc&g4zNh?ZpoBO_#YEc3#_Vtsru5^eWgCJXep5wmI*^y!(m!Q;)(})D-fvU0qkI zedJ#Bebkymb&uQA`fS^p22mGR9A7Sa2ov!q9>5U|9CR3szIuT*i$)pdZ|}%P=(u>N zzDqQ;0HWwrd7duz26Ja`{;yBCyoiD}j*2NxZh+$(R<&y=Pd+m(iv+^&1r7`an5U|X z18|@Ki2cZu>t~Omhm!K?eS4b<62Br$NeE` zJ%%Mr1SY7Vz%SBeQiAUV-nVbpX?r5MLZ=6O7hePfO%WHN@DgxCZ9h<>SNwqpLW!Uw z)=|ksHXV{z0E_3mh0Ou`rK~XM9)ngH`Um_=0!Yz@FwU2?L%Wg978lQeDxDYf>PC95 zEelrdr!){Ck4=dC7$7{GJLtNuTrDBkw*sZjb9%wt0CE_k7NL>(Zfxyo{bD)Ads9fU zTen~+`4fJy@^+i1{SDn-U6EYQnXgg?3dbPlUxw4q%~f`CKxAi95CudmlUk|!L44?? z3&e-p)0>`?Pv9IYf%Nd`WJK1%nXZ+mDDlv`pfA-;UffRiMK+PB6C}`k?Mt&uu?wxR z5r(P)2}CEh$~c)|{6{fb8aAVtj{VLHILQE+00Ouh00PSRg4f9fXqi3YK5FqLZOnXX zh6Oa~$l+HMJz1X9#s)ndGYTI=dz?SIub%zVg6hOqKJZ?6SyGH*a;Pe-DpGiHT2_#1 zfjj508Vg(UU8!l)2iQ!&Wz43tJk_(Z=4;wK@xtBjCDanpqir&pD4D~>&O?RMeZRIT zPb)Ym{CJQOzU>uml`auhe5+wtpFQGRaT@J{i5Ev;em`j|lNvhfeoc_bhyp>;C{LpJ zZUYy~=(Z)P5hNr0+4nWthz$4PPc-S;nL6ueS9JXk*@zj@n*Q4jpeFV`0C(SN%i%>y zJxi+7IyHtniZ_d)RQyMa%NPc&3jO@B4?drK#2W6d@bqWv${Sv;XfwU6mLSv7MAG#! zuY#gz&Bb1*Gn-N{mKonF|1P>0^)g=XN$o1p?WVtU?Xj{1(Jp)jq4Eb+J}gE}IxdI( z-_-BlgmFCYf&rU=xg*Gi;{m-+SZnC<%4*LQzna~|{}k7k9C{kY(F3IS*tFc`N{#k; z(<C#QYr$h@Ms~Y_Dj)6ATTy!FvvW+J=S3Y2)A!KEvmA^=V*cgW9iy8-*&z3ipKm z+WXAwY_dEPLZn;hI!f^TYhrsad*AEJ#)Vk=a6~N4-0>gwNK-9dt1Rct93h@)vX9@Jd_&_S`JLJ)MldzxIP!mSYrsMb*QQfXBI#6 z4EiQqrhT`<=dM*e^uNY6>s)*0zT<#gIMcTo4b5h|%O@Psb*Ix#cOD}^q2Wh|y8YO* zY70_ZkCRN;qrvcPUbxg}J2+lF+6;pW`AtneQ6pJU7Z<&nj3VsKPX z5NW(RUEhPX_Rjak#Zi1~Mh4FJ?O3LLPc)``fpLX8I}cIs#pF=pJQTVRE7Z=L^iAxi7uT_+t{+CiO!+sS zf90KPSjh;v+g53f0u2mTZPmf=&5DaJbxoHpo^U_EmUMV`w>+dMg*D%f=JKX2_>Uay z|HT~#fS(5CJb!{u`d1wa^aIhI#Hc+Qij-KEnlOqoi#Sqb_`tfAFs>8ESe4h6XN%zSL2GcD< zrvT}*y>h*|w2?o}=gN5~-b$!mP)|LyIbDx>7J3t)H*Tf|-E&54iM<#GkVp`ieDd-} za%Cr~iquZso@JokLQL*g?5 zNSL<}y8Rx#a2^UHGN2xfHA;1&z;aI;J}kjj4EiEMWs1_fNiN`{)>jITvIyHow8L~x zu@4UsEU8;YNSQ$YHmfr~3>xJ-37lq?;&hbyi(oga1#2M1K>iEvaZYTN@b}Gfr}V#k zrHn1Use#8&b_;muu>&xIu+18QTx9*AZB0kA4RM-ja;oG$9aY@^jK~z&2@$3wt!_&> zPP>V|fA>JAg*xM7d8%vAwI|1w<<%@huE0#lma0OZf4~-3*#VeP0rdc0sJxjk;9f{D zEb0bkEE?(65r@BUJDv<_<>OhX@PBPf!LUeqaxCur#t(G`g|VSqL`kE?^+Wri_c!^L zw`zWzVmjuX$$2293Qn==yF{>c_}7(J$7RalLsYE=E4qzBvPT3HVOwU}sD4~*>jOSq z^hX3;9aZ$8?e`}6@@jqhXPrz8vK`8I<}hQaH=oh;te3)qH!G-uQBp#CsY-~PyqT{a zf`C?BWE}qOmz9B6XUQfa>|u%?jca?Kg>$Fh2G51o{T3t+UY92!l=_VPCQaXn7+dD; zpI$Js5n(ph@Q&WDeC^eKHdVHrXJ^WSM?MRD&6LA?1^a%-UaKhfz9m}%gHkNgUhJb!N+z; zJ;gO>wutKj{SK?hokT8gc(J>@_&e-ayY=kgh*5r{$=--$4k+ikLL*2v|E zsZrOaxI92#;nbNnYhUn~ncpO?F*nIXHb`)&%+5Z}gw{KJNnpaGur~$w^JS3^ktp%_ zpG%Hs*5UaE4bPNE0N${bi(!VdcXjcVn4iu zc_r739NXO&z;|c$0dl#1jUxz$bNY$%+c%^j6A?Pw~>~XJfG{4_ws2QE*B_2 ztCpVV8x9laB915ssPVnBBW+~W(|u$$^!bNNato&tnFO}*!biiqrXC?~lJ*}R_aTiq zaKGzf14#ufE`Y}v0gc?_&!t=Aurt2y8?L%SH9-@oO0|a&P!>Q|m+ddgZk3*~$NZDp zq6mr={W0tGIx#ghabD*$Pk8oYD=1p#?!@&FKgaszWDO}vPvyF>XFb0$kCH%Af_k( zGzxhw`c{qIc`cU+B2f1`_QK`yb7$f#gx5}-jNE6SN%WOel}6R)_Ed>#G=@CBBawHR zI>m5EJCo~OJ;q^=pOC~mIM5yz53*Akf%5pS1u7MXp00GQR0iBSt^*OmbSw^eqk6oLNo8f;9!BKRkZ_o}b?pv~-Y%1NwLSv5* zI=RfIRcGP&bIea_@)UF5Z$dDgmdMP`aqxQoOuM3^qf1nL<5!;`7(Ywh5Vw>KKRK~O z{P(G5`z}(4A_GsZvtpxA5(uwsuGn%}2n;2|S=hr?dcQ8~Qq#~x2{Tjgzd6?AvF%rE zZXO;Rd!0f+uzZ~waT600W1>?S7WO##>00rrh2kV5lHQ%Pic-&wD^v;rap*Zk&bnR0 zx?3+iZq_&Lh|JGa+3Wlw;s;(_urJ%7HB$&kNNP*cvaF#c#79C2gTw8wC$ z(9(3eZut-NKcZgj+Ii+1?E3Kw+i2|4w0=_-eX@*Rqc3M_n8mxT1U#QnwI5E!d-`5) zK56;VuJ5viZO93kQ%ZfM^7Vy*3TwA8F=c#r&=sDhojl#vw9?qvQY)avc| zJoX>R>sR7De&vRZ^Yetp78cB1vqdER)}0~L@=JVgTB@q6J8Nji-B!tBw_^teUgEsC zkdpq&AubUlY2T=Pydvaw8;d&glJ8T|f|Y;Ee7PHKd;R}bWA|InRYA%-tG4WPTwj@> zWEp}*z3NP|!!*-b8OBkC7v+11>N4s-BX_k5Y!hJDElltei7(dKTS>CB4ZyYXwp`C0 zNVvEpwRmjjTb?x%Ku?2^$pztq=$6F;Z&nhE<&$a zL1Aw14Gd!FB7W(NKpz`B`!<{Z%f0BU!uZT`(+TtUPdQQQ-dVQZa`pc}s%h*uW2ZQ9 z^m)>HKc568O7d(S%ZTaNGBW-_~FWYLUOW03w$&k{ER z0s=V`up~`tRqZ%i@3$=c+7!39xW7MDEwtaTQE9U?9SY{eo;LiY@btSm$_1;`2)dEm z%~I?ba(`~mk?)Gcp&^)Byf`~*sXOnt{`7&tm%b*(I7*_qF2H?3Z3vM zUU4v6FcUW4-|@dLx41RO6`2_wMS*`?td>moqIMx;BPkT){E~{ER;8<^pg>JcEpc_x zvpI1jiPcsm1dsApd}&5j8;t7YuxES!_GUi`NZN?J zo3S)5cKo)D$TAB;sXaOBiTF^28aif>QByr{yQ!s}CK!lq+F6u&#-U+FzVy;-s?unS zg%^F0_u8!WGC?~Tl_fd^P8e>Za`3JaA(7GJk_0I|1!5m${dN$iyjBK`Dw{&?swdp3 z9|lHWK>s|5%BDR%9SG+gy`(>0r+@zg9?P=OdM`rDd1mi?lgV=GC@h4UA=bEa>SMi0 z$}Ss2s{8k)X-YJk!(~oRJ?LF!mVt&klzBKt-_2yxWICYPq&N`2S0Kb60xte_Lu05Cm2^!qVMJ0qK1YYJ%#EERXYg>~z zENMN`9U$Un6)4RW%1~;F9RBQ7QogOaUSu^2nt_s_(@fY$iI^(E*OJO*|*euZ7S4a+t$CBMTD^-M( zX)iHKijs>4d3f^0tTHAex$?Y(pCX2SgV`8J?L`TD!f-rZeDH~iQ83svN2YOo@B925 z*NM|`R*!ct)ZYnDa(4Ha94rs2FtoryebwK}d$Jg5?&CsUzVm<7pW>f)Y{4vTNScO$EH}4JUq!zLBItybjTt^q#m2=M@$0qY3`)|GU3%xM}*l^}OjGdm#x6vsB%Z zrLvmJ*u6*2j%CGpP`^RwcI(e~BLi~f6U*H@L$CG<6tnHRdNDIswqe8j5IoD0j;AYr7F!Nn zb;*)(MZYIp&Rl16?fV?bHlNt=u$wnXYRGiKXUtgNGN^OV&eSv&4%SoA;AUnJjA}MS zoOArXgI+E#FzJ13ZuG-VsE!%>Xm#5Jka&52t3_sXGn{_nnZCd(qu*(YEEzdAUNb zmXC??$=q@%tv=+?f)oPxe!11jNQXgoT4(`lOCb3-~2(Ol^|5!Mf%HjTgdz)cB#iR3tv%+w% zbCZX6XV1J?;p4=;b$XqAWWD>|UU7xPxv1NCCZwp@y}BA}RTq05?5zc3Rz`!VxePm& z9=A3kma@M%$lT4l1@!dX=2yygcJKTeHImmmpJSvYU2(xp8`KJ1-dJea%qMOUK`+DnFM!m7L@u54HL9T1GT9FvE z7V<+*i{GEx3nJrBrk$$>2TPvT=Tkc{_BJ%A)w|Zqv|9mzH?_m8Q!9g3nRYp9Yj9RW zgUH$5Tz1$e2AMQYhUR>W;^p87)svd!R_%ON_P0G}d-ZcNtJMseHo+~tj#jOl2kX3g zzYowbT!xNcbvQF_1?q4{HBA*2O1`_p@Kt~)C_Kf*N_mpe<+sgywVAmclz}~TJa{jM zNv~FPnGTK!$xGm=@`J;CWzFMK>&||DP*K)09zEec3Z{p5N1Z*rnQ|y+wawS|s%~ZA z`b0Dana@#{prig6Q{r+G!FD#@D?{)@GOxWL5scwopom?@9(=)E$&8ghs`qsW+N_FP z#>Gn;*28#h!~^|Gy)H~@*5#`D>>iGCQXn+~sYhg;zTA^WJovw9RZ2E@@)dK}XS;yKK;N-$BqW&+MgKrK6VYQ;L#RZ&+*u2+(ps!3l~X}{B`Mvdj=G98k@8Q!yB znw_03%V6{+bIf+k_bn+;()(UTYdDIKv>00=s>RW%q5RUqT&ZYRsTa1QjAN;-IeW_? zcvx`5>RmE_aT|WTyP@fKRrhS5Nlq^j`dy580>KkMFHBfHTIsK4B=X9l|+ zT8=}C)ayZBBb?~T0<=A+4}6;gs(Wxt3eb+k0sQfrX1f!&3zdkUBO9*JcM}Fk)FWKg zTht2&(J0O^j93f@m`e7XKeQgcr2mEnyT9#u>ylN=&e3{wRDQ+nGN3+HwPo?4@$M}7 zdS`z-jCVs1!M0X9ftM`L3elf6UMP4z9nZ2+lFgz)GDj-Ehsj$ndELe>_Lp1rC_8kU z`%=bhw*h2g*4iSvI%F(B`>pTM8LSBu#!OG&)R+Bj`%VTIi+aTDAnsQ>?8r38oI}nw zwkTL3E@!KSyW7T-yI?SYap7vb7PMr_BFivdCrkcBfe1+EQ^V{Bs3cVYn^mMd1{LTy z+~Bu&;g`yP>~LX$0dRGXI{v=$=H9U1-8^Vc|!5YP9g@&{K!Ki0Xo697M+tVyQ^ zplTt-__BC&xriMV`0RuAFOv_|CdG7`6>S`e#$9OUiWVA`nsXm+zS->GBqVqFXl5?% z){jKtX&DZtGJ=X}^vzsPv}sikJ-^tWinUr{nUzC5(Gw>yKOjQRFyg=GYeIV*6M~Sfj#l%N^-y@Pf!Nwyf zAUQ1oqht;?)wvLJREFzGz3{Cat-B*7q+%E===0qvGSl&*p-Z__%zZKhXaY_fs)NIf zTncr`_nt`8-?9oPQz1@LKt`Vj;|(sJ#E!)~jyfr%ERn^*6G7~KISWIy;(^LxB+z z8yjt~J(_^adviE|d@`@<*GK?YupNY;fcNaL0ZzA^m9BB344+dDN2Q zy@z2*QNAmAk&CTx_Y)!e5Ak5`E!J{pXJ-x2su5>rH!!A&iQ8+V`76v5aNSC^CGis; z_i9xy$Lxlfv)j#DwU=~1R5=>gbw(oL9gMDUyYEJD#?5V4Y_ky%bf&n8#+9nlvl#Y8 zDRsjCo=|&X zK0I^hFVNMk9r&wr7j8824t0WuLV zt|i>z_dg=eZ7_jFbWSkj_O#UF^{uC5-CRKBRONcF7mTESA!>+uG5wHpmE=^zsd;6o z8dCV2-OOCWjW_aqbn3pv$~xh%JVL~mMAMAL^!*|i{o~{GRCGSe0`&CK+aiRVHoxn> z*1M>mVPiS&fUCC`75zBB;^%#_1beWZ4A<4c3y_z${%AW4*zw1^35r7(U~o+IxTZFH z@ZhGta(L#rrbhjT+xNqqKBFfS-&*d1QW1p>+1jT{8k`G8N2OT!o>k!tH4FV^Y3dDK zKjyQs59HkIBw4EfIh)Yoq{)VXjF?n0R>qsSynlE35@CEo5rkI&<` zcxz!WLA;LB`x%-0L!=7Cr?$vz7zSi(l_w_nj*o zLp&`GRo9>MtPz|uMoq1mxnZQZ7ik1V;M-y6QW?*_?oyZX@bSqVz!i%9a??L=onVUaP*(>=LF$GtVl)2lcey%ustynS*&=9t#k$oD{ZMPP6=DHcW zp4L14vOC?Z@%MJ(5E{#lgwJzMA$pA`!IdiMObxQijAxM(Z^lmQ%w{k1(@h6Sw?Ae2 zt#eA*7g#S!2Iie&Q~M7-@#=fHeo}`p+u?V`6U@}&me14nOH2QIvAa>$4Lgzgzg|c| z#EwRVW%&-)Z#y=^3>U>n(Q(FPeF1?{Lp_dT ze!DPb|9>E}jV;>gUY`JSfihlud<){1-8!spq*vs>Dh2}oGZT{ZhQXY1KrWy`IT9Rn zkroYt`H#S^{^mt`a>%m4+Y`2rdVapvZ4v5^$TYdg*P?_Y8#?3^8NQk~df5%BVZy$C ze{Us;Pl&S|D;3u}79*KlzZue|@mjoPRL8{WpDai0E6D%-3VxVR*d#j})Om{7juIV0yX+`{_~C$48=AUp&X8oX!6XR z6cn5yWf7$+9{fK$ln>Njp`2nYnld8LXcPt`sTkNz$_0lWuMT>t%KZ*s=5WOMRo>8B)hJ4mv8~f7eP*T7bHG z&esh|H?bB}Zjdp19vQ+d8&C0gEhrsZBkx(H23{bJ8zP9Y=SFTJ+u%H2r@Sx1z}E=j zyBst|nix+!<$;itx6h`J1=>FY;WpESgB^M%)je$J%Wxbce9T`kjIEYNs*T_ z&nXD;F}&E7vpY-As(K$JDe;qCCcB*W_3L&h6~0I@G2;&jLHRY~m?XD~nwli06LS>O z{h<}N-sk@~nIm}AMDs{-90y3T#fcZU^rW%)%(yMTbkFAW3ls%Ym|TxgHY>TN9+jma z&c@Icr{eUQv9U3rp~lCk1nRK}K-G)WjJ4906cqs<2csTfjhJ0rtORgbqZ*!t12&-0 zX4jiJA}rmZSS@MXA70_GSuIQEv=w=-J6i`|;n;&<&s6L2m6}RkslLdph)%Um)#8Iw zVO0&=FVUIWL`inmo-{FXgTAl2KgPQ14M9Rqbol!M6$lrXO-Z|v9*D|SP*5*eQR@$f zJ3Up=&qI%o>2 zI_$&~5j(?hLlJ)v?LP?k4f_)f)PLGwL;>wNi{N2E6YwDe9-MLI{la7g4@!J7C8gM) znvvYWZcC5rBwkyOecQBH%CsB5mOEr1H|*9{tv4y2Z7}pshB(m)*~NtyM`TN?NW6RJ zJ1Uiu{!2ol*I>llL0n@ln*@$h6qKiwE1j~x2k&+~9E%6x>hgfYR1cSssAyvo!0_GT z1`~N?4g03eR-0;Fg;8c&o}`>*{-vuI4Yr@rnl&CA&|l4e$`8+>d+HBoE-hO zQSY&^pG@kJHxo*Yn>n%H8w=rdTE?~w`pbSSZpuQPB4r>U!W&~3KA=2p-(FYh zaSMOBtwID)6Ap|Plw|N8#lMz}+x(115fXpkb}&Wd@M;F6yU6wcH^8h6%mYS^2CC3b zc_899Wj#;kD4(9oQ+5V&jWz!i+GB9P>#s527KHYpH-s2Z2m%sUjH=TxSvBTlk zYIGe^8Va?w8Yo}0^~wh~Qm8$s3&{|?|o{imi;-mque*01~3F+}b}nY$eBW^sVyX4XF9QVZa0#v&%Gci4=@c9(>%ON|==C4`ZP z$2L!GI&LAC`_XlFlRD9ZEO6o4@*d-VdO_%$}LO@3q#o0?c3sJ@nMW?!b{vq?oAU3J$lo znSDG1lw#{3XP&zZ@-@M#_o&$q&)0pj{ah$szkVGVU$3P9>uRGtf@CZZEot*GPa5BN zJJD4|Kx??(q<0#m<^|0H98y^H?Jf0&Q79XSkxP2wi;wH3czx7>VezMcK$o$vT0$YQzseL_M(nmd>D)i)`G+0Ib^m-y>vmDHV@Gau^1 zfedxnw^vZ7RcBU++79y+O7v1ulbcx2%v7P_RZQ=C4reAu!$-~*mOP^2eoP4M99^X8brRbc&6fZ(O2#B7+|7Es9rC3 z>tiq+XYzL|tDrdkQ$966jf@~@BIwD{?qhC@;%OykJx(<{K`iXlb?}jly%$CE?q#UL zPphr1tz0;8WQ~~yG;7uV$``tp8gK^x8sM+o_*{09!)E71)|#mv<5am;nG;x~qpkZrcr^~@8AA%145F+MZEfxrR!^7R zZa$tiN82W6e?4;Ku_jN!I4_z1PdNB&KdSp#irkfWv7wd3RDU=`N5$^!w{QBoXHQ+Z zY`^bUzC60x4YB+A#vG8EuYvVv!tnTi2n0Z4jnHG)suDi=HRyzz z#IBE-$cmT3#|bGYRzu7wv10*fBgcX1$;mv#(3s_Vht=1UjpwktzzulQ^E-ex>|P*; zOQNEoIrjm)tKXf&HWeuhh{-o!U=C`RA3ihL5fBp-U6f2Wc?Qdrl#Kl-A&xvNE^aF5 z#Z(HXdKrm+e9_$@MoqoFmPRV@R{jfyAZZ8Wl3nqN_is;aU>S$SF6u<16oX~J}f1PvqWr*Aq07P*q zrWQWOcQwJ*a~_{dSEvgOuh&^jke}#1!Q-*fgFAs-A|!JN0tY#;o>6%pW}>uLI2&J_ z1u)B|^tbV~HOvOmDFU$N<^Z0zxGRcE6nPbNqle(3dK?W)I`=V`HyTH6hRncDUoedU z=r8eV?3&S1z-t&#+8wf-@!8VjOVj3u)fhQV2NE^EtZzAl$l*Xl#pl|CPfrUUTORUK znoU2S-zPPg|Kwui%alDbm)pwA8A7kq_yTQdNalWpv=B`)x2F#^* zPX}xST$8O9aurSa48F-geQ#W78=rRsD8=TYP*) zFISQfEjUG|UOpzi?hHizDGY?1ylJ5smV7y~Bww>@x&1tnQP zQ*K;o>07kcD!_hsw6Z*IayEEE!kb;HqR=$qjLlT#fO!N<5X3cmh~$`sI_+~@eq-wV zLQw@-)eZ!-=mE+{R>GJ&`JBGEcxrZ*_V)La+nCccPmix3yRVX1Hndgoa?0$MDh9E; zqF=kJ*M`aQMoLDshZa@zs+WvVNtRz9tZ&T>=vM%k(=o_9lfGa8?+)d4?4PEWP?G`D zK<{r+q}m@VvK3ObQ$LoSC~=iFmCCy8&Cp5rH$&+7P3IoqW-z=7`0a>10Qfsa%xU5F zZ%NI}>QWt>&55j5;DuD1*X3j4y@{{b5lk=0AiJ<>&up(@9gy?v`cs(h7 zvyFf*06UBrzu28amA{1U`D;9T-NW~^yzvSx1vRIh80+487q)dN-T9ul@eIdZXh zT7voLCu!KVDy}zjY~V%!g$M(Su(Q@X$P6Lpr(U2=T=a!fJ+H}MEz_-MZU!km{TL8| zLEEaMEI;(K_b8pdX!>>iCB>-ZYBTGmu*Pyz-l+{SaFpBJ@a>r?hh}z%7X|dm&OB?i zgt5Cpl>HFLD(Q07Nq56>Epbl$SjY;G3UZk^aqLt0$g^CJ{wdkvbY$x4H#REC;_`A= zh&pA(<9ZpanmG;k#$nHR#MT4$MfT|1Qu+Czc|VbFwirhb!UjSO$~N+*X57onL>^+H zz|-4#XXUv!VM~w-`fr2;$gp0agkcEO`uR^%~mi z{niuGgZbOb`JUiuf7INpj|%}$)1DaxVUpq=0K+qJ<>?D*;aklt%n6TP)?z0TgpGPf zk^3Y#Pd>AXDBNREcUuFwqhz+=YyrSX%sK`TVYrtfa@*;g=itsIZ3@3@rp@Tw99wzg zL0IG25*ib5svRD8A|$aR)dymW+9!7Mu&uqec6ZoLMTd!MfWOapa4(yPRjxJ6SX+^s zcObB#6vm?2K&2IY8s45r53#vZGy*pg7@zY+jl_ zu$1^-bz>WC-632F=l<3v${y0dbGZIan6}MxAuQgllBuUli#Dc(sE(qs@!Re!1QP!n zWL$Jb-=Nt0nN3oZaw=roUnl3>yLxrwWTN4vi8?l%s#z^cy${+65HHY;yy@-y+N z285gnhN;74@)oo7-q9@%@BMK7Fj4E_aO|e%_xC`I5B9MPxYT^^(z^NEvC5$jUI0Oa zGxx9ZVadA-zKSl1$~m#4dlh03PMhZ$_e>gAkpha`LXv~lrF zF7wZ*npMwzIXyR(D4@e0CRQE1QYnWZzY%jY;LnFoct65!F&uv|ot}6VN*2XoLT^&Qs;Q1xFi4c)RakE0E)xqZf)@y)Rd$KL}U4k zzJlnZ`yDYb>g6Ii&ZDC8avrut4b87E8A0&2O0T{8^^2WDW_|auK3MSHC6bVvTp%N_ z_f_(MFW<0I-sjIs8G|5Pbiyn!n8E+ST1eqJp1ALls+H^Rg#rL;(%elCf+Ag0wh6)Z zt_VIyiidl!`g53CkurUk+@|L%yGl%Tu46c6B3{7Y-W>3I)q(dtFi5d|b*Ptfl!|3< zxSMlW4tuN~Z5j=`-03B4u7!QceBcy6$*|gKZ)0Jhl%uK1H;FuwLezW#B$Gav`KUDo zgc5mCNz}=Ybec!Hf6SbSF8r{hEVF3c--UfNzklSgi1*}=?9gPOnfF%OK%gMp58^zs|JE5j!{T)+Lz8q;%y(4~ikv6<{yTyl9 z%XPoa-I-Lqq)`=w?FmstLm2Da7?i3!;c~#f7_7sL^kioOq8V_sr184~j@7mg_Us3s z_}-Ma#m=qP%3T|gSF0#Hm#vIi)OX($G58P|$e7GnyYs{rDUq$EOYJ>tt-Pm^mz<~K zamttip0;`44>+59!5_KsBE`NBTrN0P8QWA3iQs4bzhiFX@t_*<*GEuSQTn*rIA1Jh zrx@%XeQpsp3h2tO@KP_=ct_?t2oo0`AJPZ61KS{1wBzxXc6A8k;&pBeB!HR(4lH_4 z_-bFy(dyKnAL5(wvWPlS#Sb53@$33 zn|>rOJQz#^rxvYh-_Fuq6VKP48d>B15nH+!Lh6&$oYw4_GT328NzqGGnKtiyFqP9d z*p-)3Ob55o6I0j+FKfIcUkL}=2SYwXM*`IsNZME}Ev#Y|A4pPVvy16d&0reZKr8=on zpQYELUT~D$>pss??KHbvsvnz9CGCWU(x{t0WU~WS(5Yz}^BI<|5V5~942)IyO}ZdV zD+smCP|?fmY4!mt88!NMYM{j2^D`7WH+%K~B1an#=R+!#o=jY->`*P=(-LX6Vcq+@ zhTPZlan3ZC7%T73-5*_vy(yzzeIP!^@P|6xosB|ebS>k^$wHQ3?b@Kn(l~+lIJbs` zM8vieWBDyEk|CzLSKShlwMAQic3+CFu2Yr$&6VcnSD&*fiq4?v?i5hTukC+jhx&bczX|%-?(d#F+V;77MZ_nYPFAfs8fFELW^yg8 z1;~<$IN2fLI1zuoATh(fn8dc>8=726 zPP!X%RXaoL75e$ubr}R(uPFp|6D$6?|EXa8{Z%?U+LIHt+6V$-XdWf6WES@T^Oh3HnWfk4F$^RVE4MyLGTPw+$37=R&_Hm9^OH~e-RL5 zyU%HsYiKFx_KvJaAAJ5lZA?zEG+8ZMN^BIWCBWeLGHq*<=mRJ1MYW z*UiGh5@#%;G$TgU`<6@~BvkHG9FTRGXJ>T@rD~1OvZ)*m6l2{A6aFu!u`<;&@uJz- z-8LuFx2*Nxh{G-pK7OxXp!CPm zCe6;(AUDXCEKE%@=iSpDP8*7*H(HYOtK=g*%*eQa%C zr>3R?n;6>2JUcc>B7mrfdFQ$yJ>1!3^0Ubjk?Fxz;GqLRrVt|c@xeN+qzLg3H)0nliK_i=0sLp zT&jA7X|SXi$+aoPJ6TT~6Ua<0EyEultCQ)3FD+qZluz?E0E>XXgA3AEUFTyJLLP5F zE}k?`*v+5lPRwxs8*0w|CKBrS0;O%DH4a**RaiY{qy>)i~Yx&WMHl7ORH6|*k*ehGO)Cyl)`6ObEiFh4}ITY6=DAo zpIwtKGBQ$vE91SDB2tq3{*uY{-mfCeRnx)rK8>K?vxE|7QN%)XA-^3MpO(3@i)%ezEBZJYOEk6$pL|U9X0LafV(#G&13a5 zcSb*El@M5RbIjPbtMN~Z-X5J9p>eLpKMb?|-^fw$9GgGp@gyI1HA=CR==b$QM(?MYk)>1pyYmL%LZJYG{A!s4OIuzH}!ousOuYmr}KR=*_&~9xFmH=HY!R2lmLn; z48Ij%%~!XddMmaJm}O)A(sWoH_DXprdQDn3^(J|ky0hR=E(8bK4xG3(RQ?VfZvA}~ zXZ9ALmT*Jmkh1|iNxn-o1|WF1-$s?)A9UWEIae|s$myx`1CpS4ucgH&LVA}dyl+tN ztB{5UPSbwXB;4q`oKgDD$0PE~}sIw04Oo}5zOtA!m zezOv@ZbpWd1}ePRwb@5z6|O|VvC@S zwEqxcvSIh(9pX)g8Cf3Kd8}Gadr;+8?ArPR&isYj>FDJLxN|=%y)^jMus!g2xZY-0 z3pc$x)C)WY#?v*d5+Mk38v!WGk=;b50ew2)h4UU&26@xnaos|EzB9<=Q zD1kGZ1N1JlZnguCd9Q6*V3YQH91s*lr1*$}#UHiz{^+OvAyee`Fqw7=G<2Fgn!+@A>wuRYTvuP^HQse1b$q@8-sZ!;-9K9mO@x6@*kU8l<_Rki z?%d)vdObcpiJbS@!ck^r`Qc)NIO6NC?zNtOOKPUj=ZHp^a1-^4LP+CjVYhAEZ$K3Y z`C$cFg2JY_VCeY=-+Uj&zG;7g4g`P%u5ZZp z5|!0W<9P!K8}%#qW;_xLOLPYuY>AM&;7;*2%Ka)w_&|DnW<7zBfuV+5*S7N5BOB6h zmESVLpRT*^FS+x1?o~mnI%qoxr$y5JPEIJI@#<3{wdbj2OUd-Ahz6bX@?=T9&eeoM zQ(Oc&Fy1~+QcDC@5?!FXavl?Q#4Y_#7|QYYtPW8T`7BH)Q`A>~Te;>I9_iL8_=G6d zDvX|PE{|P=ml30c|TZE9b-5YqQ-;cl~&uB+z8y8T0kYGHPNJi!{ATrcec6sW`{9F;&u_{O*X7p* zOpzyQhGx^BdK+e1K9$PC#Ph5#E9Z~cj%rkWSJcRm;KVxJ5sj`ALHf7Mk@wozfa<{z z5vLUI8@CJ$y_wc2rd^K-52wdg?kr0_%j?Sef&$<|pP`CH{G?$q2v_xQ-1SE8?20}L%Kiv>Sd=3*^5`b}bp!|vTYf(Y`@&3yI0 z91Vog1+?KK`ZY8-oT|v?2{dJji;KU#qCsy7TuyncE5Gu7?L&37rmmDqM}Nl|O6yH* zbQ=3C(RtZD#P@dI$&Pi?d2qxo65bAx8=n7dvk76m-cY)sC;G+r`hPQy+E#g#uA6!X zik)R2L(i)n%vdy@>Z9#{4^#oVPqF^BR+l%{SU`QkM%Pb$eb5DfftwLeufBcRGamoJ z86Y_NzLK_Cnf9la4$!>|$4CCb%#rs=VrqJ-+D*~bwAN~-PU?5hRD*MIjk#vCS71XT zaFmZgMsF(0ETxr|qm1f`&flB8_e1?$(1}`Q5@ye?otpL>|5+w|=wJh`Uw5H^&z=*G zix54o;?RO;?ECKEyb2|?dysgmpQbMZw|1$9?OEZG9#v}hEAjkhn2-fq||yvbBs$A|If%M=b@$>Ru2 zRd|0WsdtVHvR6}8{Q=5r*j#74(~gkBcHW7pHB-KOCx*-%Z~!a?ect|8V#wku4dR^# zC7O;A+x0plK4h6B0(CrYBvbUPto=yVXq`` z2j$k3meMvPPB{08o4YUA)l{$G&CBNWYO5(Fx<)-S3d_~Yo5&&Ab4fJjfc1MW2d4hH z)df`Tsoz|?7{HEaP@^$BCZ<@w)8L00A*B$5`8HHeBAw0C8KbYJv~yN~2q)3^>?#g3 zlgDoX0aPiCn3guC<})$1NymDkfS6UzASr1^swc|Z{>&6p7fJ2>d%ThL8nErs&mPC6 ziqp79zCdZK3|bU@*S3hT9ico!&T8!oA=Zx7?0ckY@4qC}Ycg|I@$2nbig+?JDl+Ml z@Ec&LveD@ih8QBZIS>;OSJ}Sh{yEXmv}&;Nltl5(A(>;W44xSL^WqjROhR=wQ=ufX zSD6MG?qH=^k+n0Iop=%Srg&=_Xl5R^}AO$EK<`BgU|Yp=7=I3=KdS=P52WR@{e|Q^C?-fZLPi3B7NeFs-mdyRu@%rq#l?;-iaAXpWG`UUg1p{zd*f*W+M2fRx+S(#TL_PLx%@D! z@93?cviNzku3=lLqDh!Gx+GSvg=`E?D!QDq9TY_qS`=jYf!DOpYMMTY`U=olZTpdXGId5K1xj2krBcw;J#&H%KX^pt{91O1AKT#W z=Y|{H4eoPI_|3LP$Km>F507OEPLC#E|WQ$Ll80MD2p z>+jK#5h_}VZ>$;>!nzVT!N8ux!&!4_wk|n!VcZ#!qUG+g^Aok+@Vm+ga>=HI9VHyQ zRHMdbx+uybJu;2ECyusPWuxhMjaXvnQ5%FW>8Go^d%f2h=jnF(!p`KE>ysTZ1F8UT zIyBTOh8R|41UWLH+?>KG4wGRH$A16j3`o{SQz%mvPz@ZX*0CvU50?TRIh>~+r&x;7 zGfjx5FA`Ka_ochH+Gp@nXOM2%jT%W}{P>O%&((-`>G$+|I--QJJTR?@k{R?!M(qsp z-k_}yTKIk_Dzca*|IXQ_L20Fki~rsgvpI}pY@lME>HC5hCw;mF33JqL_prZ1FRSHI zLu;P(Q$qoh@4Ez!PblRuFT!U=mGei19z1sCzo#hh%nH}&5cbF2$k1U=qaKVyugBVb z`k^fikXHE9C$v6oh7!mnwcbm5*oLiY6->)rey(JyCR?2i-61Y-p-hW_h%IJl)l*n&7aCcTv?R2r@z4IJQes*{4bh&d$er;2ji;mE~ z7#_$NZXly}n)A4n{c>Z&Id75iYbh`Mz$I~^|5HjL(G39*jS-OO4;kWSNK^^<{@7VtRuFnF7D#S^|k+ zBm1)a$I42P;>iIJ=@Dl`eMlKRhPUWTAn3j&wi2ZUSqr}!0oGblMcxuUH`Yp85;+`L z7&$cpl{dqy(-B-g)6N8(BnxLfWDS9x8+%rE{jzbUrb+jr_E{Sxle~mvqYVyG0&CMh zdWzyB19!TIwxcMjW-$mvA-F<)QvR&3TT>!!E$^K=S_jecwzJ>D+dO}@l#8O38E}JY z6xHx=6oXi}rVm}eD8JMPMj#V-nJ(c{r^8A&Ca#R*dL~!dGz}wX6tKCZ_cN=Pckk(b zjkHXj>_mW($!7^tlS;f2k$F3K>1+QE(*w136#jc0%I9=eafHOyZ?MdvCYzZXm1AvW z=!+J7YWIjaLgVEbvxgPgv+1KFm*S1j7`ccK4k|E(#ter*q&srmbl#SN(HQtS6M3b4i3f^%Sli98 zmi9Z$)fLTFZdK&Vlk$jO8g}YH+9e|`QIc?SactP42&bJ1B=MFoZJfBl6})K_zqp0u z%Vl+g7S%sa28V3w4?!84FoCSA3q|s>DE7hy=~8##Df#Ht_-h>A+$`5q^P~l&SU#4h z#D#ve|1O~x!(T?-B+*x0Thnv=F`I&MIdX%Akxda(ZC-(WR`+3T>$fMEkm5WEmb}J< zuz?zZF4W;r`($#1PHM!e8=!C&#@GAY+VDA?2@lwJr!`)d4H8ho4N2zo(Tlk8W@H2U z8i1of-LfU8dEeUaM@yiD(@DW0fAX#!83Yyx$?&FtdNz0(7f|%^F?>tlns(j1K{{;j zcvJiG=damQZ#kt-M(^#?d5b~SKcgv+`c?TBW|QZK{iodz4l}qZqs|3G!-V}Fq_25a zEmLH3@5C=P_{2KFB#*W$oq4Sbvd9@!=p*2`S>l09POdIepQ|Xi_$4LW9xWC{4Zxa2 z+$?piGalonGb60L5u-sccZphm^{%&6bhMG7Px1c5h)ufm(Of_=&rfZHu4hTbd_&wD zFPJ#0$WiTNARhaBI^<<2mTn}pMAvRp-V8~?ZdgW=3OQn*tmlD_1Vla*Y) zy@t-0z;y&I9PNvm%KXGH$3`eQ-~HHkW@j@x^N8Z3K9iQ6jS=UvJIPH?1+3&Z-+U$! zNai3Pyo#aji=@Qr^B50 zI0(m2y+nL(5h;z2gM*st_SRLAu&}X1NP+^7k>Kyx|3hGeV1_~TfK0luX{x%-5c$*D zi-%~#wd_nx#%UhV$K`(ol9=Hr@)wt|?fwhjnxpp4^lyR1n-^*+kN7gSoQPsW)gWpu zP_F;v3gcy@2VD3=No8)CK@)T&JXM0aySSS{w}_gVau{F# zo;q3#taTV;BfQOGFVz0Le83ijHmNryk5o6UrBLrv+0h3IU9P56zXELoQp$h&4B-kB z)k^TLGD+GfVROY64so4a@?xl|b^cOC)PD++h|%+p#Y-d3?Sa12=0>*r&_%jji1xq# z!k8hpLW|>7n?p_a_Lze9wl@_VyNUmW{@0Q}W^cd*n>}LY{h}6>XO1blZ6SoKj+INr zWmRLCA&*0RpxN}VQ92i`+Zq8al8Sp~c7tdb*pEME@ z8#<8PW~pk_0LggE<>6$JG?0Ae1S@`%p-}rTwnFLu(h|`fnCU8*!!WZdbq2D%tRZ^E z?7IIuliqF68VHLhHzhj!b`Ubj)hcjR^KvyayDxG6{b3(S=@T<2=PN1%W2Ta#@K90x z%$x%;GaFF%=tvZ^NR|IxX5(k?6au0*>2!FI%a8q87rZ2<-Mdd8AM*bres!B6DOP%C zz7{Q_Zx8NJ4K>HBOP$^n<1Xw!JW?{)#`*UPi5mW-W60^OvgTQ?o0xcPY@+aaqIxgi zJ>wOxh{69glw4qK%1nRfq!aK@m!tpve3qmL$8S{aKQT_U#9{~Ag!ud1TNVdetkll_ z2z0vu_dkPL@Jw)^7#TY*bsc@QkRIe*d#e^_%0)5N&Gr%4lmFbWI67_Gq8vhtSIo4! z`t26FxS2K@ueyWAEHte3=Krm}ZnO5mL#U^(?5tDuyBKmdg{(2mD#ch0!WkXh|H_0Q z;TFBgR!6W#d)iH2k^9=}Siapyp8dk=NJyeM55{_@ga5li8^%v3KBf&fjt+*k%DPi! zW(i6E`%%c4-JLUS!?FbQoR~oaZ5YlFEoNV2=a~S)*uDRXI*gr`SKOCZ_Fw2ulh7LQ z+glKnV&BkjIeBDC*8V4ULUA2q>Y9Hc<|?7Q&%>tidacpVeVzGtm3EtTM&o82)2_*1 zY>xH4fn{^5N`3m8d$x4O7bkM9vHjn&prNsQkwMb?&a{fg0g7FwR_Gkt5_-a5Ad*9o zgx%}!@m~?lEB&i<%G#l18xVIRNEpULEX&6}+IHev|Mv?a_ZVlNq?TD8K5zF`$S+V2 zFRB@)S!??LGHTDPP$;b1LrDfSKC)KjFTvyuKk%9=JIbW*}p zExB|%*q_UPv=%MBLiROuWx^!uKc)2<0M>nUep}WkM_*#G{hEQLT?BR;yb!WffM^>X7+FTdF!=mG=$)*Z;eX(EvUG>IJ9}J&uR1=zYvE&e9 zXI8MsqbwVqq>7ZS6UAfCWZQI{$o6%+>?^3!78n$Sw_d91lV4-ir@7itpM({#XY^yJg>1LgR;tk>$H(Un%ltMcE5R zK1SCWtuUqUS#Zp{Y*bNI-L%C0#aE{&Mjz!2j%p*Lqqw01$J_89zyPNP&h|Gb;R6Af zvHC-BpYxZN_hYp&j{YHN@P} z!^*b742suO803O9X*{+A^)^raw&E8+W}rHPRGW?8)tf~7HB#E2`^&+_(O@ph2KAoj zW?K5#Zn>xrWz+hIu$iUs!KN3FPAD zzH5*$UTg~lU>X^7TiYaRNsL|~C_2x44~@U8NmkO-WOR0RcGwsa`66qSb*{r>Fw>$6 zEvu+dxKm#N5P4X@Uhu{(KZ+kzX6T@S5Z!0DeJnxM~J?@8x3ryo99W1iLb49X&rBZ9iz1C>|#MAX0Az6Xb2o8x!5d}?)V{Q zGE}j5N^NqN6(a*f44C5ynA@=8)NxL&@?`Kme!@%eLQKxA=; zlDRhn7*G7gMX`J5L^n8Q_n8{x+UI*dKgESlo}Q!q`52x%*?XmpGb++fM>NWX$kK-- z9xpFeLx@+RJvmR##8bsdo?;p98EeX~z<0!ex;5r@9l;GJ8P>is-QW6!Jo}Tl z>$Bx(;%7Yah`JO|%g7A@1vYUFlML(!7gg`l8PSq6q zJlw8zL4yqt@Zhelu08WNyYpKD1y8R^ZC{kOA)S^tH|tGJO^wM0RQNIoIBz9{5%q$k z5*N7xC>`P;0qQpM{P}?E5rN|M$w;0St~{?`y0WHbir48^A6$Mo)A>nl7GNQ87oedc zJ&m<9nQb`!?6mng{n8Cw2`6%pG#uWx+g}m~>T1})6W_GR7x}=ey-^)_uO{j#6PnQV zQSB-q(B(FF9b{&bv8D$|x}R&D2m72MnVS{BkMBg`Jz~R#oE#BEfqC9CZG#2yPEir_?N}G8MAMhNMJPb>PsPXS>}$*R7&+cO9nGt!_hTa}A+wN7pz3qL zJ?a7PvU~padj9X$lm|^}5Xzy|if1?)tMUVW$~NaIa)_IV`C|~Q?bTj=Z{Bq%at^RC z4r&C&j9y=!_DZu~&ma#nO9;&7iaw-5@B=M7`U$#U1-YpTr&(^vL7mL%!O%gvGz1eXxu#bp@frH)WcwGHh=g;LmAL|*|JmUPCl|5rIqLDF0 zA&wP=iL4QDDl(0;5VWl91U-BJM`crIi)l}0h2BLC$ytT$7P3(ZSEOpHs*;@tQ`XgV zk-8wc*ckX)nOx7G7n#9+HMx$jim{msL_^pZ9oXQYt%>K$ z9-zpNlR>AoRn+zi-6)ItH()++;7=}q&-Y%t#_HML9LqU)Rm4BL1z0Inx7^=Y1Down zgd!#=Y4B}K?d|O|jrdJZ_F-S5_sk~!pLlDEpKk&et=BC}Mn>xm8XDTzW`Lb!T?KAN z4eGN46@A!olHz@a0BY)w1cv+@2HWW7JL`g{d`+$zLCd`E2P#ex9UZh1G2ZPn#m9!! z7z=LNfbTOeL3IH@k`i{_s_SAupZ(e2X3>cxVQ)_7bB;xN*U4#rIy2B`e4xIB51dzz7v(eOfyzRkK6RFK#D5UAjCpS~UW zD8&Q?gsl81DhI^yuucNk9mlN-kpxYHpn3_c5aT+}!UTF^9vh{_Kv+Toj^t__B^@O+ zmOoTL@;4yRFi~^iJwTVUso4SG^JZlx<@Wxu``Nt7mf&1Aj&EJc{v2b9Ad*e) z)!%a1sP{FcZY>ezRy!POz@_aoiVZ_7X%VKWiu^@W1~MAV`{fNypNAz@B!|J zt5uJnUL)*36oB>!AD!xWtT3LaGix`p6CCi+yjPLo!-L0w%Z0|gh@ z1zsyI9%kJ(qD}%yzQ?uY629g3XG7@l62c{n(y~1Ldn%iv)GSMmn%7fVf`&KK{vC-_ zjvYkL@~YJ6djYm;6)n#r2ET``p<&g4m?&hKr*+g^Qv9`~R7!#f=hXddQBuV&_XUcB zV(c=?aLOwLgKYbSJy9zIff>artWf_SO_QK^LP0x5xZ-_cVqtQ1ws@$sDAL0N!$nA2 zkClxLH+;jBa69%8l`_~Fxi=6EL@ds<-L8*?Hflf=Wjyz#ojN(}hkI?|&6&ItjLH^O$=C-9!_Zam(y#ur zt2{6_JcPpN`xYO6%{BfhO_uw%-B-j$`)Bzm`tH!s!ec6%7nJoPL~&p{0J|NCbU?r8k5OIQY5P53s(?dV*-fUD?pcXndTl_hFi19g|0( z7DdfHl7`h@b+O(>)ZEY3+#tLB5|L2UD9dfhvO)gccJRL4b?gqa|u$h1Ro;j(wd+aOV(z@JbF>hfwfIp^kG=dXuhggk5o4&g@b z08A|2Yv1&gXR1l~-Ll7r>>Szr7XS? zGsCU#uECi2rLF`re@N|{TePb8Xb6{gyGYV-g~=D5Cua5(Dzkyo+WPa^aJ%s$3Khb2 zZFP12kCpfMk_pfjFZ(#>)!r<`iq6oQK%K zA-QStPPt1gB$CL|$P7DZqWEpN)1VKpiK+Px--(+6e0rX&&?9iKd$z`FCC1I|Nx)HQ z0-}v9wOu3HN2x;K=W=s(o)iAFxQw#RSrPmg7P8FL?evlLlgx(j4d2j;S+Jxw9d@wY zE07}tJ|v?n=@! zvUNFi6S?`shW6d;hvbvCk~*DS!2e^%C^F5(P4~TbzMf76%B?(Pr$79x7~?g2e?JM} zDzaJ84sM@5Ik-srA#mfnJ7qbJC@SpPeXZi`EPAc4*)0q@lE@r7;ecWKBj+VjyI$fZ zIpC89qrNuGKzwz}EUWX31I?my@K(FOU9xkEL&mdn4 z^SL^E*1um;Son^j$*rcSTzhGwkA~5~>w>Y{!wsO-*L2#hu#-%hGqLqL)QuN`~ z>XA}hTYFtvv*xG4&T}v4?T4Z^BotG0a_MirOHn|Vub-CVZ}f&f;DuyB?ELR{0RWs6 z;D53j9Ncf^v(ce|Qobq27MMA^rV!b~q9TsG5|v!7`h zN@Prp&*#gC3Ls3^dWxv&)FhH5r&iH)zZLe?>pV!0mC1~QF8OBMarsoLC>z;-(rrusXg>g6#nDUPSq@a8{{eO$a- zt9{%``S7+1*{_ctw^>v-C{deJ^1*Pk z7{3fW+RlYN{5MoJ+>9SBM7uU4wNqqaz)D9MZm1P$PY`yan#1$QMp6Ga#Dz!?ObIo^ zQ1$A{)*Qps3j2TlL|?L>wc{mG{BItd_U+?iVZ|w(*NRCNM5ZwljD>RvCrjTSbn&Fo z4;7E1jqvSTk+Jpu$A{XCe(KB_mnYJTL(MQ7pt(*|%UQ);t#7*F5~fBR7w~@_2_H!H z9loZW+rj8JhC^lK=D%bJh+jHg=>7b{sqwFJTQo$xN$M~fi83sae(|M=IRktAMM+{l zVIq!H9z<=sxOda7Kx>-WedMK7h?ratPtm<9JHS>M9uy*%^FD0MvgCzHl~Vo&-r(TM1Y7l6(-d9KGp(>fCw&Fl>&o+ zeO^3~hNW11{DG<$)fApVweQno+E}M!x=Igg{UHgulvk7mOP(xCn)Y+`6lm{okyWUj_CDrp1-rm z0981ONiLkF^$GKz)x(2*hVhGob*}+Z*9Cm9kM?Ura-YkYGF7}Jb&^7R@dhtl%bxKh<+4QV3dS`m%`FowT(B;avIgx%YX;p|@@ z;|o;+3I9vW81mLT<;L%0>E&DEPcoH=3{%qgtv|k){Q2p>j_Fryx#@D+uaN7`vUWe@ zhGvPJl{N(5TQB>{`&!tj=^CKPRWoG;v*H53Cf9y;U6zse~5{G5l{^s`R~nu zB_&I*?c@h^0LEH{A!)ihxZdl0Ys{hXFFi7{m&qS;v>lIxWynOke(mc`PdXH?mdfY8L# zKsoxJk5yGFHubA`DT1(uFk#RfIRNmSn|phSV2;#Nru+3{aj~>SDnRUX-)(B7@{|tL zeU8;Ri0VK?@ZQiRZps4d}235vxbBw52)a*;rQ%{^I$j!aQPjSq+P-?8BkSmf)h?HvGpKyVq*|g-P%)f`rn&1 zhM0_g!!K@(L=LW4Ikb(qtEl3ZdP^IDC3~)y3};-4MnD!1=>=0TLBs#Y)KvyV^?Yrl zo25ix=>`D>fu*~<1OY)nx^@Yr5m>rKq*J6*LPEMbr5mJEQV@8rKg9p*2R?Z3&YgSb z%*>hRobx;f<;wnsm z?uNsXC>-yr1PM6=T_T@>ULw%s0Xf|oz8~f2*MPnKviX#gg+={=j;1CZPzg$=m)OUH z+?V|K96K;}ob$kZvO`&>&aPah*REKK|LjHlA$CZKA?+4D_g{eGQd z0E;6Z9)&dSO9n??ecTtf4iR|1OUN*Dl+6804NF2PL8O{yrz(ql=M%5z$7}u zN$CJDBCfMjMtFloGiQ^hIR%stss;G%Cv4sy=us09cZHM#VDQ+EAO9iX79NwXE)T9G z;IoFC!WF|j%B+Em4@ThVq@M1zR0DHhR81?Q>6l{lF!U&?iT60$9kDA*pO|3{ zM}~++^BRQ-zTU^#RD@^_ym6faIyt&FnC{GX4}=<8yWenb2oy<${gEQMrbLtaVRZAu z$hw>3nInqq{${h zBp572@uTVrIs&EEya5?PCjKBC{L&kLAfCdM_OEH3F$c z;rzS8?>0S`14x+fwj#Nvi%7W}jfzBqXCcFSoIl_|LPX$$|<+Oa8CkF-wh5 z1`|Q7A33BBHQin{O_sajsGImsmG!NPi6NpB=_g{E;F=t6j(qyrMs=eWF#Jc1c7VIG z1HQep^F2d4}@JFi<#1*n1)Kbj6Vqg6kTh4g$NS#z$F zX*#hMRnd22E`r)Lu3fE%G4pJkBDF64Att65xLs4dk&>oE-T)$RYe2g0^P@`Gs?1AN zUj$za5)i=9wt7ifLS?`jjB1Pbw8meu%_b%9;ZeU&(yZ=IkYtqUJ+_XUAYfn+RWdc4FY6)d47a``v9AN;sR2z zMet53z|GHpKz!!w)iT}Q;2Rt4wWPNSE~ktv3e4?#ci8T>3U&&RGgX1if>IzBka7#O9!Y^lOFl_{{$oS*ww4mBom&JC;=Q&Y@w@HF6X34nnFI7gXQw+G5J2=KSCCj3MGDk zp7{^tRMXxdw#g&4 z0jQXJT>oA|L;a18K!dlGVrq|&c}H8?{R-XReAZ6BKj#VS65N1jjf|+>~}0s zAMXlVS{HYw{rY5IpoPYt9ODSh*wk0Il`EJS9?28#1S&Fa2-_)(=XZs0tVK9t8O`G*@?1Q2^|p2v-AxZqoK_2C7=4RZEvsLOnqI zcrs=Q8A63#W;*TkRLw@VYw74+}|A#tKDS zHbgRDs#$`SQHs$cf$`e;*2Pdv*aTu!!>JgdgHZcBe})uYRQ9p0(O2%x^~7=F$cZ8x zB%9QX^w9OU0>;bu_`WeFo{9#@0&d3YCP5Z(PQpWmX?ucO9F=T!oe`K%P5G7_1t=ZlHpWK)aZI!=kA!-y@a8p zaHY1lnLYX9z3;0-=Y|b5lwkL^&a_LO?_XJ;cjKUg9~1eu9RJY^ck}PQ1{k+od)_py zoRDlMH4FxZN@$rFZN^|Mqo5I?1gMoi%))hxBV<$>E(g8SwZNLEhP&i-JteXYBV2RI zT44|Ur3+Xrb=SGYY_FD%&JK?hdZCNO@5t_>>9mnp0;7gH$Gfk?wG7sS&#Z zc?x7)SZ@hXKCU?FGmZNVo1<&bA$^lku>jW;qdy2twwkxB$%F91Wml7QzY-z-)@v>O z&$hZPJE8;(*RJ#q%zc(+ZA-{c6hlI^s+DTheN9pD(E%mxY!vrsO^jtEwDG{G&X-(l z=4Hip5hmsK25(%MQ&iyBty@xkAas4Lq7H9r+aob@{9q0uVn6DDvKGgaUO09CzR~m| z3_A!2CKhJ+RfJtn=%c6Y@UUaP~N6~h-yH&CEmk`Xm44tf%c)g6hLMEOkt~P5E zWwDDj*_?JPmO;zTiY1wcR1*uSqcZ{OZEiw951)kVd<^a7{j&CQOl~>3|8VEOoWM2neI(GM%zwSHkH^7ox zn2d%W;30Tt&Mg--@tVXhqBT*suiKH{t*=0_Aa9| zK{t7bn{L;J_!Rz4xgRSRgyMQA1n9m9%fK4|XXnLy4FqtFq0PVZE-wLLAj@TXbdI(9 zl_W9bw(pg0=?~W+D^vK0;UF_Gk`$ZN6Div_$;6OGs2Z%8W@?Grgd7qG#ie;fbLkW? zX^upL9ZyjE0h9t27}-WaiJ`VUl6CQqd_vp#a|2`w(48%QnCj-rZI!UTI31f>dJlEgF9isX>kA!^l1gB6zjj zVNMqPJ8)!TU_~Wnx)Px~A-?*G5=k9RlSK0>Mm1DI`i*^m5XgKk{z51(ILsz$C&E46(nP*@1zvUzZ;DD-)3+_Pp|?Gh`)7$03EkqS9{P0$uIa`2gY<3BH$O#LZ$6&M+6DAZ2;!gIWvHe5)z4surU=Mv zovSx(q#e3K)Z5F}=Kf_Svm3;;;FY^f%jnFkQ>%IB4!vVWSJK$%!P{TA`KQ7|d`1h) z>#hWzLFfJP!<6!F!mKiyA_cE=)~=dv-$%dmd+kdUAx60(hxir`1-kTl+PY#AX$=pM z78Va+LS}@=79)mDLUGi)H#u)sI4`x}4QXORHMsYM!{{O z*JGj)$V=(Jd(!jN0$c%(IRt;j;9*VpkO_yD%k^y2^=!&jJ3k#Zsr`YR&PGRL@g`Vd z_EE6PoR2=v-Hd-dYSzefdUA$NO38N9VWem^AOwqGbew}jLb8A1rh-V!tOnj%b;;+g zIG52UUWAqL`A501LTF?(4Hd46n@>*|@+8_NB=|%?y4AhFK}?0xKnvnEfgiAn7W7?& zTELEBN);3Gjy4ZNj{(tRt@jcg?s{KK{1cD=TShj zf23=69z=5MB-s0iU95=zcbe;FQp|bf-1=G>1SIz;ELI?e3-Ao)Y>0)U#@Ps;ZPP!c z4g|6sguNn$Uk0l{Y-%O^MCC!&n0$nOgt+tt-4eE~>~PQxK>iy4lroaK^##64*sxxu zs_&GP97j9(;7DNRUx?K)Mv_gST0o6nU@uh6vvpdD-rz`6g;}P78RJ8=E$EV8OLpJg z*HoN_wkG*G;3MH@aqB##Lud*4-CIz=N}QU2lyw~Y#`?|=sZBge1qav)+a;RVs*)g% zt%sv%2S)d?bf3bnNjp~w94p?@O6Q#k=thTyAS(Dr5x5Dgd6KRBE*7LQkuc;jf zGb?AXo;>DWd-Qe8#ef}mmHQv{OfWLB4F!`$7@%kAqLjz>a<-qa!-nU>M5rRu=Asqa z7u9YYnV&0rwm0;4EUpEiK6Spqws6p{CK5ooh7k_pGfqzKa3S>mo z4WlaCG;9JD>vu+CZozXZnrhsLe`%DVqGGni8^~^{lm(LlnLO61%JYzv77XpePqnEZ zQl}4-?t-F@PAvzA=|UwJ)YGxk;0qfuBaJksv|r6xO9XeY+FEB9R#RK<1%>x(3_6R{1ndZp zkMc%-w<%@sqn{Vye06SZvHZQ|L6ioArXI3klq9FiS!y%ZwcJ5hbFNGE9`um!TiMmM zH&WVu^oh(*I5lO&ei{3#G6e&As$8O$->tj}4jB^HB6>QHHAiP{r0U`^rI?Zv?~Kf` z?Ln-vzJ!D;SLIm}j9@)I+l~H}+Db#|R$?Q5RAi2|gwBdUTSDyiYg*DG?1J%2RxV>} zlhqHTXhDRfFBg(U#6IO7X7W%O*8PQN=W2%M!QzjFtTh?Mt=Jh$8h$Rf&K9jm_ss-*$Q*-jMjBR<+p1hd4dEw2bR~vLIs_re1F!DpqrDilgGT;`5&kq+L!sGd19JbGNZb^aVh!^4 zS@%r6@3TuCo&30Alk3ecFXhR~w^ob|HM9TbF|0mt!daXYrW5jDFoRntbRWWokr?7n zMnL+xLjpLZ+J)2dJL7q-b~W#KosFoU5Ms~?#L8bIV4?i|c6{^~`};{jBuREqp?p{g zAwO9S>=N_N}syCKO1WUalZY{F_`Xaf!BW zCc{7TERyPUH~iZVY(XuqHk-t;fAS#hUITf@@>4hTB`iG1Bq%9aKp{wiohNPbCh5u1 zy@hejsE=&D7B;A7B$QL*%L5Z7H-`=teN*|XnFWl|w%MgPa`8$_yR!t^xXxS6F!TVV z`IG16@_vp|1-invA3wF+n_~2c{1hsKrzw*CSk_ZY>DgREXkEWu zW=@)VVT_sc5zWuAvQJwOwR9ENaLXcN$kBk97y6nyy&`bO6zzrZ>a z&C$btmG>G}Ha4V{nt#_2qI@SQy%AONTVEiw)n zeE*({xC}iC{MgLQ^oM!2=Po3%37YtfXJUgW_4E2S>Vm5p3!aOUx${(MzW5Ru>cZrwbK8Uik<4lPaO@5z-UA5%r`EmNfxigF&FSc56qG zWT+Q=^*OClby=QVU45OS!)rKEyOp|ndE?$wdm|kki>51abWXUW@yMei4VJC_t@jIQ znL&pS6Zf$Cg}SMf6Zbsa>zwwC1RQSHhi<3Q=3nrv0~E?(8Dv^{YltSM zeu<@(MM??^ecFdEm0uM;!7$LUM3e{_wR)#utDh??l~4>+nU|KH@s1nypbvGp5ha^` zb6pPSZ^Cq;g!ND5RDG5QgiGZtWMw-=Kpf=Z>^jC6;^PxDCWEEg+ogt)>*>6vky$>xNcfzz z$ScK=wv@rgEj`e7)elKl+~4uPSxZSdRp6V(b99HlIPU-XqVtzfJPj)Jm^C6HS36OkNY6zT z1IIQ@I9dm*Jj^E2wD}wQQ-G@3E{(5a0%aw#W0YUAAm6}7+q*sG9-)D=v3~k^W&;tq) z#hSOE7G=o`W554g!{0}QL?BF~?q-^De!Fs4d>YXd_(;`c5RhY^Uw{d&Ki(KBfckO= zdmtF2baCT?>!+Pa@^o#v_ZeGZoN1!$IGre9O#^ThMhH?1pVNe=3xtN2BTbOK%Dg^j zU8chR3pOfx+KUR3yhknNEoN;n!&;5p1Gd6~s$g|6++U*kyHe`s4ohl3jub%#92^}j zPh%nQ)T~CH`vK;ehx+ z#tP(?M=71|rh4g#303#}>5^{J51{t{pduCS zUR6gGE0v&Od{lb_xq|(ZyA7wfJ#=`T+Tm1BySQBXnP@5AYD5{_DZt>{WudsV8bmR* zbKb963Vkb4vn%T;HvEE&&;EU&dK$!C?B&zoXE^cCTFqKo&AiSY#1m$|*9ngn@IJ|? z`&5&v6XDA8WLBF*mHZI09Nh9$8lM#Xv!YCM%Ts@+5YJIJtd9y+FnYZG+Zt6A1EO3% zf3#e}SCQ6qOMY3hrabN3pj__n#KJB=^9*V|hbPBS51un97eIkfD;G zl`nx^LIURGBzgfV-WaIicOo&bN#%{lqn<49s7XHarlTkHt9I{g&Kl#pl67T)WB z%GvHH{y2pX+DYIw6Z_1dnEOh4)4a?Gz2fZb4XM7mWji##+bkv&SYEqp>g=FW5#-Yp zgfBr}&?n2tDhUvIAc|pY!E93XMBPGm!BiH#*zW2XL5IvUK`BBA>f2_u?#ztH#v0E* zJ|qMYNE-*+OBR8m7<3@k9Mta15ZWQU+9>9CNSS_4Vm1*=^k8CpFDVNoN5rwKj;F}P zc3slT$);VAlNP~QHA~1)s$?>9E$8Q2*dl1(HGFs~PO%FHXv3#r1nyo4t#%LKj|>JN zZ?NIuP;4Ha&%k#(c!kVV1{grC6}S0$ElD*x5g@FP#qB{gg&Pp?ZoRH^-FJta9ws;f zGwN%g`_Cb!Cuedfo~Gs>3u(|9*$NOAxWZM&(%M^Pd7YhKl$2#3_b}+G1OmVNLv&P^ z)$n0oLMv$tS|Ucnw2zOxUhX)jD^#9$%N+PHYg{7js1N7nmV^kNe~GxFQm zu&;lQbtQ>7*fB`b_3)+#cceq)%R%9F3(bNox9+~Q-;5*sdE{MI+=CH&X79*Hvt?$X zoK|e(h{;;BPVO!7wl0!rxARHZh`}jI`P?TwGuoA3t4>HY$MuTb!eeul;t82>3s+J2 z2;HL1eh_gQ3Fff8LWHWSq(V&gEs8jEYtbxy{4pB_1|qoL8ytAtooQuAyvGf>^-LOvF2!q# zDL`Y7|`46~m!>!yM7yr(KPDW1T_aXw*ieQBPSs-c5;ZRb67+Y-!?YNG)N4C8+O zgppy+4nwsmM&-4?YrM&!wJv>NK@@2z{&5q&^L=!*NZKh!CHeEJojtW)Nd{!_6*CK) zj(G4;`<7;xmZ$i$OgUvU7v*PzJ!4GH)Jd)1q|gEKcWJpI;xv%IF6#D?9+ zgI17)f8tkw43XHJg<$FLz+wLwgo-}zIfFhh_>+)jToSfz{Tr;1iiiYem56(n3~NS;nMNF?LH z)w1{)*KIITpP}Pda+6{k_J*}WyU6f?(0dy7=A5S z;ygO{i5l%0J`IW*41p5r$LRik+V_%jC{^Q#?AP4 zHsukzO~5NZQ$XW9D?>VH8uiN0X<}@?xC^62mnt;o#DdAk^D?tnWX zt4~wKbH0o|T4f3_PQW0J+ZamKhyip2)O7RCbL;D+w} z7f6LfLUUGdrqW^cLsH}etP|(AZwp>+?qvs2|(Xzy{7JgT5dz#tHE=Aw2X1Dh%JhUOt(PIuw*s5U{Y2)x% zm0&P?oo)k)JMxBMffl)yB5QO+Z4zS}%GiI}S>^vJsJ5iDV2M`sVV`@@=80Llsl-sZ z;!CxfLUBP7hi0#(q*oNxeJ=JFbYG) z3CAKPQJ5C9{1}fW?F=Hbk>p%PZk-=DeIkmULBN8n*Q#u7X?^x>Cb_&uOPaME*FXxQ z2W=QeMczqHBFh$~j(&m$=ITcpL5s(>6M&|$TAo1kwGPFWRs@WN-R(NXEiE$nz2V)R zC|@2*a&_q-1dZ*Y9n&EbBQ^*I(S@7(X`(N~y;i^3woul*)tvzEC|ipsmhRD zsNyH!e2<_Z-2Jdfo)U{t_Ww`=M?yfYDu3~5&!O(A!;N0Ac|V^KSL_O7gj*alNQa8!;GoR?if&M1sY)T;RWHB#Q->V1W#(E|>>VcU0m0q$y4R6y)LiOO$2uFt+aP zf8)i+MU*-zW8f?xAFNC#Kye&i9R(G5DI13IVkGgzltS}_#gpQP^E8?b#(cDE8?!*{ z{46X_jQ+EGX_Fk;mY;y+1Hau^=uye3A$FYV_MI~qtsY8vO_*6_dp+eS$mptqm&0=B zy#T;vD-1{l-RjE5ma-^8lz36hXL*-R2yB-Rye}MvuiFoj>xc=XSq1DIx@s=QCzmS- z8~-A%0BuHy#_0Y$P{WR-m^!wN1iHI z;1E^YqCzXDdnBs|S=(0)iCM0%znGjG6JvVP3(ovr?4C>lJ!JK@P)Ff}-pj4dEUPx+ z)*EL3c8g<;yGPrz0_gb~hBd-3<7H~5@C4;THNHLbK)fqj@M;j2s{>by%aM@kUx1Jg z?*dnK+0737bh}o(H;2kDVbAmZoNFVKA!`TT$RtiiNX4-zFP|`>(HQq98sjlPKJgCx$x6=7s9`A6MXI+az0GPHb=;dk*=Bg zGhQIWaq-EuYr0CEYwaQRR6}#x+n1cyp?|+;Eli9nbKpmI;+yNT9J51#@y{wBH^Z@% z1-YOCZl*tD_HBA??lgX{0zO*kwo{b@Y0SbN&YXWj&C= zQaMq*5Mkqb$e%85pNlt7%F#cnG))_t6?zx`kdPa<)+B{=3=c3cUJuV#Y6oYX-(#!Y zAn2;Y3V*8iBfaGE_kZv2&SUKF?YDAV=VGgn%1yUn%bdH1Q4zVEKl`WU0);RK$eCXW zFg0f9+rAS6S?eJZR#(hg{}W{1lKh6Ga1ljSS@0lcZ0j|~Q1}ylfH>ve=4i^v#JsXh zO*xq4bV^-T@Mb+m2gT$6RTJQ*-0+yn^bs-jqDBNwn%@&Vppy=qun4hD*QS-Hs0@9@ zuTd$B82IgMP!U7Uk-K!?M}nnwXWr}Zcy*QBWMYRM*`dbvDn>fnyJ#8>Tvk{w#oP&eb;4~L#~bD zgkCvmsh-VjHsmqwy~eZ@A`PikznZLo@rmuQJcH*RntfjAV8&r|Is;_P%wg-_)Fu^( zq^Fqe9)p>9@tc|=p8iwHmP@($DWEPW*9Jrp45hkzEc>>)P?!e(MZEl_7;+CnlFNrN zhY!>9@<59JDjZ^-IE>-^R$x~mCrvPR&!JNfB6{q^&G%+>NTtYvsqLTCf$ncLC@Ky= zH|um~o1MRe#L?Sjh>H++C|3E0e=I{vmz8tjSOBAq#JrkWpqT>;`~Anmen(h2q4ah7 zgBUdMl1pSzyPUL;_``^kG@{G)lW+I*LEX`?_R|a|Q~C5G1C>Gpum30EfP;ye$rDOQ zMC4cEOg~mRo}wb!hZup0-XWxlsd{f6_$rS`$$OO+A7&2>r!>~6Z`#i7^-GYu*I}36 z#}G$Js+cKy)ESr;xEN;loK5h4%YOQ2K44J_nq*b3II4Rrc`b?cbJU6ZfmUC+9HC<7nuyP?>FiK)OZ3nBt-_S@Zj?J2 zkeR((Y2b4yfJ1^oDl!NfliP@#y6ecbsr1Tk`9we8&B3aX z6+e0W+H5p%STBDY!b>^H_%^VVMSxrG^z1KD7d%`BM6t6(V{DxhTdUF@B>|n6Gq|Lk zjHhPOD(lW~3K(MzmR*J5RN<_d4%c%#0!630rL^VcG3?vH zl5PJ+LQ9}~NUod6hGn_vWcFyg3aN2WzKV7*LLUPTwMZH$c=nNL4I_6|L z;3?tqE9~0$k)d#N&mJbwRgO)TnSy72BX04iB_$IxQ~QP9y}i(?3x0r1n-G#|9E-So zJ~$_i^R6q3rjy9M??*6MoP9$5Rv5|SzR}pNuPQ>h+{LWle_%^m(@o3&vzy>mMVYD$ zUEvJKo5WC+Dq$!7#7v4_iGM6YM2pW$s^Y9ug|-rW14vJ|l)}b6SKdFr-(=L(V%3GXGG6St1vQL zElY>Ox4;koh3HLSp5$7E7nz&k3m)D@suquJCot8HoYq|Ljev&0Qp#vFKVjQ?{VCha zskAq*z_V*(C7%xOhZWHlPbCAtV%xyH}OO7l6Z6%<_mUE}itw3E4F- zZRs1o<+iai%lrwu`<5HLC@je!7mXt z?HY_-dC#&^7a$??aA@aan%7HY>-bBOW-{^s?miMzG*k7!cNQa;+uQ?HHSydFu8z?=ZT&Bz6x@0+xAvHyM1mWAJb-@hZ7xSn?u73&ydCl(aI|~f)gHL54*tY@ zR3Ab(ffiaYx$P8voqr(AbbX)GaGk()DkASf5*kmCBs_^>pkrWUU}50kSe%WLYnMuJ zy_3y`Y@B#`<}FkB;R5yV+Onf8-}Cy&J&#uu1DXnJ=;a`k_ zSta7*c(=x_2`ob}yytJ=%`Tm%jC4_i9+|!s%kP`FJ~-3Rcm>BE?V^*r+}i_2H!;wuntRr*b-eN{M zBYTI{oCU1-lPVs7R(tNOxwiJ5+ge?lk!p7p0q^36?p17U3k@t3`5OswbtR4f=D(ib zW2}d6@0CKq{JV0hZx`JkA3t=}&}DX9NuZ;kpwlcxxk_#tj$JT}`4p6IDNCpl8LV6X zSa6X$zky$qH+^i#Hr-@_dhPkKz%SCfJeIY`!fhq59xK-jMzH= zp0l}eNAAHD>A~MA%15akE8W{GP2g7NtMxoYDTRLfO(fCOvxUFZ|s zxnMehfVmGBb)H<)6qT!mEw;yFeq%%GuY&K~HOC6B{NDHwLa+RWwQjh4Bhf?T zq>MtvwO+F>^d6-LYR@|3S$G=5NeROhPeWlWPGRs6^G5kN?+=nm3Lyq{uhwmGJ5|${ zdwvJd@@OKx`~oe{?NbFiea>YvR9gbglpYcFOzn(5vHQ2L8I%`gK5|Y&S@#@Qz zP;al_hi@P7{VY_@h<$aN%o{i>)?Mo;Dp6X!qQH)3UA){r$#i`>zrMVFLH&f<^`MRq zDC7wFt+tk1^2D?=@|YnJU25k$Tcqvcde2rYQ5qknwaQ9B>>!})AtP$=5&P2VvN}$o z26kBVD~0kq+ZxwYtgb)qz=9&LYUSZ7dJt1dIaMemRFA=bChw>&kTFIf~3f zY}g;hP@*Z_gczJin#GByn{>Ky0{4dVA@996 zton!`sp7EN-4*dIjNc-C_vbLtT-92~i+2EP#(d-VIQ>a?R<5q+7o(O^%SHU+5%Dht zDd>cd+AvKBN*Gv&JNdoGpCa;hW3jW~%v3tRMcHB0K{F-a?tm2fGPl$zl8nv{r5+Sg z%V^I&?Qq=#B!11nL!)1skLH@_@@{YP3#!5;2 z7j>=ip)h8(4;;+MIxf^|z9d@8XJ)H=R^s|vMBvGG)+A#-M7fU2sLG*4eQwkM_(}u6 zFa}C)9+EW*(|zuly@7yWbh;I?Q2>mz^yDSpQP9CjcU6CLPpIRGX@K;#vx~-KCj%P= z=}+9%0g|;QX2U*&D&`)^ZVCL<&bb4Jf#dj}@~2}y>Y}`?`IJ$v-i;$SOu=w zIt?Ek7tj3SOk12-!qNWm@A5fUWAJT>?Co%DfTAbA$IJ+#@W&*pv~U*O6bX27r})!M zKM5oEI)BdJ=)UaC*eU&eE-jBBnNVtyvq(T(T3_o+gL6phnhdRVg%xE9eyZUs&p`Z2 z-POZ(I*>>a+q@9m!0 z4qCpjiq6G&a(nV3Ek*c;^#dAojo=r-8YkC6rXQ(h4yQo=sE zy5nvPF~H{_mR*G9F7N2I(yOD@4#UK`_Uuv57!d1HwDw(b9F=vvgwI!U!x+0!xROrs+`_RDaFYzHUCAL^X3*ZJ5ApMQRc3u+)09 z<`$DmRaYl0;e1fLIJsTL@Od4~n6Vi{SLH0=@aSaWlMAM!V4Hxry1m6zv!;HI)SJ8^ z%&NQXd#Y&??)axQzq3Tj_D18fA??SmbY$fQ_-;@3m-Q>zwqgZ*-y*BWN$a) zomyYrE2)gXH$SjCzGl^II4bv=(N2jRQP+Pv5o3BA7MBPn3 zS*$*&W)+}eeaZgThHc_Z`A0U!9Tw9afzwz)XG?6rV?wy6SyxAIaS2Y5VShZ14HW$p zh5$)6jzbY960YM$I&_H!9S#a5qM`YT_j8c^Ajb6v3jrrR7pOTz91^)wo4VNhOS{-cd(X>V zNcg$X##JJJ!}-niw)YF|!V+D3EQCM*nmL)Myj-uZeQuZeZKf}G&G>Hd8;CZF);%XT zA~i>@4<#>^Qnt~F2AgKX8LBt(WOlx~;7VBtUWG{Tj#-uF%|(pHsq08(zC--4xkKsY zp&RlsquR`G<^rz@*igf94b=Z+^(1DYh3O!nF~Jn@74>z{pk|AMYtG;_G_Ot zq~`ADc?flFz;4}~Zap{O|8S*A9(p{PH$}BfIoyO8@#6zgOz#{af6w*+oCzu zTtv$E7el;nhIo*2DM^o$+0A+EB*s+Aixq9kyC42{!@HrN)qT2^K#X)FWD9}xEgVDP z4TTqiwY)<{Z%#F}G^-K^kIx}emU&65X)1ZCca3^d{ytT`4YyCvYPt?vQrh5dXt8)z zPs_{5y>V{$ErA`D_yaqF`0n1rf?vycw{%Cbc$@1XuR4#NR+J{w8qc>pbNDX8ohdXf zy;4`dWq4vKG+x8^dOrloYO9_6{SuxR$G5-IHzW9ND806deWXf-J#HKQ0Y{oGP&5Zm z^v3AK$LF_^ySx98j1flPYNg)FFp1ZdIhTHkQpkZ7XD$b@$mremLWIiutfDC!NYlR+ zJ7~BP-Q4@yq{_!Pg#Jx$e_tdc{pO@;^!C>X)%gi(DP{8F>zFK;g-d-(>jEXsPs<1& zRQ|TYIp`JWbJJrq7MpvUNVW)atpYpBQu&+^GN+WHFKmYw$bZ~RuKV^e;WuTR)3x0# zE(PI#zkFxJoGXF+8U6Q{C+RqqtSJLG6k*W%NER!=r9H=VR=aB+0U>ZJEXOmlPi

QIzJ_9ND6&v5Vt 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 LNkvXXu0mjfkq(sf literal 0 HcmV?d00001 diff --git a/docs/images/usage-dashboard.png b/docs/images/usage-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..585a626544a6a443e5f5e15c78245bb5ee69385d GIT binary patch literal 287412 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%0D`#jcUfMu`fZ{m5Fa5sQ8Nsn>!*)Z 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
N%d2je2Yum`gl1P!AI|2M^1eLEx5*;y7`YwH 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

v=VH4kMVZ-|qB(#YM? zTHS2Bca?`bm}ht=_4!reo2<(f!6zl^r7Y6*f|_mj#~ia{R2LvqQ{m6GKHCY#7%d-{ z8%$cCAD=meUE2p$wN}#%zfcY z@V$A3tLJsPLyJhN@yX4=(%pwvr|DGb>R^}N>pb$guFYaK2sTq8X8)q6kbIDJ+#f4= zcE@F`*iG00mORklE4K|py^tg*o;LIpgMEv}+WBaxgX6aG2;;^#SUJ$!@K3#*+}S2n z=s(LuJ;pxb49!3!VA)a0Z8R2r_aYWqgH5@Hv_eZR?Rk)w$enAKqq6nZKnj(`NZ( zJ`}gZW%YWd>{YQUcb$e1dgp5xMxYAo$2F!CXoj_ry??iYx2G~3EaI0&6-9NF_l3en8j$g< zaj5wcIY4_{)LCb5u2!w>;aXlV%y)Of+Bmm2R|F5y$$&}!#q#%++F!;841P2!l!r~V z%!hT2xp5ct|^*!hc zJwzA6!6$+oD^>9YNc@;@kaLR1)K@x$-8hTz=j4J=5YFo)$?9zD$eY~`YSw2I(rV+r zrE??0hrDlpS?}7aC?Y}5s`%Xc04#Y%*5OM@y%I?AHjs;shVwMZ8WQWf@@*wDeei2} zQfRf6zc{UVd2S~QHv5}z87zIn0hp@1YriK3RC88Uob=PtiN>tby!=0od$Qi*2(l=5 zdgrvOpQwM>n{Az!M6IbW4wIF0s)n`gh=EesVa~n@HusB7nH}{X!Cd3@+ncX;>Cb)>7`$E)O=3b>Sc#X#mK;;U#AP)VvlRGGJ=g!= z9z4cEMC65vgC5J^Z1*G*kAYB>_`@~1eY`EuEt)_UW;_+y;V+?fMVHH>^uAs(tS%rHS z#j9n@sNSu%Od`QL35BI`!0KYOd70wGN>oHAzuo=MLaYt)(*|GIWCLSh7_*l1Sxbk? zN;Gsoh*F8ZiptbM1i1x$s?5NQk0cJI3)e$7SpE}U*#t*kTu2;$ovXkgalotTWyMV3 z>wy#)?(aQ|;_G4IlM5|c*?6Rcv7XvD#Nv~^#Vu}N5}PbL$c%&Yy}kthDT`KI7~E6t zp{~#08i{i1&kq)@`@aDSkll_-I)p#@UCoLMxT*u51;U2P&u`qhFIDEl1~+gxu0?$s zn!MBMyR5R8g%n5+f>I64PB3-jm`M6tHmyyMzVO$JK20r2^L~;)F1z^AjohEQ?*qGjlksq|M?|9QPbuMwfgePQJ{Z^1+T^if zw6Jgrg_MO>jnibjkKT3w>-ml9>=pF5ojEa4MB#MGo1o@C+kq9tm0_|YhsjLhUoqIN z;J0uZ7gCmtO*}IswoIcFR4`LEv762#Zn=B-th7E#r~A=EvOG$=ez@4+V{Mq#$9GxS zS%4*b{p)f>@Y7*r>ieN`io$1-#KF`2?_eJh(wV(_*lYEUdS&jFeW8)5=1e{mE#c%r zZW!8ilQ7q;X)PZ?Lla|y2!PWGCG8PQ>Q325bX%d8F@+DrLXe3<6pQNUH;Ty7C!u%? zpb;m5cf|2@uhnu)Z62g)q)ydGw{O;W;>~z}z^`$2w{++;S?FC->dm`oz%e<@x@A&@=3jI3bY(QX9YA z)zl_|zx7#+jXL*I14000Cze=xJONqrtl#+5_SBEALl(4h%D?UIPkUamLJSrjc?*0# zpsfcF2CE>3E*M^43Jy}sgj+al9_48*l$NtnLa*{UfXq@h;`xp$K%y?w9%nvYDtn$} z;;Z2QOZ%UG-57xMk(NULkTmmokDkmdWlbd2O;j#>b*h8Wv?O)!s}BceSy<7d$l(Mf z)~T4-*A7cy#Eua@IM%r$a8@p-Pxra1E>P;q(2Nmen-mt=fB6qb{paO!5G9c#nTV(S zS>J=>{mz&;T|A~~!Dy*6%_G+l{k}Ep81x9lF}I~^*=xLW};2TRRM0+OKDmRoYs zEj>xk(ZKPv_DMWlW4U#3qdnSq`7wh`yzBfeO~;lN6^xrR4W_acGCx^m6iKpDa+jc> z)q3w)eyAlJa$Q!frku}%jZ14c=E8_+*#guGy4Zi$_gQ6VH7cR#Sl zIEr+1LeNtFJA3}=%wUh&7I3q=>oO?baQ0NV?HQp!azB3_9b=pbXj3uGcV%PHWCf>8 zJT@ik@d`UdrvQm9bf^j`*{xEw{mqPpN~UX+w65@Z-GJ})lGVU55N{-mJOl2p;_%n? z-gX3HIf!=cbQyjwp|}{S|VwSKGhk8Q%>)%`$eb*^OnIKM>_bf8kVK>wv4r!g-Mfq690$DKOeV) z1a>%LY*Xq5I%#ab+M-ADXt3iJ{m$#@;;{v zN?MhfM8w3(M6d6J8cM}VQUTbZ`WoRJ=)E70^~NIjy25b5jQ*dL>;K;i6Wn8gw?Ong z++%@RjIT^?2-xxQ-fkN`uN1iy7BA?7L-6TQtU9saR$B-t1qZN;^v=oK`r--aLKq@x zWJ3Dr@_7G~Fua7fq@7YU>!F!qyJ!Wrd|bBZ`89pdI~&9wHQ#Ll0BX|J2s{Kx3QWZi$Z?F8RZml z`9VEFuLCmz8LPu8!41a_yOEb$h8N6?PU1fVWnZoZTSD#zm*R%jO3$8HPA zHVAA#ZV^d-8lLE$v_Lqt&vrgzD!P#Rlf_ms`}j?+ZyIcEy^F`M21w?$BBR>I4J@ST zzfs*8i{*4Z$?TZ766fmIQ`C?PWGnKLy;07;=PjHhuoNhMr|Rmt2y6ZFB?XRIPn2Xp z4TE9J?J2_DryXw(`w1`l9H+#>8$Yz&{cyFMTPZ0~Gr%a|QbG7Pb@Mg7LpR_DAgeIC zQKpu|Rqs`lJPQgKsXtQFOvXw^ZU(fK!4o?WPXvh1`M4A_7aopkD?MqEHR`cNf#zCI zG75TsK_y5nLGdVnIPq=B)@&HLymV}4IKIcxSE}v4QM2uy&Fu6R<@S=XLg71(?OmfA z)h3r8t9eWK^~;W0O}hu)G;GJ*?iH=qPF`mStkT(^jg`D^`wPdHT5neUJhz>$r#Ot1 zQdx{5Z>+BfCYGpU|8%-9c;J3~+vG3x20udnpNF9h5BKSQh@w(b=g*-BrsR#6Z+7d_4P%z5E~G@RnZV$caStY-kSqQ z$f&YwV@@Hu8$rJcq2wz9`loQsksWMEsPa*%Z4W+8n^`ww2>3&VdUkxt!YvMzncjD` zr$-Q_JlLm@6<@E?p~iadhl$A87ah{eMhhG^@wA?t>us<0V2qn73F4{{Eqi^pPAGu5 zobRovTufH@s2C01hHMfIxL$|#akT&>{^yi*tYWSlT7gfz`_upMrYalYI}w6aW{yZt zhL4#D7DQ5yPlN=*ljmeSbHbyzt8+QkiMAw{qIYaT8JnhiFHl?^{Gy;QjP$fmNk##o zkWX>xwfRw0ylJl&VYV;80Mwy^oswtY>ZypensxexcysJE=;dBwp2O9sfPEnfd4>`! zku;2}f6#W(R#Q`xWaz)2?6P$Z3eQ-5Sx)lj;Al-<9`I_$zgkCRRcmiwiEdJ(`Ef}DZWQ(slIb_m$;>&UJ@o1u=l z8bvhu9x~@i#SD%}XupY)2su^FccZdWhp|CrGh)lxRHMuF zJ;K;H`NK;h)Mv$0cq*(uht1fCcqFhPp_vTq=ljctq=#d)B4-@e{3-44d>(oZ^Lds% zgh&N_c)#=d-?#SbFQp8yPuYU(UJ^C_t5;Q*{gz8~_vwHslqixZPWp&-HmHC2cHnJ! zccq`UKlS7GtBew`Lql=T2Tc@V9%P0>LA=bJf2 zhPr~WB;WVZy0V=xwcc?6T&bWpx;w@nSMobv^Z0Q?#+!H`bSD}<8ZD#kfcngP9EDY| zkgqLnX`JL@(O>GgMlq(3AUch);Apz|?gm|7Md|7Pq~dQBRNleMwSiZOZVwX;rtbwd zxG;>(DGD*2x(q1=o+8M7tVJ4?zRTDakXCmoAhIl_I}{?Hm$1zLa=~9MXh}O6#fMfA zBBgjust6kBHl)T=4m@&XW68W~gn7Go*`nj9xFJ7C`$tT!VUxJViq+id0xq@{S{t7B z&D*!;Fc+{C9{OF(60BtqkuzN9Qa?IWRA-dE$Oq#$Y3To}y%7-o(Dy9c;Mmu&p=^LB zahQqSXBQrqlC{7+Bg&zDDoWD*u5^>(e$r*5)qt8#|5*@!o)jYbdkoq~Lzr}<1blE^Dg?GQJ*Yg! zvj97>*cn`kT_Hz*EQOqa*`)PP{vKP*c^Q`iPG&_ylA#b*LC@yJq#M3di4v+Q2Y4xT z{86D%f?dZ#GB^~4m2cBVv3(KJWZvvfuSj?uC`g5!s9}R29e2DBm;EUgA37u~VI_%j znDjpj;pWMVOkn0ZT8jsu)Xw0DzIto>#X#cLRhS1;Gr~`ryy2@QNz*aiN@l8 z4N>Y+MdJabT9yw1PU?8J@w=;vxhB^Q$vU0FQ8&jJ){zLh+Pvga1W2}Q8>;l%?yT%q zr$8b|;R3#IMC6R3+GZ3QD|Deu`#$kYwzdV#p5)=`ql&yf6TYa=BIPMo$RbA3RV)&VrrNhXyaYF3h~=~pHd&ss z5uu%|{t(@Plob$7Mt|XV5t{ngBqKz6cz17~vbll^v&WAfON-oIN+0fZ$Pu&tC?39j z>2-C(TEMgH~<7ulOA*GGTmed=0*@5!lY)fo71@swLaN3Z|Q1r9v) z6lZR&KWt3vG--UwTiA2FAZuKkPC57R6Z+xNI+mVl&$;52-|^RyK`gWU>^I{IHV7L7 zQ}2RXwq)gsh7uX8flhBuYbx(GYVt!Q_>&H?^?qnVdClEQdZe}qJ!_-D4 z3X;!0qfun=vewN`7u4yv%23D9P`4t&>)%{^<1oaeaInK;^pue%z{3i-l&d)^NDYQkk*{U3kIv@iFQ3($10U2fX^2er1#}h0lY0`f+(=k`t3x+~y zK(D$b7hgAq!X%XVad4O24e6%u|Loyk4hbf~xPUlp*d#jy?;tv>1Iu1odl6hm?A?eb z2nLdt^+{HTJ|(zrZ0{@7$Wv2DhEh#@O+SyST}n$jG$?}k>_Zu-d6}4NqLZ>rKRQF) zc=$k}?s>U?*x7%^1uEacI|W9ztf`Fx0LeMi--_!u@JUEarYSs?fy`55;cvl;`MsB0 zso=5iNUIJZXfn+ulOPE9X8V>krP7w)lA&DUIs#p0n2t}koz_P2AY8^j)AmOQ0gFF1 zpE%BNxI``np1itjIp1>8bJa-|KtaRLwpCh9rM24KVA55{Ss<(<(k)poomArE0G^7k zU7Jm6ESbw;jK~kl-3l2t7bl0Q{_&fCGYG#E+5kn8LD809H!91B;|=GH*k;}A2wx?u zKIj7w#k46)6s~*ChKIKu6>;T@9YNWa6y<_k&vRa_lurw*3Y$jFwY(vn zS1Nm-q4#gx@>__42kuDQLTT5J2eieAMl!kvrx)N~)S4IRssMCYZCKT%A}a}GWz?(j zJ?H79P3X=|7rMn$@OE&0OEI4lrG`mmmfkw}eJ5!JO#1xf#zf)8)n}L`9VDc2Y7PnJ zDE)(@wdWy>{F)})E+1`)nHX(=N!0?>pG9c_3LeHcyGhfFsoMN(<$tNE@O# zGFvmk)L}mI3&|HX2AO2~u!gwK&d=c5b|#)C?8`ZEs=QGA@V5PGBH~6ibpz^9?7-&W z`EW+z|GqnIYB)EP9@39q6|1D08g{E+%1srvW2tF~Xm?Xq5ov*9@ryFeWl6d0`s$<@ zYt~>4dyx&tp$Nd;o${RYKxbDNgo_FElYnfG3=WT&Jb#AgB_nc+g=^5fYxlYGlBygq zSD>4ZT_0Fne=A95ZV6>B6b!4-0)wTTqiWD_e|k{HjF^5M3?GDQW+%R|mbB3%biqLw z*`^{fX__1k1eUR2wWvYT}fZu$jlmlhIct4y(%;2s4U<$UM-QcK#6`w-5zJt=X#9Ws?HbK zSqh?G6(?2Sv;&yre5%iMdWgflCSinqSF_FAAskTv@!6afQB zWGTHVDlnTU!SIW_wu4aoi?g)VD&bD)qHmk3`bK(EQ8Z*Yfuk%V$VeB}aU*&0$Sp5( zn_f>cv9cBhl%I4JS5R@SE`epNN=-wy82LS_6IX$E0^ENcVr`A}zW zr+Z{&_$U6Rhi=x(rLd-tWfvKDaRj5E6KlrtEGZjD$4$gko_P-z$+<4+4)n~+^RYF6 z@z1!`sD{UfCbM!gF@B;L!aBN*x*1-$dp) zxPW+@$3Xv}n$w_zXCec#;xP=8DZFrWrIDJEei>@O7v>uRPBdPRDREE_5jK@C!X!GtoR{*vp;stk zYa`$F;!i)=~GF!#|FI86=4o|XInVE0ISJL?zw^dGD{EBUz z`-`K2G@Xp4^Vp$OWPL=*kz`m91yHkFxjBd^|(Q9Bf(cEc6;#&xFHT=x~oQ z*ovQ{$Ey(S&dtm6;fLmh3{lE6ak~WPS%gSK5Wt$#&5GlEX=Wc=2=U0+i5)ea@qn*Q zhJK<;NFED>#NkllRe!Cd3b)Ou@m5oE>$)iO)IPiKQK^xbuiGNY$|YC9O~3>MRE?2n z#f&*{O4V3Cl}84nYy#>z9mfuftb6%%A&0b%M)kX&g%EzG@Tp%QMZt;=HA^FaOmaNS zn^mvP80tN~?u19T9lI3j^0CU%V=DlenK*O_RdU6wLR?Uj2z)3UbEFjJ)nZWl!Q7Eu zgGrt)Dh1zR&i0>`)c;35e|_5lhowEMI1oxfo<92IKArv(a)L*E@oL5|yusZ(oB_02}nT*Z@*3?j&@_m=467 zFuJAL;<5O{E}e!Y=UrtwDaAblAYILLlD=dnO~`e0i$T&3@FHY7rfT0%D@%qZFHFrW zPim0&af4k(zJ8`q%I{cMLl&Z-#IN4*brUN^l)i&b1Yi2t;u zJ)8hLyQpL?TkL|HfJv+_D$TkCh5-C>W)-qgA~Af*7dWp05iOZ6UE*E3;-K%{K&t1{ zWhYjB%6Wbo3fElm#{%}OxEPy7(GdzisBwO~g^pXK1<%3ZgQD&uVsbtY|Larihaa~_ zS+xJi^Lj5o^o1VOpnvJZGK=o3wC;;0?O`)8b=id7vBLVJ$eg-lRe7UC86}sSZUY&l zTF|k5?2mIb;ri-MYkV*}5k0#^HAHZvq`%?!4AIy44$}I24riUatsI20Z_?TC2IrWN z|A|BDz@k-1P+A;1w%S(#m1^jv6{Ysso6sLSWQGHB;a@R){%ikGUL9x zkY&8Ql)hQVjq*?EM!-HzC6GT$a6F)Op3xu_GPu2X(UzDtnqTT!v)gvK^f~^OWld&$j)F6rL4(Ud zler@ohR5;D`1ZuNYz6NNlJ+zctwndstGm;wW+TPTrTeZ@GVfM&Uf)gD{CPzm_Wj~m zn_VhHT`!*2A4B8?T0?h-^G)c9e0i>UMd4;wx>8V9LyFRjG;Tj>m|mAu7UzypQEhPe(49drn?l8s^a_Ux$QfQw(R>$@AgDP zzdQf=3HAF{-<5JB*VGnezKOn5OR;P*P_hqNu z@7tS-+w>;4y(@0*uE%&Df?%U5?M{3SwjY3*ZFj?aGuP$^SGafntDj6S4i9J2nBf1) ztt)d#E_;&Z0`$$nt2A%QRID-RYU{6<#APlGJ=t+IOl(KB9ilR~^!PY)OJ9c+O!M>) z1^rTJEjwoVaVyG;Bwe|uP8}IKqN%RfHtO4<6Dv;qha)6F&-Su;xnjJR3^o>xcGF0v zqV0$O&@CFT*X7IWp6vTDqED@G5W()8C+_s(D;$;&ZED-G**DGkUW+H2S=Y@26311p z^Yu;80*QidssJ;}P6LOJbi|df#``Aczp%Y`oDLAMG-yI(F}RJUs$k zyl79)N)_}zNcOLUGSx31Uy&vgYy=D~oNSJPMJ}_)1rCMOO8%a!dPf`(o`z^td5k!P z4W-Lnih)&b^ucxclAyD-cDL9jju{u3ktASYVf)%*Oz!fLQDKmyr;azMs)sUtk#DB9kQ&5mj#c&Gt7} zwHjIr*^L*&hwr+#Sng-;+RI%L7E2h*?S-$`W8|U^o-8=ei!8ThdfoMHx7V0qL2e$! z3LV+I*cTdR3*MhjTxS-woL%EG)3x~gPJehZe{1@iji16iy0Btgu8>91Cf@RH;98uUk0anFJy<(QQl*x9-$=#Y z5|RXAn#lWPN>9zo8SiA$u+%Ga^+K>&&y~2R5R=kNnfA7>l2(rkkBrQ zTl#T;W`U6?zZ<6Lm&TBy@O__pKYKSA)GD{B!TrfiV?T3xjXt&C#?9HgQh~+iv>=Ro z*4et5ROmMDK2o6lp}g+g_8r<^t!KwMppxgE+eZU7Y15AlA={$_j2wLS?s?@SI_2X1 zO|HQm4)CGn-nP6Uz9F<=8I)*CoOm{p@67qDH~f=ltzpUHJQ)};SX|96s;Uk`6mlCk zgV~{Z zo}^SaP#==UdhoK`URm&>hKBU9-32k@eEp&{%qr2fB&+A2%#0H7le{;%Vk>%P>FFs8 z?Be6VBzzPQL+zvy&;W(;;0XsW zu&xl!TR=oxCXX5KR~3R}AV*1G2wTEs+(g{^5qp-$gp5N&dB2_}^SM2${cowzPl$WV z?oJy@nL4B*rt=npFH=*O?!qC2&;EYgzsgmm27HEJrwHOeIYqWWx@?*5b84x6lhL}D zb;-v}=Xm@#MJFv~(+<&c>;1}kAv97{^iYN3>J))!%Ctnqxa?$!;*l@7(X5h$ax~?>2^wF2%0TIL_pPh` z#2tTn?9hcPb8_GfLAMTSn|w<}uY0%x&@sFmn9K~-$ITw4<^NbSoq`enZvObQrnsA_ z>SnPQ9-*gP;fb4Lo)mTha4@oJjy6%c*-P%#DNdD2U1?;%Z%|rCImOX-uRo)Z$8g2L zOf#9Vhx_$}S$i^z(Y>SB#sbV(HT~UK{VuyBG$R8DrNOuu0MhDi?NNN|an%Oz?eWaf zX}Om5Zq)n9#7H+7Pa-(A$P0qHT^j5L)RmfaBWcYHg@d zVxRp6dl_qC7I_7ob}Y8Dr@9OI_OgC^ZGac@r<3^KV{@h`deA!slU45L5ic~a-GgW} ztxhy%!nPrjB)7@*oH{`~sV zExMZy&WuLW?Db1aa5g6l2f`HwiiF}vtd`|p1ByqdA-tw$#{DLrM!&aJo)T=&$qIVK zbSozu_PePP2{f{(IsFRD1oS+w{Eh+1^qIBvU)f*3{`mACw?O=O7mmDCehTjg;bRQj zFfI`uhFoB*D>8UH%q?u-irJ@}!5$%bKcXi@9uXa+6_kc5p3QL%Sdfyz*R&JQ_mdRycz>_C8C}% z+9XK$R%C;wV${Oq!?|mQ6%wXB&7?&XghQW+i)4G$EsFnVm?jBaA~U3*WlU1@V?_76 zG1oWA7k-f$1bpmNgUB0?V#VTX=GlRAF$QuQL zHatvZ1-Lw}`k^fQ@I`xq?l?x8Be(QZl=W23~ z%RP^uNKSswS?+6G?S?_4*eG{=oy;xT0(OAyU(!g3tJul^`S$*J%S7_{Ce-rvD*uFuYp@hjyoEH# zK@v754J7&M6)d$R35v<8{8SlS`R*vV&3#*5@$x7|t&ur@<2A_C;vIyCfwXO#kxCk3 zTO_E5o!IN}GZ9dRgJRFQqvVV>h2Sz*eecP1Z=P@XE{uY)kGEmMmLtqW3p^H|j;Sl6 zdA`7HlR{-58XbzjFDM>u7 z7&R^pVIT!3)#fOH^~*Ag==|`BKKUO=f6^WQ_pu`vt8bg()U7Ph0#?X@#s|<7l{Kx| ze$mv~xkd|%Epj(2v#yK>(=%(t6!h0y*J+XC0yggtBOt<9_@!g)is?k2MLgZ&qDcv z#MNXw$0!-d<&%k_l(@^x!2q)KcqiTTQ-q6Zg)~F1%IxSs;Y!;((^o3 z9A740g_5P@EzNfp!mq1eLo+oY{hop~1HzO+Om#8rlxyik7BwdQo^7H$qPA82!ensa zXX!x_IL5JDGtMbbp=Nk#mGu+;Ou1U2kaFMPc!xkcl*k!fxnyfv$2*yLqWp#maDCD- zo+`+TR}Nysy5TlGeHKaWNcvO64%3e^xM$~rQDZ}2chMiD-DlXx*9|yB?jEUV!VeTr z-M6@j)35$;M9rqc$Qn69z=$pVvOG3RQriC&;cEO)G!#V^?OSG#R95_NhV>s2pWT-MiT=`@Aq93s{bdcFp3{I3djn)z|W z+wc#{n8E5|+;~n11Cng?nGF%LnXF9gPI18%pQieXnFbD2>rm+@sY>I*3zT~oh9%6N z!R(NLao5EUc)3kmwHz-D`u;W{M-Hor{0LZM$>&j~-8mGS^I3k&%}Onft{0r0GJ9qj1!936T-{&Y;nAwKUOrc~4c^gSrn6PWJNAye;nP0!y^4 zYeJtu`c?K!wz=Gp19zUh;vYB%Zd?XN&$j zk4}$0VQ*(b^<@P zGxaJzjoH%rH97av{KY_3ZovbOO?TwqY>wC>Ka7-F^n58Pq35YZ;O_+)IpxYk#Mg%> z6ge11F>zP-RpwL{Xp^B&raTR+&t|}|db~p2xXfFq!hmH(tjqCc#vIz*ibH=%3HERPx*xg7o!C?_HQis?NYQJoVN-uot> zP1%NjtquBh2P$KOlUtqaF&~K`~e9P25TAVniutrTT%AS76|kPW#N(fnGiOY zG7TmUC-qRTy3&wa<@7|~=8Bu>bm%J-hGA94&aOF#pmEmwpJ|2&QAvFq>m2+~;64)o zqy6>=#MJQOO0`OnEg5j_otD&qNs?_26RkeKbx1>>Di!OCDdgz7D5p$p?}}6RW?Qgk zbe*3JdWcO!+F$SYPb~La_zQ=%)ex>>pIRoS39pG@{W?s*+lGX#-{pN}LbhI}{CWt2 zp1Oysv{iR4qeg~wm!3?%BOWd#N6U1wvl-oFy|b;PAKEf3<%u3!2>Ze2#JBC}KG&bJ zm3XpSHcB2}TKBfy-At2eY1AJ7K%KHJeEe_bc;+!2=~GNcJSP+=eof6cvBIm!u!#GZ zZ-}wRuSfUGtQ!%gCmZK*XQgpC*x#$*rjWF1%Dw7mlk?uuRl!#vimAz0r`m>|tAnVe z8p+Nl5Av2_K)taR;AlGH6NX4i44DAn@NVV3^jJKkcMLo%!UJ_!?e0JAc>ifz-smz& z)}Ya2B_M39Ur%X6&lk;WJ0E|Hc+-#IBItA0>1Co)VXOB=&)xCZ#%Y;(TNaB!n|i48 zzb290!bSb5>pxr~G>1BQY{&bRZ)RgR4J}dXhY(NErx5W8cTuVHKe4LkQ;|}7W40HL z{0&wd$HdYSh+k3@tS1qY>(OvlsWlU*ry^g^a1- zZqAo7o}C3mHyKEHi!d{{rn9iNyQ4Y3!1?u3?bP8?2q>}U@aa6+#7P^zfp|*Q#rNQn z({JhHb6~^XD{Cs-WgNYku-Zjvos2;zGO2+P*SBVyk z^`j^CczHlUul-uB+;6_jJm>o&aZ-^-$0RWwusYW}3;*+3`@F)edv$(TL2uqcq-^ou zO>+Pv;)37t$+nsKgA~8xdKKeb9b|Puz(~gE?%cl6efeR??d5RBuio(sbGY+os!y)3 zvU6Yf^pU!pO6Uu$c9NNDMjRnwsMGrV~NK51yUakbW934T6V1sKnjDF}hd8il_N+s46 zC#wD*f3y@4mZ!14PRzcnaDQp)@k5Qr|GDq|_Jh-;v%`~LtqoYLL-8{Wwd9SQt%i5K zlhg8;fBux#F>2`=dv5m46ea5dlbA}dl&}CG48nM<&gS_t89S)doBF{GM+(v<-aXv~ zRYfgAKt3m+7m@P27esvg$(7fZ27dQ6Fa{6}-t2=kD-K$&o z2G3-_TDE3Sq4a4kp26d@NUBsAMrykGF#NT`=C;Ao)~nQAr&_4E2rzOn^t|dplU?xI-KP{uNny#a@IPaO z&CJQ@yO97Hy}Zd$F__*S>p>wNYBoXe9nD`@fxp#j7(HCm_fN0A{J5DuS%=-ubzmiYt9kmFslZ4bpxIO8re!+&Os^8qe0F~>WIa@7HNVx~?CG3&ajn<$ z-qS6mL$K^{sExi=vYr-4*=j5TfNVRI1wZ}m)K z06iJYsCpklOE5tNrkShVDQiBnFiutuAuK&!^jZMLtzZ)i)~;p@hmpaTu9(65zU?$b zWCXu7{@9=;O_4+(%dT}_eNN^TFIE?T1U7su@pRu_2vR}G_<(iZiNMGDC7!Bx3^vM- z7#@Q}Fwa@(ifzbnC?034127HO@Cocuhj~?Pg%BxU$yanRiB#JN99B(4EH%x(sx?zCth!+euex3 zz5&8#Q2nv@y4-Tr_Lv5;g#kTKWL$qKxT89MEmKJZUr8%_C0+h{U&vx$E{>9oEEJL; zRQy`4rR1WSoZ)c=XJl|FD!A=EISX@XwqJ4iRLbLD?1wwR!a}TOkx#t?_*m= z*ULTtNK-!pdG+!~`uO%ZhE#fD&<%L7RK$Z1%Y$6lhx+QMjxFZv|{oCdF0p z_r}BOU(BnKf4}@jJKmpeFU#6` z>cfwN)at!G+ACaFa(GdrFd4h#$C1lP=6}3>x`nL-x;Z|6y)km>b#Gd41<&&b2=Zr2 zegS?v6fp@HFDdnd{O~MHpork=PZANS4l|OHhDitsAGRzr!aiyV7Wz-NR&@tUnG56w zFB0j~M*`DhR3!Chbi3i|$kRp>%slyM&)mGwL}c>&og91(C-sy)r3t`ju&AZ-2C?OC zD!sqAQ22ynd*iVE-ICAAnFi((??u;oS5G6SPlL}!Y#Zk0nec7xrnS*kE>P&a8^1R{ ze{k0|pX^^ftT%9!ux}`tAem%i9^};GPCXq%EstKzEypfZW8JekDsdn z|I*pBDE|mr>Fs~$>HLpu7_cZt5gfGemgntpn_}HFH*}VLVI}@ecINl>H#c;vx%(xN z3#tOw!1K3bOo=@>uSZjZ9Zc<~H*9lp3`{n1ZWx@b){$DisgKi{hwd0iwZ+im*5-h{ zl$EB|;r{K&e|0YcdMGgiFf*qkTJVmanQdl;Bce9KXS?FOAKh*%9`u@3!JCy0j;d^_ z%pkCgpHc|^dSqh)q{br-9czO0c0`*W_e|=n8dix2weWPc>cFgMT#o! z#9E$1=2>?RejG54I8LOpBhDT%-bBZEd*$X^QuvoXQd%gM;=n#Mo?0JYaRiDyeZ>Lf zs0sH*GXvAx%OvY9x?4CxiS%$$P|2zL5TE~d%`sm5TZ6a3qQa+vjuoE_0-lC)zfG8- zQ7+b8VpNG)y%djR8j&p?fdQcFXL)y5FPHPi?4$?0t`p88M& z)U%~5DOx@_wg~@cC=JyMM+C20OaecU0NnPI&F-!3uJ%sa8Md1IUE16MI0)JYI$xi1 zgu4=j)lB@p6za_;f?35PkAPzeVLelAZOM1x&Nf^A$q;sR=vShpat4yH-V_ui$)A9+ zT9^)Uk+`-hK4SF+xcF`)rCx6Yq4*Q|=P9ZK34qSet3WU+NLxlUxWyrzcEmRvZ9Wl` zWM|h)9%b=@hV4squZ)%YPywZw>C-W$P24}Ktp2WGe#ak-VJ+-=Ay0^0E~!D7(rxje zaAbeUR3CEpPa;iXSgva8Azx0Z#>DN!Bie3;7|NM*j~kN-4P`oWRdGU}%2rsaLe8AW z?9V1$IY@S{lJ@3j1@qWfDP4aCI9DOA@6BIjcIs%G$xy7XV;`g1B|sfkBrii@1Nhg* zYuEt_eYY6s>a8YF7~cN#%x$sVpOMv7#BmjgRLTQ9nA%hmA*e) zl&adRq(mCcp+nz}(I-a|zzN9NLXG{wtv1+H?xpx_Nzykf;HM$>+xnDhvn{zOokR29 zm9c$tVbgCE!#Z+%09a_dUPBzy=AaG&5G03$J`JsVzf;Vd%Rz!OhK$a-QdVGE!;q;5 zQ5CUQ+F&k72!8f6Zl#Vx6GIPvxBm6rb%}vcRg**tAt++~lTh%4WJzgOUdXWyuhy47 zE~prePR{=$?5g9c+?KW?BHe5nq`OlorMtVkOS%z(O~*z+TIufYM!I{02-4jh-}apE z-s|z+bNCN_=zjNl*UYSEW}bPLO}aI6Md_h4fH1PIx0#%7h4#W8C&(notk^On(m-xc zC%57{z0Ut1y9VETDCNm}c5BNV?NPjEDK>LB5K;|}QI{zuiK^Yh8H9YKHNM!f;}ujX8{d2yUotl+axk>SRvMMUGiT1ZE($_D`FA8EtTGx}AS zD7yBy&({9wluMBu03F~UD%gj5z>mNjb9kIROsPSTF{vD@&~Q#TfJRXiaC zRKm_>`50kQa>&r**x|V%g#6`gqY&qm^EZC+FXDtBDVu{dP(@5Dwt#d1Y403!8NVOA zXO75mu{cERjgP7mRCMU<=?1SSRwZ&;BLW%1anR#7>-om#9}KyeSKassw6Rs)*UrcE z=t?;IB9XKi&wqHM#rBw?eFNorX6->8Ap`e)H$}SD>VU9^t}1dNtzYY zh670^I{A1V>Y8?!|DUkArN{9!-tpQ%Oiq|Df4w=Fx z=IW7I(N%|r5y%it2=q?YP7pvA#+E0;^T1+?yS+95cvH1cmciQ8jrrJ%B$Lme=kHH8 zd8Z&M3&L4`xk^S)p2bSK(qY)4L$+L7YC_&b95?dKp&%C((GGX*CPiAvn}W$}F9o+c z(l3)c_J!AM_{8W3A*g@TH~#mk+E&oZwCa*p2-C6%rx|QRO}(j$1Sf*5ql?VRn2Xpd zx;C>L^ae!pD9M8;i7dnNQwW5>@{nXzSXiGEaB&1wPNj-vp6@GjMKWT7J_use_fh~XwB zO^u!@jx-ZxLI#nq*a1ojWw)7=M)k}($K%ot9XkZax7^UF^@#Vi&SSGf+t;~&r*q*K0gFkY@PR6$^|`QxITiJ_p^P(k z7;M34v;=&ZwV1V_gR*D>nm4zdXvp&;XSu%iosxw-k9kGfut-V8PAH*#7J^U9DN8Y= zdlUA5fGIx)ibE=&?N&+G&)3}ysgBdeQ41HPId;v57vnRnH=jqgMgL7F{EG*-F_Otc zQRcL`^dh*w;F@O;&b`$j7j@Nk!7`xDqI5VE$=Z&*%ciGq;)ta@YbK-fx@?JMZ8x74 zR}G)gXYQ4e-PV~odRa0u$9}r?>Q9M_vNecRxhNXOV!Q47wWc5wH#IK&NsahRsQ9@z zvun_yT(k5rHn*52@m=;og&xuZA1TX1nhEe)%GMnAPsgoEXzRR`Cq7HW{of)EWjfH5(=VA&6;RM$ zkl;?2B)a--)6D;Dd$`(AWRovknd9$KS9vP$r*sNn8l?9`WA9H=)={%&T6WTrq5vsG zcxtL<`a#P9%foTY=vvZ{B=iHy|4bJC{h|R$Z{EFFqo@SiKM5>)3maaI-f3#++~>Wh z*C+^S<>;v!oI2@D{&&bf}1H6%G05a*69?d07fmI`qHJ=T^fi<}%}4gb zwJ|W$ivs5@C5eO-h0*X*ZFPZ<6#Gfh)PEq(tI@$Vs*(55R*s&>gqzpK!ZR3Pr^oN7 zBQ7;eu{an9N*0?V?8#ElkYJ=M0_+h*FC!B2htktu+mWfn1l-K?NQkT)7UZVplXK0x z^F0RCpHqy&S<9;n_riV^md*+w_kTv?HOsf@i;lvHUT8>H@?J?xZFLJ%B^}>0!WKtl zwY7I3FjoDeFBv&db^L=j#jS4S^H?`Bq&w6YoXGEAeh)~0jsImlu(3}h8tjdg)p`Aw|rqL{4$t zz-N?6=3748QLe2eJM=X32Hml-dl^lkJ6?Jcx~Ek&&-j{ZPOQ|OxCQFEMXhPF9aZY> zvb(=F#1B5U#zI(baNrtHDxP3ZrXuadlQc`C8%kwdwD`F8IzcVSKNYv-PQMKGG0lAk2|huuL5iHx3zdzV{-RkI~ zkIxkpa@+TRSc;ho_t=XVZPH%zT>Lk-FG+3dytvE$$;%G;>~Xm?eGy=dukB|4OFt+< zwvAIiw9uS~hnaNn?ZO3t_Og70bC_9w<($Z-;uS;6GogA0?Spg@DyQtH^6VtCkS!PDo+aTFX0CfkV`&%uf@jausQ(du}nyMChY-X39=&9CoLKhej z=BiJ{qUwuZ8mF>PV+s@4)vbzRJ+j=8#q`t->y(`dC0ZF+jvus3kXeE`s!J9~8`qbsF?f@pGTDVriF@t(E32xq}k09niva)#! zud44bJi&)3BBD;zG-agVNbUjga4el=hV+JRH0+X#Wj+YLF!ZFMx|kj^wxXT#B-t3t zoBYm;i}7qcHTn^OGFvw&osjae0qLDaQl=8jm)WfspcpHAC3WX3CSMaAKObVn$ZDb%w)wQ1mJixY!_$J!l8)S1+XZ9<@Kn!^U2-Q8(Gm9dmViuc!ZkH3>q>vzoX_a!bWVhU zkb{?G@E95B(8&a>_52siAX6-_$3r}^{>oeVFIk2#(YBTLmM>ZvjMDy=Ylt1MgEKucOfwbxu39dkH49X!c|`xPsa<{(e}aQE@Kyf?*e&FrS91J>ul{+Y1m~ML!%+VM+%}xu(Id3AR4HSz1&I5INv%FX z2zenDGCrj#gwzsOWDe~Al&(V0P$5&^J8IvDU`Mis|H8D+29i=2<0{;Tn@AuJt@I*3 z;%EpGFLwgB3;-gkRm+%YPs%0LgL|>I$;f-)XE6%|uN2MR@yDds8;KUQAvndlp zSk2_G4Q0=o>lYCsS%{3%{60-{q_{S2Rp9n=XpZ{8?6O#f# z*ZhTE>9@NgVz0?pA4oUqqjO*l&{#WQx>$(O(PW|a2XXn}5R*Zm3|8wm9x;H(h3Ms8 zxg4s~8YKwdvQ)OSihRtk7nSxO?NEOiPMxmHMG*}eq&HL15outfA!j(Na_MUl@KL=B zAP-*-OS9b96Pkr{M$TZEOwIHKZQo2GU5LSg+F&`RHr654<5es0*wD?Wz@T!sXY(h` zIwpIN$h5MgktCrCBVetK&(}+!L0`gv~qZOsLS-h#!tGmr+vvF6w z`-6#YDZAo{Fw37l^vW$`@zR14IkzK0b(=395Q9DBhI53sZ=Sfk|ICJ|x=1gw$$sd# zOd-S0*$Tt^g5u4B9<306mzKPRv>v2BnMr;X*<6%UEKT@#t1rujCaOQslM*=VZLQ39 z98TJu>>J6RaJ^T&z7x2`o(RHTN|}n|gmmiL;leRDzo#aJhT4|}RJ~rRuh`WRE72*6rffajRSH z!QlSs{5gH4Q04hJhMA91(6pw;`gh3O>~3>we9L+2gHv`49puZ;pa#Eu@d}E@UqzSn z#Vccl<&nA=0cu+#ye|ul;0(OPsQHx+G~9Sx3WEztt}vvKa8V_zr*?!U?qd_CYW$7d zVxqi$L{$YI7I}F{ji_?p+)}#!Af&fNNPeG{F`L-pxwmfE`flO92X1NQjuIV!%=dnl zV`@2X%|hY#Iuq^gcKppEX3?R3PSu`Pe!jf94yyELNVH@7$m?}IGcItyq9^@{v9&UB zf5wE^7lkJeWoT_qmcZMPai@YB`X#e3)NsVR?Dy*KC2^cma5vCA{#bUXq{i7d+jOoI zW%JeXxZjXzC55$(^V1U$-Gk$TL*H4G#tF)+Ylf3u3pSH#3JD6YWW-#G-znfRGJS30 z6Bb?Xr-FWI--%ZDBmT3Dz(wDK%h$A$dodJ&SR&u=OVWBi=d~qV1j#;WKE5M4H@41S-&Xq;<{I;`$@!Q54j~IhOdS$51((!{NvE_gZU52&IIIo~AY<>y zIc+vf8`ct^dRNSu3f@krvmobV*8|}1ABY?ax|{I-tP?J9Lqom<)4^pY^0xoh*o*r! zLcLGVf2$yBGeJY>*YUChi6gE1t$ST&CD@3(KVy;~KCsOLu0gH&JsC?*ETqnMp`eCt zQDtQ4hb$O z2*q`2aHlu^PRExMj_zGrk-lDk?%=P1&u_=?jSgV~*a#8kWG>#qmT9o#XBez`|3=uL z>SUI179jwi+EGffPVUt-jDVXo!8WlUR&m$nu$HS`!;CJZ4T6Sur|_+|V8bR8Q-91{ zfodP3T3ZnOJvjLto}?2yIuTj*#ZC3GiT1Ey7B92e&+X%a^($z#vz60SJJIsAWg7b9 ziNAznTV|-6i&SYYItSy%?t$(ById}x({B9m;oYx$TV^4{8|8~y20BfbrVaL63%fxY z6CPjt1QPd~_BO`vn`=uf=H|f$jwH=@`&{FWOb@PFJS>%O^^u{CpH&q^s;bVSaIXd2 z3ipQjjJVhCz`ozyU%~`oBIV%lQMZ*ryUmL; zF4vWE!-xsj+c~G}b6=9Xdx5)8F4i)T&y~fXt}r}n;3SyPl7xtfIS}iCm4%OF;Fj%0 zKBjDIiJW(8c0S;XLIaC}A#*g{lEJ}QL0`okjNxJ++F#<=k<))yX48ilvd_SVtFj@^ zT>D*Z?g;M@n2Wc$(e~!*^t990>MJ?W@9JWQP3}GwX>N~@U{N7NfoOB}=(8ls;th5` zgBdJ6{nMKu2PX>PPJ_vKu2%leeQ0~ci3kiT_5>pN>3TYa%Z+x>v9nXmQV&5dP zW{+Y+vzA#B_i{l2Pl)P{$Q>%xN#<{mcN-My;k3{R^^1!o5Tq2==T6^jV)e}t%w#>M zt;RN%|MBxj#0JOp=pSOl$#X5K5nRNcmOQy;wy=;9gB5gD21)@`p0~vX+VvV7J!p7~ znR}|&lf(WBko+yoA*2br_=RA47QW;KJxawfLwLOBx6Gtmu3dh>V)tS z!?G=>>IzWGE@PChI7=Dmu$WAp?CHVHd`ItGt*Yns@FF& zHsF0=I|0Um-9;md0Q5Avy?*yL4&s@4Z6fWsp-H%h==nixNiS%5n1t*!XeSQ~A3%tf znf`A0Q+jH4)ne?1z1|C5;As??)^@Md5 zKXVP=G4H?d=i54>`Yh{7A*3Dui80rX@8k@rTij{=x5>3OWb{82NJhMO1;FK4cCX#+ z8g?d`vV4!HT4Q|d_b*(nbpZcg0kjTnW7imkXlfD6i4+a4laz${!hbgp!@^aN>EOHd zmv|^N$9Apxn1^YzoZL{HKz=t(5ZQv`o=oFhM8SKGxIOF9|Klh3+7RR4B=eaWKg#BZ zdx+>rZHi>B?a0IY%IwjPt>MOf8G+m7!mUERD?yL7xK9~->mLgrCZJ@c{J+9YW8C|pw_09vbELEjf&0Z6w~*X!RNb5qr`)q@ zya+QF`mP$(j034?1T(ci!KGYl=@_A#3`AFrD#H)qbfIAL6|ag|)2u-+Sym80#CGDa zGa=?2Cff!=zb8GNPTr_G)BgC)?T7YHg;vP5-LKeB&UyA_iG42LdZ<7dZ5^+jURw#k z&Mm`6*v{1oT=?Fka!tf!F!Y}*`?r6q(sOig*=RYjehp!sJ1zU8jQ>=NMmpb>2|^~^ zm+j>ED=_q{N8dava9Zj8;ph&;5KBw=&tgBmsd={7X!ZSQoa_8ZZXTpa^o${g%@9{ z>JDeJbA7ibwwA>{i<=Ge$1wW1kw64_SqOPkOuDa5=uWtUfMlJL;DYD>(kJ@YS-?yM zb(KqTqS3Tex>3no_(y@ht%P!PJHQ!tmZ^wgVms$7pMl0pSkbmIqzs#HtwTN%@j<;J zm4{Nf@gTLe@%Wn}8P%O?h20Dtv@wnr)WDQ(z{JM`eFu;@YaWXd|qc$#wsoYVm5R z66D{fZd`s&B{k*JpRpxt*v@v@%qi{t7BRawnklW*Gh(XIqMLXyvog1P`;d4Q7tsnB zIheD(jq|);x03Bfy97Tx%w07vrIc0dLSby4VO8^&W*m&boawJ`pWHeYg&xpM`?l;o zXtE(hJP>F-%<-~5>{?F~fM?<#berx^hRzP29No=ckC@Wlw;1VC_zKkApInX9yT{$~ zbzhHgmTnj>E{f^9*WH5j=6%CXMqbR^+;h!`a?h^Px=ZhCM+IH4OD_qZgC&j)HB~!E zeSgG1kmCBbRV!wWyC{^~DDx&5_izs&92hK>!dx4yDc%n3mJ#hG$nfJY{zP{P?=$Ol zFTc5tAN`;rCuYo6%H0oU(l2egHN@LSSia|c>+0SOIW|Q3BODG+ye~eFnK5}^Nj-e- zK{Oi3`})~U^NQ0}3L>K2At0=$b&Fjsh0Rn{+;V-huQtDbHZ|h3N4DO4U&M7-sZ%<# zAyC2TQFHrszKCm~rv_=(=5!Z)Y0Pr=li2R#Qs;|iSK02w0Rb}%BIWq+X(-` znb{qnt8(bt&Po+r6jJ-eCi7u`eaQLSj@{Fh9oNvE%htvnY&9>zIX2X$Be9j}Lm-4)O3T-4Niq6ccZTeR5rCA{#$(Q!oX8L5vGMVE*waVtyTB{t! z@r^z{+@-+s9JeTAW5=0{ov1}{EU+5U5(T-8KwWoVedC?(gXa3prqS1Hk9qfa8jM#cV@>_y9T3|` zIx}lu2))-%Ixa8tki?BJ~jE5v4XV6{u3*OOS41rid^Hd+&hPCVfs?u@2S7-mjx8dD!k z4vNg!cpjRpb-S17Mm$>TeFSn^Pa!iQY+-xb8_=b5!KwEt$!c?h7nmm=oYR4As;JbE z-obiIW$$=~wBr5=vAM{L_2_6+!gvhLIfi}kCceDyRb*L%TSgP13PYyC5X)|T6N(w^ zE#!VGjc|gE_>GG-#cpqGi4aVYQ1lBCaI@M)!$B(1>;z0xQ#cFEtsx>Wrzv6;KFlpM z&g}c8TV~+W?K&r7XBjcf$+|mk=iwL62G0_wYAJFG%UsUTSn&`~kV@j#iGZFL2&9n+ z5~-jiquaRvfzXcjjs}XtdoRDfdI9x=#D1;uV3#KyO*YM^eL-+2AYxtBa-hbhUjArr zM$bJZ+&?G{q0%(u=yc*m=Q@K{Czqby@$E*M!1|Tz{sKIYD<7H*UpdvFCaT?(k}ns1 zTF=5l$_qrbh45mnn)>RVzK>=L*APW-@Q+vWqJM);pFm5()m41?MABCcY^mUtlY0h8 zgN;2ORX(+w0eLUJTRLS_7LP6C#b(!zF4L@bSs#!!6v5<5a$qx&dnXxgYuXW*hUO4B z#Z$5$lFpC33pp<6#J^APB`;6N@gt&>8Ic?34S(Be^?u*Arse#CAz(!r4r`15H&Qt8HVTtVI1f>IP*RtN;cIJKhh3UQ9H#q!X z>Uw|qv27NX{6`SGJ|=Yx6v)9u00f7fxCxSsxkr_1JX3B>RI9-MQ1ZbTx1uZf+1Ivj z@0J!MhI+B$7L|8}mxbV^z#qJ8wZ^86`i!!2KOZJ}>RfBs%;Fj=surHhhatjP`2p{5HvYmI6D2ZEdpa zq_p>FMplV&$5`2$?>SNl3_uzp8H972`%Fx)o*(9yE=7etiy55(2+tK%n2=2R`!l_C zRX;+s<)rbS3Ii=5+5<#?!IVF_8-H#qFQZ?#p&tTviFlnf30pW$6c`w4@=n?UeILHR z8x{_IF5}jv(d2_D)u8$ly$~D*upF&;QpL7sP$KNOby70Tz~scF<^UbwZ&&umbNsf* zP-XD@qXtrW6M^!WRH0x?hD4zxL6-bT0Llo16k>f?sLy6*2oPIxh?zvlHXEj-_}acXWnv&A5+c zBsLw3f_;g~xzo&CitEVzfF-q4`++bdLGvVa5;bK;2XWl;_umkX`Ap8V5Y|zaOjj8T zVn4=WVDO3m{?WfyPayA?Hi|tOiGch^G>5mOBUMNtnB!>L5oMIshPQ}2 zrAF`QswS-)8Lh%+ZHW2*ddVw4V^|hJ(L?yK&>}a5ZHxim41mG`fhne*i^%SHc;&oGYLzk zEpl(SJE&chQY;rqQEukH{6d{Hix8s z)iB7PM$;3F?d1~vL^*Gr?YJoZhMv4$U-;KkVEhF6L>gZ3Faw2~qs%Ne%)!b92;)vPQT zwtg(PpNp}ji=uT&)RbgudxBd^=Hs{G3FE2gkX%`&2tP9XO$7LVpUDY$Gl>cRVxoK$ zLy_2v#h~LnTP0ynIKIk2(*L-+E*ey9vvkY1CW>wO<$i}SAMM?U8lzx>_PA}ebQ?1r z0tQ@>iJuI8C-3rjwTJg-q%9UhD(x}HAZ23785lT`s~uTo9Z{y z9scxsCglIT#%TgSYO+&;8Z8H5gZ2~er5Z)pa6^+Y(3nGe#~w|QLRD|Hw1I8rr1%Q3 z3y#i;6YINHhUl(fdutqfJd8zBGA$f##d@$R0sKc08{=!Kqi zdwPjzagDnhbe!vx(3^S2{X*giiVTjx(vN}$&vnQ8F@=P{q26PbLt-G(6NR`~wC;9u z^@Z>n4IA+(d22*$HsdLhIR8gXJn7{qlQ8f8+W~vQg1}~K@!Boa?J-XBH{wmB?_2hq zrL)XAz4hV&7ObY31`qUUz~}d>n#^H{1HE7mN~V8%>?Ei=XzdY@WB@}BwG-h<)QxQ3t7Mo+gpo&BHB!xgy*O?3$5F%x0(97z#Fqj@H zQYQpc_98;0z}K-_c@58ZR0~W1fHWq$2<zP7P@Xk1yGg@YqsGNZ^ z4ZT!GU`!YmIy%$H^7BF)a)%+;}ShbfEX0h+Tf+k5fO-#tmH<`FC(3#$>R z;z>fU7rcDoNJU%EL>Z0iEMq)keQDL?q$8sP1J9-NqMbls-2{=ILFow{1q-N(@AA^& zN!?w&h^*S)7D+p_5ES-{3IErR6Jjs%-iV8gqDN3VjCD8yTb-OkW?j@}uvr%}I;3eP z-N)i$UrgA`2mt^N_}RAfleNkSU_EARrTNHr9ONF@fLw-E0S6vXebHGDbdJ1J44A44GkhGM3_P#oML%u z)J~2x!Doo?Z1+t4=*#)`WByA^C2we6t}Qk#E$E-@p0G{gO}2*h0@R!SI)<9u82b6? zZ~%N_coyc4$S0-z5#=7df8)2`jQUTsMML`1KWN^NgKS>0>xa`~`f9XT+&~d&DE71j zI!l#{RhZyc9Xh1?Sj%-oF}yb`Vwmj4%V^!vIa5lvh>sb7%o`F~hoP4|%6oJYJNC_; zblnTeT+=xBmH=FI*41Q5{=$`)J8-gusx!`j&o66y=54Op6$cU7W|<2k zBD-fE0dUNB(DAg=bsPawL{df&rdM+d9A>?`0a`O(Pv9A7K{|i1)`rpyekGB>BcLfKW3B z9TS;VjmR)<3P0ZPrsZ0-exEdA$dmctG@!Fb@X1Q&tJP6FE-T4@ONLV~z4Q%l;W9`U6|4z4mZ= z$e9BjV(gucyyAmNUXjLIN>Zz&s>Z^MXX-p=Az>0Cl*0;#gMfg*Y3}qkZocvt9`FAM z-ugDE;`8l_QJje)n9otF<48oK4)U|?#Yq*&LI5J=or_ZWAPSB5d0wK7S<tm3bx#IRzV}}SPMFs_ML2An8jS{VNvHVLrhVBNI zQC9+w^eOn_!1(SiW|mHRjs+x@X+~N1SXhibyckb4<2uf)Fis~iSl*-g98RYfBpec!t;OTl1pjuh@s-$E|MpbXW zAOO$@B?Q6Kc;oTG1`>EdF~UewI<2;_CZ>TY7W8Q-z(2AK~n}U|T^P10$ z7Q1QOYMcI7Ncm4CtD+|z;za3m5Q81U^Q@x zLkf9EyfMwh3gz5pN2HI=)H1Lu+&Ddby=9Tv3P~$gb6rsKEE%BInPZ$fKi{*Tu$u1u zh&Msb9t${4x_VFm9CV*LDd9WS6c_K*dGp-5RNCw&(|PgdninWv60j?#<#p;4G<%EK zl}ppyqbk&w0A!u+rh|r8rzz*Q))(V7-Ott@C1ERvH65K77*QXZ7Gi8w6i=NI6?=y>e0xz z?Mix&K$`b&WZSx;E2_kM#rq{RrOHNKDthI8$A)5y*j8WDind25koZF?FQ$3gXFTX^ z^xQ8V`NCUd@~695nT|H;FQpMHD>g^ul7t0Frq~wW3I+(nA#4`E`39H}4C_Oy;WX%| zQDJc=1!Wkl0O?*TGFD`{;h;0VMrWR!-tLmjhk5Ma{rKJj36RubLzjg;`?f(W|3e;{ zGfX~04Rt_ZJeq_JJ$D8eQX$gcgs*k$BMrBCb_YAS2P9iE{cV@Vs6s<&2uO< zGhM2K{@kPn6G$QEAMChIljJ1}iBz*H4mz3x35m*)vt9F`sUVanI;}E^3TtZTiHV5; z5Vxh+LvvwDxzHq?xrlzPzGbC&;kD2^R^zp|k7RB(XgK6bvr(vXIHiLY<;5mNHOsrE7gsxc8W1jdvebSn9ubNQP6a-q)b7Qs} zpwl$ZYyRLf!j8A@K^t%t_#9if2}AcEFz5GlxXGQ$<%8R6UldQ*mg`X);_CLhD_`{c z;|KvHwUXBRJe^Tn8MI9hIe^f8oRVA;v1kiVr1ZVQZkLccIR>&TL)=*gr8B~J!XaMF z7oF$$V}Y^w_Om{$PeYDchpscG4<&jnjoM=oolW>KzZ&8HLs%#J&^DLp%c+Vcq@=}F zN!H}GLM+flpAYZn03|UPtO%U+?~W1em)V)kboW&pwS@!djFDQ|HcRH&mv^k_BvjHA z8kyvTJ4xr|X(ZQtaM#iWpMz5&dRQ8{08P%&J<%Pcfbe72D8;|u77FMzkCiA%Nwt#A z5JD#4S!JWD?GukQQm7n?I{udtU+CmlMKQ+_y+FPC`zclABxF-ue1Z%U==gapaJVv# z&s1s!krZn~9SEoFy$V{mDc%XSj4Kvs6@||wYS=LubTzQ-Aa#%6EQnct{U?ms4_A~t zr>kr5T*}OpvsC}U3CWMqFC*<`b4CKMrfW%PFJUYLi^N^Bzf3nm1$l@!QFh zMadAQhtka8(X-)dW;v)4#Hff41-y8ECmWB;D4&&7BU_SVY(avps5#HcBUAQ@>|N&v zpX9poXbqD|hI?Sw&#L%;C1Jlt32|r-lknI^H-^-V@3#Fe#U=w?!AT*wp-wCl<_$Y} zIeRlj((cY+`GjzKWgr4>j*dDH0_Hd6h{m!$@c#Qall(1z(3uA^<(KJWKe1?IwNlJ~ODh{UigAX`w?>VH<(IH3! zb2uD;QrI{e+Vj$KpEP*SB9RXAZb{A zq^&B=rtuSui&dtwk>cEv#N&i}-rbA0{(M@r1Gr*ZRoqmqUy?^|tnMR*T&g{2o^SQo zgT&jO2aEBRXn&-(SW*s0P&w_-QJM9{CiP-a>4;g7llW?$ET2A$<3PzVVGUHx#hO1k zsi_g%5o&La!*gITDG(QGe_hsVPx%`hEDU;6Sx!RWc6<;ZgMSXrI@>WfQEa?>som;M z;`Q+3K+W`t$FF0NZ3(dCEM&zmkmv|A)QOx!x@W+;ObB-AK1m85^3g~;Z$1FwQ1?Y8 zpXWEfuGNY1HaLr22uwFJ7AdI#A_D0^N|&9StuvSkm{tLvfsJ z8_5EjlHYtvKBUIx=qZJDLSP^QMoqCOIwj_0;~4W-g`5)hqE>Yo+bl!E2!&EE?cV4w zzXOmmJlNj2$>HK$y2NdzMqjnQI>cY^!See`GRmZTdLSUXWJ#x!!N{fPOoJA$NvSX1 zJNkxE!1!sS3d=Sjty)|p9F8+KdsBL%JN5Tk+CIh3o#ccu<~TAMD&466dh^oyi@P=b zrnNpUVkSmx%)dse_>XOwp|~Y7#&{y{_?@w+1%UzZaCpudn&zOobZH8(EMXRJeT1SV zShFP8xH{cN4xNASwGaR=m0sVC{`+sKRog21_ltqm7Op|W{~XTxXQx+N$`P13JpmlwQUshv&8}%N{{t*u{aWl=|vCS81e=G>z8boQgT(8C@X7 zZZ2hV`^r)I@B2v_8q^kgU`^N843(3?DQlYj1JYyB96)F!J2NY6t`9}*8IZ~4cDi2| z|EtaY8N4S%+Jv7?peTl3znPzCpi}StLYi%B+pp0S*&r-NlofM`TFkVnC`s50PnM|T z78Gw+zt1YUSgms!EEuFMRbOUs#bC9;m_0b8Ts&RY63+ZW~}1Om=CRV~5K`+SSG=}bi@ z46WFc*M0gw1Xz9BFM%h?d;^mCdr$)5Ud)q3J3-UHybCexc?ngbQ0JPb3+fC+q#;-& zS0Se*>sLzD7PSDqCfPw?upBSD#MW$zgnPLc-`!(R(uIj$YP6px2zb)O^F-^{v+ehk z4AO4HcxnMl7rU*)K^_@B$XS(ZP+ccWibfWKht4p#-SNrVK>`ufEt;8siKEaaUd}m5 z{78mVE&9b=y8P7WQy?&=dtVlSGYwj!$qBr7fA=;kIyjv?EX;#J(|}rzk`Vq0zY$~h zq($d~lm&x<&{>gR?T<(1R<}rBGPLUmTIi_XQnN+0pTUd-Gf8}*HjcC9{74aKnJ-5y zk54H~zJsWt)?2L{`Z3)N=k8hG>&}}PB12~$8%H**>Zoxz`Tu$O$iHo@4UgpIoq>2v z4|87CoW)lh90fX%4~ZvY*vuYbeTBh|Y2dO8UU}14k%qk;9JcBRG$I@}2irjOD2bX1 zp5!#nJA4kKUQfY)#A5m?81~-!ZDM7*vPA4YnBykyWG-&xY)@CgAnaO#R z5ioIQfx@sh7gTG#q+~Q?ECAq&iF(@E+S_&;>iPu5B}lZ?w?Uh|IQ2U$)O&E*e9MJ5 zG-^GJ$e?H0?r>H)d6f6R6)zw@L{Is>2ThSgm6?M=74ta)QbCp)TTSmI3b@ki!OXqy zxMPM=)eR>+l7?bVyIM9Lk8pqr;_MT0`a9Em%U$_wl=_^v`o34&cAZy_2f#*G+nf8r zLF=FN2ZP0ivlfAe?>^iY_dEvs_Y3mejV^o0Vo{&r^>nUDy>5oG#rDlMpJ4uNI!Pz^ zra@>N9|kG94^DR#FR+L@G;uzT(Lic(pd!^Um%|ZqAus`I7@!)5aMg?DgdB*?Diew?8J09fx#tYlgXa-Xj;$#&kq ztL?lwXtr<`l-E7-q$;(>udLkZ02xv77GB<+mEB?982@edqV5CLr!Kft6&rC}R^Yd& z#xAE)Gim97J*Bg%N!t*jcxa2yn*1JSCPGX@yH1Oyu`|ybn|YVg#7QOFq6=;y^k)TI zTENev8Q1pKi*KRPm5ZL9J|K$G>w8Df_1R7*@C?7hLkmKQoTW?QKD=1CEFuuWho zH#}Kqc($Cvd-vgD!q&OCfhha4U+TwS55&J@>huVH=h;}2EdYtjxIxc=3OYTCe>xGCDPqEnpG8Iw~2K5ROE!oj7*LS2Sbr%rCqEb&gJ&xA&0!IXz zacsXeEoTG>58soIId)!0@Xcw}S|i+C2w*VkT=On8dgkUz18}AGa&wo_T}_jQn%1S+ zdairfdKwhiSn_y2y%pN=tS=x}`(ScnH5@zQEwW*=kylY~VO=j%L`x&?J$I|Dt1>Qx zVB$E@R8Uo6J-;M8cZ(OXeQ&xJS!r!MT+$GwV;*_EpmOCsjx$tJ&MJ< z`tB9a-H`GpQD1B|uG%rO9lM-@tR+pdR=Mpd?{xOPMwiK3qR%?cUoS2lb6>!oqUTMS zd9wA?n_ZZlzSqzwIOd%`T*B0VM$6>Ih6j9i{fkX{vAC}fx9EtL!HEh z&S_%gQd|zUTaae1QB7-Wx9nu?~wu!q($i}KH zHWW-_m$Fmvm5E1n$VPD^x7L1g0p8AW&Y(nk4%Q`ey@mDT_POTSV(9#c66IlC(L~TKyK!GI{^3q0 zcBpjC^vl#>iP6j(hn zMfuZi%V6HYM_ON{&Ff5SpOYbYU!_?NySataoa<*zSj~;05*nPXlD<(4%e*;Dgeuw7 z)GjV*g|rN7Tq1D-w5PE?a~mbJw&9I6unEy)e3dz1jDCeDhnH*PNDBC&v5u;#AZ=BOm}!7wn*ObY}qSkpL*GkT(MhqKoy8JOm!+^@kI3Gk2bb7im<4 z?ZuE8A6Z}#eI$_pE~75@#uCFl9$6M2+1M>Zywd{;^i2wYlZr7B&= zQBJO`mH;QdbQ6$+i7$LrMfRu5`$A8L(>GgdmQ-OK%$F|i;WihM4AdPzCb8Z|91aaF zAsJ`VX6B~M8qnJ*ubiwO_B#_Qn-VEHJ3lNYv(=qwOiCHI{U_Cxm%xommqlzVW7Uwz zW-Jz7r_;+H*yy=m*ynrQ6x*Z9m*K-i+j5nSy!Btv- zim2yF)69C=qFw8l6G)tGsXmm}MV)M|eVg^8xPNn_bp#HYLE&FRxl|Fj)LJ-g`C*Xz z!ea_k*W=*a{uAs7K!_L!$DnppWO1XO0y)NTDh9L_gL0^7qQJ%YPYcb}>@ZmB##Z#E zw3QW~*cs224Xe!PkYE?9o#jpE%v5MAi-Mzq>+6y#t~e^zo{GRGexI7TX7|^TDJKz` zLw>tN;SX^8NA0dKl6&L>0gfkut}vstD2mS-NUu;^=zgt>HE zWp^bnh2LjryAtdOim{N1-d!U$#8SH$V+0#=wB0_2%oM5*z>hzvMofNqHil-b{~{a) z$7^L0gCXo_E#~!%y(%*(yhUz6Ah;+{H*lIqwU3u?-K?fRtStQ-DrsBwJ-MmK+sWPz zWXe-Jp1tlHy??#3?~iBu`i4mQ6&*T`+Z0h|ZnxQoo9ve`Zw_!NFv$Alg7`s2+L?NMZr*mWWHg^QBlDni7aD%8^ z^z0u5Q~g)mboBt;((6Sm#l(_^$?Y%!D7_P0zig%HnHG(tY|`b^-@>S9&A zn)w8RgKZuDt`^I_PGNyw-VG5I6a#{UeIcA`7OJ9R3sp{y zn01~tn^eVAMGphu%#iNPX!472ev<8N6TK=X%@Wr2MXWYYWcJB_krJyOfeV=>`xW5Ajp=on9i;05^2cF z*J+vkq?*mWtOZRJ-0A~g7YOu4MJy&3B{G5EHuQDou7FtZ%!i2hd6_u7>f{XZ;su?hd zWn(5l+5+=nRR`sXz(Fp%-Rz>2d@|aPxe6K4G$~_D*C-SeTn48gcos-Sr$sM3JOJaxI{yVG!@RH^?|`bz4A)Nx$uTKzWp7zeKfueF)rov^4M4Kj zIzJ8YN)vXn*zzr)R(c*jUbT|C)H(5sY@z@=G$<81|-cONY!>SOz|_M{l$!;MkU6+ zfCLaiMRS?4vUri9QWBgIDQ?kRrN7T4@1ma(8okIb06XeQ66Yv}XKfGEx^}8}-34F% zPPuq)6Ca&LdZ4)9u0EpbCnR>+ho!pT549aG-ID%|dqjUN?B!kBH0=#oLzCbHm!`8w zbkoVRXbf3At^`st#iO^{Pv*B;cNEJk$Po*0eBh@T_K55KjcdW9UM8#^fHcN&vw*@0 z8%hK`pr#M5o{ zhhO(3e-|c>U+))L2`Tb+TLYC|wk9!xEW(-K071l`to}2DS!fSvPr2eXeH_%jH-7hkBZi(2=7VD=bG2^$`h5W8~lo=w<}vEbnLh1 zJdoX+@3$ioxyrw1AFY*W_}P77i<#d5P;kaQepIcy@I7|<4oDb#`Ma5e6$_(poq;4w zhH_LkpdeX#m^VhnS6KVi9bLlJ7+{%EAP%5i7(!5u*T0Chh_EVpF#Q9TDE&)6i<5uO zZp!=oRoq7ADst&gLu;yf|DvGuznZ! zP%Bt)gAb3*7pWn8)`3TcUvFJlGH{qep_EnV`yQ*Zc|c0+{j&+=f(p2Jb`@TIXwIz_+ps| zux1~qJT<@))yux&v+pn)V{nV058BZgAX^y(*aW`S5d)8vnG!%;Oq$2ZvuImC?FIIJAGlX97!hGGtZGs~Zz{f&nVV{q z(~TP>1)07V-!5A)EKmQ6c|&RfuPT;ZhBo5+D<3 zdI$TOzAK3}J_lb6#FIZ3o}zhfM*c=EtNuL8d`%uhDc~eiGeSv+Y>7deqX&!LXqm_> zuUW-hR}baA?#SP34B!JSE262dZ==^X#KaO4vVdvzrMdE02R#G@cKm+N|C(z9RWxyG zcUQE}AaU;3Z)LCFCu%RX^c#%u%qO7ZZ&5s<-$2L87XY$Fd;qArBopZir*@Bn_0H;f zYLqtQ*b&~+ucKAM$pS_&`WW>wzytFWS*>34%B5^}C2Npn%s3R48UE+=%mR|Zd zju_va4tp~P#tD@|I)=m_eK#-_#qlf>Q*?}szHOg4LNk0`IKOMWEN+to*BVU^M2;^{ ziaj?;A!eq%cj$|O4$o52Tl9x_{8#9uC}$X0P%R>7?Ou z*rtKvA|7NL>vIelm|UOw$#u^!N z+Vk9$p8%mE35d2ch$#|Z{P4+TbgYif-XM^&2=z7%U^>qyhKi;FRWGEr)Skr0QDjHP z>F;S7=gaWFo3p*D9T0NI_5( zhw)Xp>l9UyS8ueh1U|FQPozP!@9<^(!I#>$g{o=$qSthj$%|3=L4q>!!rurNZFP=I zlaeUwI+a)}8Z<$?M8^_n{;`rQCz-ocvh2!`*pQ4^g5co1nIxD1EJUf63DD#L3knH- zf|~OQUj@HG$CK|>(B03&KbsWk^~6>h&k|7#@hKN3$I>ud@>a@gt0a?|Bj*&Xv1U0C z(6`mH8<(Wcz|?*tXo=kZUYo6>Zws*~;U~KaR$nXdZE>WSyH6dZ*9b`c6M193?dWU0-N}OaF-kgl zX8m7SjdR@L${Cz*wUGXFbDC6;wkv{9AhQe}a`do8Z!3`ISHGa?18uop!>hTC4)KbN z7~d?dL2<&gMMI5jza3UBbQnWvC^r6F`xdMyr4eLj; zIF3{9Wa(si_#{{wtBrs`%>W>*GEz$4LC@8h1tCRl#7*WS{3W^ev_c_M&kwdy?feIJ zW|q=~f#6YS$CRY3>Gr?x@gktWppk~hB+4gK?nB`dDai%`RpA5hPAG^0d5UJ z=E>(prO&VvJm!vGGOlvvt}0JZPnrb_y6s+NS!7jcyZmPn;5Rxe&K-hA5EW6z*{AYz z7Ab0=^Lx;K7Pn#fE6h58FC3;ZGNt{(tGw^BQggxeRMczoy{yB{nfiL=*)VV9 zf8FB$N5lW-@7th*Mp1SEVOz@bL?hZ^FVicgwN$4hDy79bdZn8o!=@uM{Ke+E`!O#% z!~R=Fqj>^zRCcr0A~Z2XxAU^;^r(?DJ>Af3M1@URU04HiJQhoq$co^-4Lz^d=YHx6 z!H&jp5%q)U#)2l>jBMW;8C+gpvalb}QBPhMD4@Hur*jb`rna9LL{jLJVu3CJ# z*Cf46a{DwUAWs+_(})+{x{YNQVMc3w;9Rz%(|=bs^dGqAkIdxPMIMv-+7ZyNnc-L| zQc1RfRudVQ-bkcBK0&sBW#7dQv#yjKVwNk39V|GE;)7^6QhtS59fu0+n*>k**y`dU znbojV~Myczu-Q z6v-?K$BN_LoRv20>lJH><(i3^PUW%iQob=cW6{C;A2a-mMgIO0o;d8xPhX7$O^Lw( znByMj907yO47ahLFT@O(G`dVAt7ye_yhNP&MU;}j@;;lu-fA>27x+}t2ZUX1Ao#Y|L@^~+NpN6ZjNH{%+pwLz>1VP#L5NJ*U$A((Q+P8x zyS8I;!l>`rD~xj(&n{;7O5-jfDaq{=#Y-}}88TVsAVcyZx&t$Hj_Jq6pS1*p1G2h) zT+cUqW$E?}9=|_%?CW!y9i)mD;rEyYdviIAwP0+9@w{FdszVZpwqA!OM50Udjc*-= zMmwI0VU;W3%}+x3B#CDN)w_!EQV#ghcd~L2f}(+w`v(~}FM`LFJV*<2(-z6Ioo6i) z4M+C7?&X!(kG_eD84vv@|Nfh)Y!ePoz^$$IB#W;@k<0i;-PrlZm7XYe`2@+L7o{CxVVy98*L`WYccH_L2~I;H0a* z1zTbLES~94j^L{z4AH{7Vj%=|`BEBsUXkcP5Y>BQ5=FxR5)2dyZSa_hSj}+O7{rWe zOi|biGnZyHRAzSXpOCvbzMm-JnO~g6=nr!g>C&aYHG&}*6{Lzt*?lY$PjhaZf&9bw<~P^8j1hc1@^)ScN_X`;1|MSP{ESm z>)UHN^H&Z_=koaksmcXV1YC}h+skqii~d*X{kKh1Y=Vs7X}8AZ`O?CO5b-&%QWD5N zOIzJj1)>^z{+V*@O*~Ygw%kubx1tVY42e1a^8g<6l%0DVqH==b4w*Tl7L8hI_jm2S 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

tw`cdsqsvL;A?R(8- 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 V9hL4cW~l%G002ovPDHLkV1ngR>6ZWi literal 0 HcmV?d00001 From 44af80e055172ba74d49feb5b7c23ddc54c80dd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 12:48:08 +0100 Subject: [PATCH 031/641] Release v4.0.0-v4-beta.26 * chore: Update version for release (v4-beta) * Release v4.0.0-v4-beta.26 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Aitken --- .changeset/pre.json | 5 +++++ packages/build/CHANGELOG.md | 8 ++++++++ packages/build/package.json | 4 ++-- packages/cli-v3/CHANGELOG.md | 10 ++++++++++ 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 | 23 +++++++++++++++++++++++ packages/trigger-sdk/package.json | 4 ++-- pnpm-lock.yaml | 22 +++++++++++----------- 18 files changed, 110 insertions(+), 32 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index a3f385b125d..f6ffff5fcb6 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -19,6 +19,7 @@ "changesets": [ "beige-horses-juggle", "big-carrots-fail", + "big-garlics-own", "blue-eyes-tickle", "breezy-turtles-talk", "chatty-snakes-hope", @@ -26,14 +27,17 @@ "clean-beans-compete", "cuddly-boats-press", "curvy-dogs-share", + "cyan-news-design", "early-points-jam", "eight-ligers-help", "eighty-rings-divide", + "empty-dolls-judge", "fifty-beers-bake", "flat-pianos-live", "four-needles-add", "fuzzy-snakes-beg", "gentle-waves-suffer", + "giant-plums-smash", "gold-insects-invite", "green-lions-relate", "grumpy-wasps-fold", @@ -59,6 +63,7 @@ "polite-impalas-care", "polite-lies-fix", "rare-beds-accept", + "rare-mails-fail", "real-rats-drop", "red-chairs-begin", "red-rings-marry", diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index 9a58424fe7a..5131e90e840 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,13 @@ # @trigger.dev/build +## 4.0.0-v4-beta.26 + +### Patch Changes + +- Add Lightpanda extension ([#2192](https://github.com/triggerdotdev/trigger.dev/pull/2192)) +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index 78f3558e11d..b293f64c7c4 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.25", + "version": "4.0.0-v4-beta.26", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -77,7 +77,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "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 a3bf51b9be5..9c4c195968d 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,15 @@ # trigger.dev +## 4.0.0-v4-beta.26 + +### Patch Changes + +- Allow any runs to finish after SIGTERM but disable warm starts ([#2316](https://github.com/triggerdotdev/trigger.dev/pull/2316)) +- Switch to profile after successful login ([#2192](https://github.com/triggerdotdev/trigger.dev/pull/2192)) +- Updated dependencies: + - `@trigger.dev/build@4.0.0-v4-beta.26` + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 846c0675988..3d4e4c8a9f2 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.25", + "version": "4.0.0-v4-beta.26", "description": "A Command-Line Interface for Trigger.dev (v3) projects", "type": "module", "license": "MIT", @@ -93,8 +93,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.25", - "@trigger.dev/core": "workspace:4.0.0-v4-beta.25", + "@trigger.dev/build": "workspace:4.0.0-v4-beta.26", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 513890632d2..ddf6c0ab3b5 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # internal-platform +## 4.0.0-v4-beta.26 + ## 4.0.0-v4-beta.25 ## 4.0.0-v4-beta.24 diff --git a/packages/core/package.json b/packages/core/package.json index 42c76333a4a..05e781fda91 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.25", + "version": "4.0.0-v4-beta.26", "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 754b5eac3b0..95b27027183 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.0.0-v4-beta.26 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/sdk@4.0.0-v4-beta.26` + - `@trigger.dev/build@4.0.0-v4-beta.26` + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index c57046bdb0d..f726d0cf64a 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.25", + "version": "4.0.0-v4-beta.26", "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.25", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "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.25", - "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.25" + "@trigger.dev/build": "workspace:4.0.0-v4-beta.26", + "@trigger.dev/sdk": "workspace:4.0.0-v4-beta.26" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.25", - "@trigger.dev/build": "workspace:^4.0.0-v4-beta.25" + "@trigger.dev/sdk": "workspace:^4.0.0-v4-beta.26", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.26" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index 8e466a5b467..108cde01af0 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.26 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 1390dc41e25..cebf5806ee6 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.25", + "version": "4.0.0-v4-beta.26", "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.25", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.26", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index 08a55016653..127063ee42d 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.26 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 3698cb76e3f..3625e0f1302 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.25", + "version": "4.0.0-v4-beta.26", "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.25", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "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 43af1bec883..7f4ed8cdd5d 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.0.0-v4-beta.26 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index 3f8a3ca2aa9..5de7a3ceb64 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.25", + "version": "4.0.0-v4-beta.26", "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.25", + "@trigger.dev/core": "workspace:^4.0.0-v4-beta.26", "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.25", + "@trigger.dev/build": "workspace:^4.0.0-v4-beta.26", "@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 c454d6c3a98..00fc15d8861 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,28 @@ # @trigger.dev/sdk +## 4.0.0-v4-beta.26 + +### Patch Changes + +- fix: importing from runEngine/index.js breaks non-node runtimes ([#2328](https://github.com/triggerdotdev/trigger.dev/pull/2328)) +- Added and cleaned up the run ctx param: ([#2322](https://github.com/triggerdotdev/trigger.dev/pull/2322)) + + - New optional properties `ctx.run.parentTaskRunId` and `ctx.run.rootTaskRunId` reference the current run's root/parent ID. + - Removed deprecated properties from `ctx` + - Added a new `ctx.deployment` object that contains information about the deployment associated with the run. + + We also update `metadata.root` and `metadata.parent` to work even when the run is a "root" run (meaning it doesn't have a parent or a root associated run). This now works: + + ```ts + metadata.root.set("foo", "bar"); + metadata.parent.set("baz", 1); + metadata.current().foo; // "bar" + metadata.current().baz; // 1 + ``` + +- Updated dependencies: + - `@trigger.dev/core@4.0.0-v4-beta.26` + ## 4.0.0-v4-beta.25 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index c42cd98d364..8b2ce0db4e5 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.25", + "version": "4.0.0-v4-beta.26", "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.25", + "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 258ebd571ae..90b13719569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1233,7 +1233,7 @@ importers: packages/build: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../core pkg-types: specifier: ^1.1.3 @@ -1309,10 +1309,10 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../build '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../core ansi-escapes: specifier: ^7.0.0 @@ -1659,7 +1659,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1669,10 +1669,10 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../trigger-sdk '@types/node': specifier: 20.14.14 @@ -1696,7 +1696,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.25 + specifier: workspace:^4.0.0-v4-beta.26 version: link:../core react: specifier: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1730,7 +1730,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../core cron-parser: specifier: ^4.9.0 @@ -1773,7 +1773,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.0.0-v4-beta.25 + specifier: workspace:^4.0.0-v4-beta.26 version: link:../core mlly: specifier: ^1.7.1 @@ -1789,7 +1789,7 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:^4.0.0-v4-beta.25 + specifier: workspace:^4.0.0-v4-beta.26 version: link:../build '@types/node': specifier: ^20.14.14 @@ -1822,7 +1822,7 @@ importers: specifier: 1.25.1 version: 1.25.1 '@trigger.dev/core': - specifier: workspace:4.0.0-v4-beta.25 + specifier: workspace:4.0.0-v4-beta.26 version: link:../core chalk: specifier: ^5.2.0 From b9e62139ffee5f73fda58fbfb1c03f56390d35d7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 1 Aug 2025 14:17:40 +0100 Subject: [PATCH 032/641] v4 docs upgrade changelog (#2331) --- docs/upgrade-to-v4.mdx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/upgrade-to-v4.mdx b/docs/upgrade-to-v4.mdx index d001a190f85..9dcebd676e5 100644 --- a/docs/upgrade-to-v4.mdx +++ b/docs/upgrade-to-v4.mdx @@ -1183,3 +1183,18 @@ We recommend enabling this option and testing in a staging or preview environmen - Gracefully shutdown task run processes using SIGTERM followed by SIGKILL after a 1s timeout. This also prevents cancelled or completed runs from leaving orphaned task run processes behind ([#2299](https://github.com/triggerdotdev/trigger.dev/pull/2299)) + + + [Release + v4.0.0-beta.26](https://github.com/triggerdotdev/trigger.dev/releases/tag/trigger.dev%404.0.0-v4-beta.26). + +- Allow any runs to finish after SIGTERM but disable warm starts ([#2316](https://github.com/triggerdotdev/trigger.dev/pull/2316)) +- Switch to profile after successful login ([#2192](https://github.com/triggerdotdev/trigger.dev/pull/2192)) +- Fixed importing from runEngine/index.js breaking non-node runtimes ([#2328](https://github.com/triggerdotdev/trigger.dev/pull/2328)) +- Added and cleaned up the run ctx param ([#2322](https://github.com/triggerdotdev/trigger.dev/pull/2322)): + - New optional properties `ctx.run.parentTaskRunId` and `ctx.run.rootTaskRunId` reference the current run's root/parent ID + - Removed deprecated properties from `ctx` + - Added a new `ctx.deployment` object that contains information about the deployment associated with the run + - Updated `metadata.root` and `metadata.parent` to work even when the run is a "root" run (meaning it doesn't have a parent or a root associated run) + + From c576e98dbc37e624bb10ff19796255c7812b2b67 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 1 Aug 2025 15:23:55 +0100 Subject: [PATCH 033/641] Fix v4 changelog latest date (#2333) --- docs/upgrade-to-v4.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upgrade-to-v4.mdx b/docs/upgrade-to-v4.mdx index 9dcebd676e5..d77af69777e 100644 --- a/docs/upgrade-to-v4.mdx +++ b/docs/upgrade-to-v4.mdx @@ -1184,7 +1184,7 @@ We recommend enabling this option and testing in a staging or preview environmen - + [Release v4.0.0-beta.26](https://github.com/triggerdotdev/trigger.dev/releases/tag/trigger.dev%404.0.0-v4-beta.26). From 3fd8ea80dac50c7121986f556a833d657ded05b8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 1 Aug 2025 17:19:58 +0100 Subject: [PATCH 034/641] fix: backwards compatible snapshot runStatus to prevent DEQUEUED status from breaking older runners (#2335) --- .../run-engine/src/engine/systems/executionSnapshotSystem.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts index 8ab2de2da04..a9d5600aeac 100644 --- a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -277,7 +277,8 @@ export class ExecutionSnapshotSystem { description: snapshot.description, previousSnapshotId, runId: run.id, - runStatus: run.status, + // We can't set the runStatus to DEQUEUED because it will break older runners + runStatus: run.status === "DEQUEUED" ? "PENDING" : run.status, attemptNumber: run.attemptNumber ?? undefined, batchId, environmentId, From 60b8c76adc37155c2d77b8484b7917e056bd226a Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:20:55 +0100 Subject: [PATCH 035/641] fix(hosting): pass registry namespace env var to webapp, bump helm (#2336) * pass deploy registry namespace to webapp * bump helm chart and images --- hosting/docker/webapp/docker-compose.yml | 1 + hosting/k8s/helm/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hosting/docker/webapp/docker-compose.yml b/hosting/docker/webapp/docker-compose.yml index 5b3968d11f1..1935ad5edcd 100644 --- a/hosting/docker/webapp/docker-compose.yml +++ b/hosting/docker/webapp/docker-compose.yml @@ -50,6 +50,7 @@ services: APP_LOG_LEVEL: info DEV_OTEL_EXPORTER_OTLP_ENDPOINT: ${DEV_OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:8030/otel} DEPLOY_REGISTRY_HOST: ${DOCKER_REGISTRY_URL:-localhost:5000} + DEPLOY_REGISTRY_NAMESPACE: ${DOCKER_REGISTRY_NAMESPACE:-trigger} OBJECT_STORE_BASE_URL: ${OBJECT_STORE_BASE_URL:-http://minio:9000} OBJECT_STORE_ACCESS_KEY_ID: ${OBJECT_STORE_ACCESS_KEY_ID} OBJECT_STORE_SECRET_ACCESS_KEY: ${OBJECT_STORE_SECRET_ACCESS_KEY} diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index 0eb99f68f6c..fd9b8578835 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: trigger description: The official Trigger.dev Helm chart type: application -version: 4.0.0-beta.18 -appVersion: v4.0.0-v4-beta.23 +version: 4.0.0-beta.19 +appVersion: v4.0.0-v4-beta.26.1 home: https://trigger.dev sources: - https://github.com/triggerdotdev/trigger.dev From 7b54c3527e602a4a22486d49298e05237c3bd86d Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 2 Aug 2025 16:54:11 +0100 Subject: [PATCH 036/641] fix(otel): prevent infinite retry loops on unicode hex escape errors (#2337) * prevent infinite retry loop for unicode errors * structured logs for prisma events * preserve all prisma event fields * either use structured logs or stdout, never both * split runs repo tests * decrease test shards to 8 --- .github/workflows/unit-tests-webapp.yml | 4 +- apps/webapp/app/db.server.ts | 158 +++- apps/webapp/app/v3/eventRepository.server.ts | 92 ++- apps/webapp/test/runsRepository.part1.test.ts | 749 ++++++++++++++++++ ...y.test.ts => runsRepository.part2.test.ts} | 737 +---------------- 5 files changed, 963 insertions(+), 777 deletions(-) create mode 100644 apps/webapp/test/runsRepository.part1.test.ts rename apps/webapp/test/{runsRepository.test.ts => runsRepository.part2.test.ts} (51%) diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml index 26599f43312..f73eada8421 100644 --- a/.github/workflows/unit-tests-webapp.yml +++ b/.github/workflows/unit-tests-webapp.yml @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - shardTotal: [10] + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] + shardTotal: [8] env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} SHARD_INDEX: ${{ matrix.shardIndex }} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 969f3f02765..c99b2e2c437 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -122,30 +122,89 @@ function getClient() { url: databaseUrl.href, }, }, - // @ts-expect-error log: [ + // events { - emit: "stdout", + emit: "event", level: "error", }, { - emit: "stdout", + emit: "event", level: "info", }, { - emit: "stdout", + emit: "event", level: "warn", }, - ].concat( - process.env.VERBOSE_PRISMA_LOGS === "1" + // stdout + ...((process.env.PRISMA_LOG_TO_STDOUT === "1" ? [ - { emit: "event", level: "query" }, - { emit: "stdout", level: "query" }, + { + emit: "stdout", + level: "error", + }, + { + emit: "stdout", + level: "info", + }, + { + emit: "stdout", + level: "warn", + }, ] - : [] - ), + : []) satisfies Prisma.LogDefinition[]), + // verbose + ...((process.env.VERBOSE_PRISMA_LOGS === "1" + ? [ + { + emit: "event", + level: "query", + }, + { + emit: "stdout", + level: "query", + }, + ] + : []) satisfies Prisma.LogDefinition[]), + ], }); + // Only use structured logging if we're not already logging to stdout + if (process.env.PRISMA_LOG_TO_STDOUT !== "1") { + client.$on("info", (log) => { + logger.info("PrismaClient info", { + clientType: "writer", + event: { + timestamp: log.timestamp, + message: log.message, + target: log.target, + }, + }); + }); + + client.$on("warn", (log) => { + logger.warn("PrismaClient warn", { + clientType: "writer", + event: { + timestamp: log.timestamp, + message: log.message, + target: log.target, + }, + }); + }); + + client.$on("error", (log) => { + logger.error("PrismaClient error", { + clientType: "writer", + event: { + timestamp: log.timestamp, + message: log.message, + target: log.target, + }, + }); + }); + } + // connect eagerly client.$connect(); @@ -174,30 +233,89 @@ function getReplicaClient() { url: replicaUrl.href, }, }, - // @ts-expect-error log: [ + // events { - emit: "stdout", + emit: "event", level: "error", }, { - emit: "stdout", + emit: "event", level: "info", }, { - emit: "stdout", + emit: "event", level: "warn", }, - ].concat( - process.env.VERBOSE_PRISMA_LOGS === "1" + // stdout + ...((process.env.PRISMA_LOG_TO_STDOUT === "1" ? [ - { emit: "event", level: "query" }, - { emit: "stdout", level: "query" }, + { + emit: "stdout", + level: "error", + }, + { + emit: "stdout", + level: "info", + }, + { + emit: "stdout", + level: "warn", + }, ] - : [] - ), + : []) satisfies Prisma.LogDefinition[]), + // verbose + ...((process.env.VERBOSE_PRISMA_LOGS === "1" + ? [ + { + emit: "event", + level: "query", + }, + { + emit: "stdout", + level: "query", + }, + ] + : []) satisfies Prisma.LogDefinition[]), + ], }); + // Only use structured logging if we're not already logging to stdout + if (process.env.PRISMA_LOG_TO_STDOUT !== "1") { + replicaClient.$on("info", (log) => { + logger.info("PrismaClient info", { + clientType: "reader", + event: { + timestamp: log.timestamp, + message: log.message, + target: log.target, + }, + }); + }); + + replicaClient.$on("warn", (log) => { + logger.warn("PrismaClient warn", { + clientType: "reader", + event: { + timestamp: log.timestamp, + message: log.message, + target: log.target, + }, + }); + }); + + replicaClient.$on("error", (log) => { + logger.error("PrismaClient error", { + clientType: "reader", + event: { + timestamp: log.timestamp, + message: log.message, + target: log.target, + }, + }); + }); + } + // connect eagerly replicaClient.$connect(); diff --git a/apps/webapp/app/v3/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository.server.ts index 1fb88e969ff..dc4fdd43e53 100644 --- a/apps/webapp/app/v3/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository.server.ts @@ -1230,25 +1230,23 @@ export class EventRepository { return events; } catch (error) { - if (error instanceof Prisma.PrismaClientUnknownRequestError) { - logger.error("Failed to insert events, most likely because of null characters", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - clientVersion: error.clientVersion, - }, + if (isRetriablePrismaError(error)) { + const isKnownError = error instanceof Prisma.PrismaClientKnownRequestError; + span.setAttribute("prisma_error_type", isKnownError ? "known" : "unknown"); + + const errorDetails = getPrismaErrorDetails(error); + if (errorDetails.code) { + span.setAttribute("prisma_error_code", errorDetails.code); + } + + logger.error("Failed to insert events, will attempt bisection", { + error: errorDetails, }); if (events.length === 1) { logger.debug("Attempting to insert event individually and it failed", { event: events[0], - error: { - name: error.name, - message: error.message, - stack: error.stack, - clientVersion: error.clientVersion, - }, + error: errorDetails, }); span.setAttribute("failed_event_count", 1); @@ -1258,12 +1256,7 @@ export class EventRepository { if (depth > MAX_FLUSH_DEPTH) { logger.error("Failed to insert events, reached maximum depth", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - clientVersion: error.clientVersion, - }, + error: errorDetails, depth, eventsCount: events.length, }); @@ -1917,3 +1910,62 @@ export async function recordRunDebugLog( }, }); } + +/** + * Extracts error details from Prisma errors in a type-safe way. + * Only includes 'code' property for PrismaClientKnownRequestError. + */ +function getPrismaErrorDetails( + error: Prisma.PrismaClientUnknownRequestError | Prisma.PrismaClientKnownRequestError +): { + name: string; + message: string; + stack: string | undefined; + clientVersion: string; + code?: string; +} { + const base = { + name: error.name, + message: error.message, + stack: error.stack, + clientVersion: error.clientVersion, + }; + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + return { ...base, code: error.code }; + } + + return base; +} + +/** + * Checks if a PrismaClientKnownRequestError is a Unicode/hex escape error. + */ +function isUnicodeError(error: Prisma.PrismaClientKnownRequestError): boolean { + return ( + error.message.includes("lone leading surrogate in hex escape") || + error.message.includes("unexpected end of hex escape") || + error.message.includes("invalid Unicode") || + error.message.includes("invalid escape sequence") + ); +} + +/** + * Determines if a Prisma error should be retried with bisection logic. + * Returns true for errors that might be resolved by splitting the batch. + */ +function isRetriablePrismaError( + error: unknown +): error is Prisma.PrismaClientUnknownRequestError | Prisma.PrismaClientKnownRequestError { + if (error instanceof Prisma.PrismaClientUnknownRequestError) { + // Always retry unknown errors with bisection + return true; + } + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + // Only retry known errors if they're Unicode/hex escape related + return isUnicodeError(error); + } + + return false; +} diff --git a/apps/webapp/test/runsRepository.part1.test.ts b/apps/webapp/test/runsRepository.part1.test.ts new file mode 100644 index 00000000000..45d91ad44e7 --- /dev/null +++ b/apps/webapp/test/runsRepository.part1.test.ts @@ -0,0 +1,749 @@ +import { describe, expect, vi } from "vitest"; + +// Mock the db prisma client +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, +})); + +import { containerTest } from "@internal/testcontainers"; +import { setTimeout } from "node:timers/promises"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { setupClickhouseReplication } from "./utils/replicationUtils"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunsRepository (part 1/2)", () => { + containerTest( + "should list runs, using clickhouse as the source", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Now we insert a row into the table + const taskRun = await prisma.taskRun.create({ + data: { + friendlyId: "run_1234", + taskIdentifier: "my-task", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + const { runs, pagination } = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + }); + + expect(runs).toHaveLength(1); + expect(runs[0].id).toBe(taskRun.id); + expect(pagination.nextCursor).toBe(null); + expect(pagination.previousCursor).toBe(null); + } + ); + + containerTest( + "should filter runs by task identifiers", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Create runs with different task identifiers + const taskRun1 = await prisma.taskRun.create({ + data: { + friendlyId: "run_task1", + taskIdentifier: "task-1", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + const taskRun2 = await prisma.taskRun.create({ + data: { + friendlyId: "run_task2", + taskIdentifier: "task-2", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1235", + spanId: "1235", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + const taskRun3 = await prisma.taskRun.create({ + data: { + friendlyId: "run_task3", + taskIdentifier: "task-3", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1236", + spanId: "1236", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + // Test filtering by specific tasks + const { runs } = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + tasks: ["task-1", "task-2"], + }); + + expect(runs).toHaveLength(2); + expect(runs.map((r) => r.taskIdentifier).sort()).toEqual(["task-1", "task-2"]); + } + ); + + containerTest( + "should filter runs by task versions", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Create runs with different task versions + await prisma.taskRun.create({ + data: { + friendlyId: "run_v1", + taskIdentifier: "my-task", + taskVersion: "1.0.0", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_v2", + taskIdentifier: "my-task", + taskVersion: "2.0.0", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1235", + spanId: "1235", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_v3", + taskIdentifier: "my-task", + taskVersion: "3.0.0", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1236", + spanId: "1236", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + // Test filtering by specific versions + const { runs } = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + versions: ["1.0.0", "3.0.0"], + }); + + expect(runs).toHaveLength(2); + expect(runs.map((r) => r.taskVersion).sort()).toEqual(["1.0.0", "3.0.0"]); + } + ); + + containerTest( + "should filter runs by status", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Create runs with different statuses + await prisma.taskRun.create({ + data: { + friendlyId: "run_pending", + taskIdentifier: "my-task", + status: "PENDING", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_executing", + taskIdentifier: "my-task", + status: "EXECUTING", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1235", + spanId: "1235", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_completed", + taskIdentifier: "my-task", + status: "COMPLETED_SUCCESSFULLY", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1236", + spanId: "1236", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + // Test filtering by specific statuses + const { runs } = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + statuses: ["PENDING", "COMPLETED_SUCCESSFULLY"], + }); + + expect(runs).toHaveLength(2); + expect(runs.map((r) => r.status).sort()).toEqual(["COMPLETED_SUCCESSFULLY", "PENDING"]); + } + ); + + containerTest( + "should filter runs by tags", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Create runs with different tags + const taskRun1 = await prisma.taskRun.create({ + data: { + friendlyId: "run_urgent", + taskIdentifier: "my-task", + runTags: ["urgent", "production"], + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + const taskRun2 = await prisma.taskRun.create({ + data: { + friendlyId: "run_regular", + taskIdentifier: "my-task", + runTags: ["regular", "development"], + payload: JSON.stringify({ foo: "bar" }), + traceId: "1235", + spanId: "1235", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + const taskRun3 = await prisma.taskRun.create({ + data: { + friendlyId: "run_urgent_dev", + taskIdentifier: "my-task", + runTags: ["urgent", "development"], + payload: JSON.stringify({ foo: "bar" }), + traceId: "1236", + spanId: "1236", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + // Test filtering by tags + const { runs } = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + tags: ["urgent"], + }); + + expect(runs).toHaveLength(2); + expect(runs.map((r) => r.friendlyId).sort()).toEqual(["run_urgent", "run_urgent_dev"]); + } + ); + + containerTest( + "should filter runs by scheduleId", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Create runs with different schedule IDs + await prisma.taskRun.create({ + data: { + friendlyId: "run_scheduled_1", + taskIdentifier: "my-task", + scheduleId: "schedule_1", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_scheduled_2", + taskIdentifier: "my-task", + scheduleId: "schedule_2", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1235", + spanId: "1235", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_unscheduled", + taskIdentifier: "my-task", + payload: JSON.stringify({ foo: "bar" }), + traceId: "1236", + spanId: "1236", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + // Test filtering by schedule ID + const { runs } = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + scheduleId: "schedule_1", + }); + + expect(runs).toHaveLength(1); + expect(runs[0].friendlyId).toBe("run_scheduled_1"); + } + ); + + containerTest( + "should filter runs by isTest flag", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { + title: "test", + slug: "test", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + + // Create test and non-test runs + await prisma.taskRun.create({ + data: { + friendlyId: "run_test", + taskIdentifier: "my-task", + isTest: true, + payload: JSON.stringify({ foo: "bar" }), + traceId: "1234", + spanId: "1234", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_production", + taskIdentifier: "my-task", + isTest: false, + payload: JSON.stringify({ foo: "bar" }), + traceId: "1235", + spanId: "1235", + queue: "test", + runtimeEnvironmentId: runtimeEnvironment.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ + prisma, + clickhouse, + }); + + // Test filtering by isTest=true + const testRuns = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + isTest: true, + }); + + expect(testRuns.runs).toHaveLength(1); + expect(testRuns.runs[0].friendlyId).toBe("run_test"); + + // Test filtering by isTest=false + const productionRuns = await runsRepository.listRuns({ + page: { size: 10 }, + projectId: project.id, + environmentId: runtimeEnvironment.id, + organizationId: organization.id, + isTest: false, + }); + + expect(productionRuns.runs).toHaveLength(1); + expect(productionRuns.runs[0].friendlyId).toBe("run_production"); + } + ); +}); \ No newline at end of file diff --git a/apps/webapp/test/runsRepository.test.ts b/apps/webapp/test/runsRepository.part2.test.ts similarity index 51% rename from apps/webapp/test/runsRepository.test.ts rename to apps/webapp/test/runsRepository.part2.test.ts index 60b36ecd206..5fbe80ce52e 100644 --- a/apps/webapp/test/runsRepository.test.ts +++ b/apps/webapp/test/runsRepository.part2.test.ts @@ -13,740 +13,7 @@ import { setupClickhouseReplication } from "./utils/replicationUtils"; vi.setConfig({ testTimeout: 60_000 }); -describe("RunsRepository", () => { - containerTest( - "should list runs, using clickhouse as the source", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Now we insert a row into the table - const taskRun = await prisma.taskRun.create({ - data: { - friendlyId: "run_1234", - taskIdentifier: "my-task", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - const { runs, pagination } = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - }); - - expect(runs).toHaveLength(1); - expect(runs[0].id).toBe(taskRun.id); - expect(pagination.nextCursor).toBe(null); - expect(pagination.previousCursor).toBe(null); - } - ); - - containerTest( - "should filter runs by task identifiers", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Create runs with different task identifiers - const taskRun1 = await prisma.taskRun.create({ - data: { - friendlyId: "run_task1", - taskIdentifier: "task-1", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - const taskRun2 = await prisma.taskRun.create({ - data: { - friendlyId: "run_task2", - taskIdentifier: "task-2", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1235", - spanId: "1235", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - const taskRun3 = await prisma.taskRun.create({ - data: { - friendlyId: "run_task3", - taskIdentifier: "task-3", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1236", - spanId: "1236", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - // Test filtering by specific tasks - const { runs } = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - tasks: ["task-1", "task-2"], - }); - - expect(runs).toHaveLength(2); - expect(runs.map((r) => r.taskIdentifier).sort()).toEqual(["task-1", "task-2"]); - } - ); - - containerTest( - "should filter runs by task versions", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Create runs with different task versions - await prisma.taskRun.create({ - data: { - friendlyId: "run_v1", - taskIdentifier: "my-task", - taskVersion: "1.0.0", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_v2", - taskIdentifier: "my-task", - taskVersion: "2.0.0", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1235", - spanId: "1235", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_v3", - taskIdentifier: "my-task", - taskVersion: "3.0.0", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1236", - spanId: "1236", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - // Test filtering by specific versions - const { runs } = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - versions: ["1.0.0", "3.0.0"], - }); - - expect(runs).toHaveLength(2); - expect(runs.map((r) => r.taskVersion).sort()).toEqual(["1.0.0", "3.0.0"]); - } - ); - - containerTest( - "should filter runs by status", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Create runs with different statuses - await prisma.taskRun.create({ - data: { - friendlyId: "run_pending", - taskIdentifier: "my-task", - status: "PENDING", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_executing", - taskIdentifier: "my-task", - status: "EXECUTING", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1235", - spanId: "1235", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_completed", - taskIdentifier: "my-task", - status: "COMPLETED_SUCCESSFULLY", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1236", - spanId: "1236", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - // Test filtering by specific statuses - const { runs } = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - statuses: ["PENDING", "COMPLETED_SUCCESSFULLY"], - }); - - expect(runs).toHaveLength(2); - expect(runs.map((r) => r.status).sort()).toEqual(["COMPLETED_SUCCESSFULLY", "PENDING"]); - } - ); - - containerTest( - "should filter runs by tags", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Create runs with different tags - const taskRun1 = await prisma.taskRun.create({ - data: { - friendlyId: "run_urgent", - taskIdentifier: "my-task", - runTags: ["urgent", "production"], - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - const taskRun2 = await prisma.taskRun.create({ - data: { - friendlyId: "run_regular", - taskIdentifier: "my-task", - runTags: ["regular", "development"], - payload: JSON.stringify({ foo: "bar" }), - traceId: "1235", - spanId: "1235", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - const taskRun3 = await prisma.taskRun.create({ - data: { - friendlyId: "run_urgent_dev", - taskIdentifier: "my-task", - runTags: ["urgent", "development"], - payload: JSON.stringify({ foo: "bar" }), - traceId: "1236", - spanId: "1236", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - // Test filtering by tags - const { runs } = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - tags: ["urgent"], - }); - - expect(runs).toHaveLength(2); - expect(runs.map((r) => r.friendlyId).sort()).toEqual(["run_urgent", "run_urgent_dev"]); - } - ); - - containerTest( - "should filter runs by scheduleId", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Create runs with different schedule IDs - await prisma.taskRun.create({ - data: { - friendlyId: "run_scheduled_1", - taskIdentifier: "my-task", - scheduleId: "schedule_1", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_scheduled_2", - taskIdentifier: "my-task", - scheduleId: "schedule_2", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1235", - spanId: "1235", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_unscheduled", - taskIdentifier: "my-task", - payload: JSON.stringify({ foo: "bar" }), - traceId: "1236", - spanId: "1236", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - // Test filtering by schedule ID - const { runs } = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - scheduleId: "schedule_1", - }); - - expect(runs).toHaveLength(1); - expect(runs[0].friendlyId).toBe("run_scheduled_1"); - } - ); - - containerTest( - "should filter runs by isTest flag", - async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { - const { clickhouse } = await setupClickhouseReplication({ - prisma, - databaseUrl: postgresContainer.getConnectionUri(), - clickhouseUrl: clickhouseContainer.getConnectionUrl(), - redisOptions, - }); - - const organization = await prisma.organization.create({ - data: { - title: "test", - slug: "test", - }, - }); - - const project = await prisma.project.create({ - data: { - name: "test", - slug: "test", - organizationId: organization.id, - externalRef: "test", - }, - }); - - const runtimeEnvironment = await prisma.runtimeEnvironment.create({ - data: { - slug: "test", - type: "DEVELOPMENT", - projectId: project.id, - organizationId: organization.id, - apiKey: "test", - pkApiKey: "test", - shortcode: "test", - }, - }); - - // Create test and non-test runs - await prisma.taskRun.create({ - data: { - friendlyId: "run_test", - taskIdentifier: "my-task", - isTest: true, - payload: JSON.stringify({ foo: "bar" }), - traceId: "1234", - spanId: "1234", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await prisma.taskRun.create({ - data: { - friendlyId: "run_production", - taskIdentifier: "my-task", - isTest: false, - payload: JSON.stringify({ foo: "bar" }), - traceId: "1235", - spanId: "1235", - queue: "test", - runtimeEnvironmentId: runtimeEnvironment.id, - projectId: project.id, - organizationId: organization.id, - environmentType: "DEVELOPMENT", - engine: "V2", - }, - }); - - await setTimeout(1000); - - const runsRepository = new RunsRepository({ - prisma, - clickhouse, - }); - - // Test filtering by isTest=true - const testRuns = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - isTest: true, - }); - - expect(testRuns.runs).toHaveLength(1); - expect(testRuns.runs[0].friendlyId).toBe("run_test"); - - // Test filtering by isTest=false - const productionRuns = await runsRepository.listRuns({ - page: { size: 10 }, - projectId: project.id, - environmentId: runtimeEnvironment.id, - organizationId: organization.id, - isTest: false, - }); - - expect(productionRuns.runs).toHaveLength(1); - expect(productionRuns.runs[0].friendlyId).toBe("run_production"); - } - ); - +describe("RunsRepository (part 2/2)", () => { containerTest( "should filter runs by rootOnly flag", async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { @@ -1522,4 +789,4 @@ describe("RunsRepository", () => { expect(secondPage.pagination.previousCursor).toBeTruthy(); } ); -}); +}); \ No newline at end of file From 9ecda816dc65c96e978d2f814649881f9f711444 Mon Sep 17 00:00:00 2001 From: Boon Kai Date: Mon, 4 Aug 2025 19:09:38 +0800 Subject: [PATCH 037/641] fix(cli): improve contrast for chalkWorker yellow in light mode (#2239) * fix(cli-output): improve color contrast for chalkWorker on light mode * add changeset --------- Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- .changeset/giant-rivers-tease.md | 5 +++++ packages/cli-v3/src/utilities/cliOutput.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/giant-rivers-tease.md diff --git a/.changeset/giant-rivers-tease.md b/.changeset/giant-rivers-tease.md new file mode 100644 index 00000000000..43ff6b9428a --- /dev/null +++ b/.changeset/giant-rivers-tease.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +improve contrast for chalkWorker in light mode diff --git a/packages/cli-v3/src/utilities/cliOutput.ts b/packages/cli-v3/src/utilities/cliOutput.ts index 8b499c87c99..7d950c40a51 100644 --- a/packages/cli-v3/src/utilities/cliOutput.ts +++ b/packages/cli-v3/src/utilities/cliOutput.ts @@ -38,7 +38,7 @@ export function chalkLink(text: string) { } export function chalkWorker(text: string) { - return chalk.hex("#FFFF89")(text); + return chalk.yellowBright(text); } export function chalkTask(text: string) { From b98584364247ebdf7b029922ddf90e2bdc8fa04b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:13:03 +0100 Subject: [PATCH 038/641] Prevents long project menu titles pushing the admin buttons out of bounds (#2332) --- .../app/components/navigation/SideMenu.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index de7c045ad14..20d2a821db8 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -143,16 +143,18 @@ export function SideMenu({ >

- +
+ +
{isAdmin && !user.isImpersonating ? ( From 63857b0445a535361abdd171dea2669e34d3f818 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:13:38 +0100 Subject: [PATCH 039/641] =?UTF-8?q?Makes=20it=20clear=20you=20only=20get?= =?UTF-8?q?=20preview=20branches=20if=20you=E2=80=99re=20on=20v4=20(#2343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/BlankStatePanels.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 03ebae66ee1..4dfe4045f2c 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -477,6 +477,10 @@ export function BranchesNoBranchableEnvironment() { Preview branches in Trigger.dev create isolated environments for testing new features before production. + + You must be on to access preview branches. Read our{" "} + upgrade to v4 guide to learn more. + ); } From df0dc0767806e3c70deffb8785d81043dbccd848 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:17:06 +0100 Subject: [PATCH 040/641] New Task page deploy blank state (#2344) * Adds a step by step deploy blank state to Tasks page * Adds links to Ask AI, docs and troubleshooting --- .../app/components/BlankStatePanels.tsx | 87 ++++++++++++++----- apps/webapp/app/components/SetupCommands.tsx | 61 +++++++++++++ .../route.tsx | 3 +- 3 files changed, 126 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 4dfe4045f2c..380a6d990c4 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -5,6 +5,7 @@ import { ChatBubbleLeftRightIcon, ClockIcon, PlusIcon, + QuestionMarkCircleIcon, RectangleGroupIcon, RectangleStackIcon, ServerStackIcon, @@ -12,7 +13,6 @@ import { } from "@heroicons/react/20/solid"; import { useLocation } from "react-use"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; -import { TaskIcon } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png"; import selectRunsIndividually from "~/assets/images/select-runs-individually.png"; @@ -32,8 +32,9 @@ import { v3NewProjectAlertPath, v3NewSchedulePath, } from "~/utils/pathBuilder"; +import { AskAI } from "./AskAI"; import { InlineCode } from "./code/InlineCode"; -import { environmentFullTitle } from "./environments/EnvironmentLabel"; +import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel"; import { Feedback } from "./Feedback"; import { EnvironmentSelector } from "./navigation/EnvironmentSelector"; import { Button, LinkButton } from "./primitives/Buttons"; @@ -42,7 +43,13 @@ import { InfoPanel } from "./primitives/InfoPanel"; import { Paragraph } from "./primitives/Paragraph"; import { StepNumber } from "./primitives/StepNumber"; import { TextLink } from "./primitives/TextLink"; -import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; +import { SimpleTooltip } from "./primitives/Tooltip"; +import { + InitCommandV3, + PackageManagerProvider, + TriggerDeployStep, + TriggerDevStepV3, +} from "./SetupCommands"; import { StepContentContainer } from "./StepContentContainer"; import { V4Badge } from "./V4Badge"; @@ -87,26 +94,60 @@ export function HasNoTasksDev() { export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) { return ( - - How to deploy tasks - - } - > - - Run the CLI deploy command to - deploy your tasks to the {environmentFullTitle(environment)} environment. - - + +
+
+
+ + Deploy your tasks to {environmentFullTitle(environment)} +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + + This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read + the full guide. + + + + + + + Read the GitHub Actions guide to + get started. + + + + + This page will automatically refresh when your tasks are deployed. + +
+
); } diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index e68273a0dbf..accb2f65a8f 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -208,3 +208,64 @@ export function TriggerLoginStepV3({ title }: TabsProps) { ); } + +export function TriggerDeployStep({ title, environment }: TabsProps & { environment: { type: string } }) { + const triggerCliTag = useTriggerCliTag(); + const { activePackageManager, setActivePackageManager } = usePackageManager(); + + // Generate the environment flag based on environment type + const getEnvironmentFlag = () => { + switch (environment.type) { + case "STAGING": + return " --env staging"; + case "PREVIEW": + return " --env preview"; + case "PRODUCTION": + default: + return ""; + } + }; + + const environmentFlag = getEnvironmentFlag(); + + return ( + +
+ {title && {title}} + + npm + pnpm + yarn + +
+ + + + + + + + + +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 29efff3dc03..5c702acb78f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -77,7 +77,6 @@ import { type TaskActivity, type TaskListItem, taskListPresenter, - TaskListPresenter, } from "~/presenters/v3/TaskListPresenter.server"; import { getUsefulLinksPreference, @@ -411,7 +410,7 @@ export default function Page() { ) : ( - + )} From f8126871f38677638eb5dfa15e72047e7f2b3aac Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:18:07 +0100 Subject: [PATCH 041/641] Improves large output messaging (#2341) * Adds more information to the large payloads/outputs * Changes the Download logs button to secondary --- .../app/components/runs/v3/PacketDisplay.tsx | 29 +++++++++++++++---- .../route.tsx | 6 ++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx index 5d5878a93db..8e30b2df757 100644 --- a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx +++ b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx @@ -1,7 +1,11 @@ import { CloudArrowDownIcon } from "@heroicons/react/20/solid"; import { CodeBlock } from "~/components/code/CodeBlock"; +import { InlineCode } from "~/components/code/InlineCode"; import { LinkButton } from "~/components/primitives/Buttons"; +import { Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { TextLink } from "~/components/primitives/TextLink"; +import { docsPath } from "~/utils/pathBuilder"; export function PacketDisplay({ data, @@ -15,13 +19,26 @@ export function PacketDisplay({ switch (dataType) { case "application/store": { return ( -
- - {title} +
+ {title} + + This {title.toLowerCase()} exceeded the size limit and was automatically offloaded to + object storage. You can retrieve it using{" "} + runs.retrieve or download it directly + below. Learn more + . - - Download - +
+ + Download {title.toLowerCase()} + +
); } 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 7158c57b01b..f68885c58dc 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 @@ -7,7 +7,6 @@ import { import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds, - MachinePresetName, type TaskRunError, taskRunErrorEnhancer, } from "@trigger.dev/core/v3"; @@ -804,14 +803,15 @@ function RunBody({ {run.isCached ? "Jump to original run" : "Focus on run"} )} +
-
{run.logsDeletedAt === null ? ( From 0fc234b97077d8bb2398c103d3a31fcba5dda139 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:18:32 +0100 Subject: [PATCH 042/641] Fixes incorrect icon colors (#2339) --- .../route.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 5c702acb78f..10f05e48cd9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -371,12 +371,13 @@ export default function Page() { icon={RunsIcon} to={path} title="View runs" - leadingIconClassName="text-teal-500" + leadingIconClassName="text-runs" /> } From 792c97339e8d26290234aef2e79696f62df83bd7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:19:57 +0100 Subject: [PATCH 043/641] Force-wrap long strings in the run error callout (#2340) --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f68885c58dc..6931df64cbb 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 @@ -855,7 +855,7 @@ function RunError({ error }: { error: TaskRunError }) { {name} {enhancedError.message && ( -
+              
                 {enhancedError.message}
               
From edafc71142e3af865037264d8e6f42fdaddf6a1f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:21:03 +0100 Subject: [PATCH 044/641] Explain run status on run inspector panel (#2342) --- .../route.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 6931df64cbb..846f2567b44 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 @@ -45,7 +45,10 @@ import { RunTag } from "~/components/runs/v3/RunTag"; import { SpanEvents } from "~/components/runs/v3/SpanEvents"; import { SpanTitle } from "~/components/runs/v3/SpanTitle"; import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptStatus"; -import { TaskRunStatusCombo, TaskRunStatusReason } from "~/components/runs/v3/TaskRunStatus"; +import { + descriptionForTaskRunStatus, + TaskRunStatusCombo, +} from "~/components/runs/v3/TaskRunStatus"; import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { WarmStartCombo } from "~/components/WarmStarts"; @@ -402,7 +405,10 @@ function RunBody({ Status - + } + content={descriptionForTaskRunStatus(run.status)} + /> @@ -766,9 +772,11 @@ function RunBody({
) : (
-
- - +
+ } + content={descriptionForTaskRunStatus(run.status)} + />
From bb54e978063cafc044290e795acc489133946de0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:21:30 +0100 Subject: [PATCH 045/641] Show text wrapping on code blocks (#2338) * Show text wrapping on deployment code blocks * Show textWrapping on some more CodeBlocks * Adds missing margins to the error block --- apps/webapp/app/components/runs/v3/DeploymentError.tsx | 4 +++- apps/webapp/app/components/runs/v3/PacketDisplay.tsx | 1 + .../route.tsx | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/DeploymentError.tsx b/apps/webapp/app/components/runs/v3/DeploymentError.tsx index 517f91789b6..11ee62dfa3a 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentError.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentError.tsx @@ -9,7 +9,7 @@ type DeploymentErrorProps = { export function DeploymentError({ errorData }: DeploymentErrorProps) { return ( -
+
{errorData.message && {errorData.message}} {errorData.stack && ( @@ -18,6 +18,7 @@ export function DeploymentError({ errorData }: DeploymentErrorProps) { showLineNumbers={false} code={errorData.stack} maxLines={20} + showTextWrapping /> )} {errorData.stderr && ( @@ -28,6 +29,7 @@ export function DeploymentError({ errorData }: DeploymentErrorProps) { showLineNumbers={false} code={errorData.stderr} maxLines={20} + showTextWrapping /> )} diff --git a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx index 8e30b2df757..24a9b66b679 100644 --- a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx +++ b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx @@ -50,6 +50,7 @@ export function PacketDisplay({ code={data} maxLines={20} showLineNumbers={false} + showTextWrapping /> ); } 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 846f2567b44..1559b2c29fc 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 @@ -758,12 +758,12 @@ function RunBody({
) : tab === "context" ? (
- +
) : tab === "metadata" ? (
{run.metadata ? ( - + ) : ( No metadata set for this run. View our metadata documentation to learn more. From be70f42bec8d1f8a26aea47d4ed3b5daa0827724 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 4 Aug 2025 13:22:18 +0100 Subject: [PATCH 046/641] Adds a link to the reduce spend docs page from the billing alerts page (#2345) * Adds a link to the reduce spend docs page from the billing alerts page * Copy tweak --- .../route.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx index c4d3327be83..4dd0352f89d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx @@ -2,6 +2,7 @@ import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to import { parse } from "@conform-to/zod"; import { Form, useActionData, type MetaFunction } from "@remix-run/react"; import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; import { Fragment, useEffect, useRef, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -22,6 +23,7 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { TextLink } from "~/components/primitives/TextLink"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -29,12 +31,12 @@ import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server import { requireUserId } from "~/services/session.server"; import { formatCurrency } from "~/utils/numberFormatter"; import { + docsPath, OrganizationParamsSchema, organizationPath, v3BillingAlertsPath, } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { tryCatch } from "@trigger.dev/core"; export const meta: MetaFunction = () => { return [ @@ -199,10 +201,17 @@ export default function Page() {
- Billing alerts - - Receive an email when your compute spend crosses different thresholds. - +
+ Billing alerts + + Receive an email when your compute spend crosses different thresholds. You can also + learn how to{" "} + + reduce your compute spend + + . + +
From 7bb88451be74c04ea787e86f9a5f55cfb992f92b Mon Sep 17 00:00:00 2001 From: Dan <8297864+D-K-P@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:08:44 +0100 Subject: [PATCH 047/641] Sidebar fix (#2349) * Commented out tooltip style * Deleted stylesheet --- docs/style.css | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 docs/style.css diff --git a/docs/style.css b/docs/style.css deleted file mode 100644 index f41ac48218a..00000000000 --- a/docs/style.css +++ /dev/null @@ -1,17 +0,0 @@ -/* Tooltip style on the code block */ -div[class="absolute top-11 left-1/2 transform -translate-x-1/2 -translate-y-1/2 hidden group-hover:block text-white rounded-lg px-1.5 py-0.5 text-xs bg-primary-dark"] { - color:#1A1B1F; - border-radius: 0.25rem; -} - -/* Link anchor active state - icon container */ -div[class="mr-4 rounded-md p-1"] { - border-radius: 0.25rem; - border: 1px solid #464748; -} - -/* Link anchor default state - icon container */ -div[class="mr-4 rounded-md p-1 zinc-box group-hover:brightness-100 group-hover:ring-0 ring-1 ring-gray-950/5 dark:ring-gray-700/40"] { - border-radius: 0.25rem; - border: 1px solid transparent; -} \ No newline at end of file From 1f4434035e11d336e8cb793d4cc7bff3097bdd01 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 6 Aug 2025 08:10:46 +0100 Subject: [PATCH 048/641] latest @opentelemetry packages and correlate external traces (#2334) --- .changeset/five-nails-whisper.md | 92 +++ .../app/presenters/v3/SpanPresenter.server.ts | 29 +- .../routes/api.v1.tasks.$taskId.trigger.ts | 15 +- apps/webapp/app/routes/api.v2.tasks.batch.ts | 7 +- .../route.tsx | 6 + .../runEngine/services/batchTrigger.server.ts | 2 +- .../runEngine/services/triggerTask.server.ts | 59 +- apps/webapp/app/runEngine/types.ts | 4 +- .../app/v3/environmentVariableRules.server.ts | 2 - .../environmentVariablesRepository.server.ts | 102 ++- apps/webapp/app/v3/eventRepository.server.ts | 60 +- .../app/v3/services/triggerTask.server.ts | 2 +- .../test/environmentVariableRules.test.ts | 44 +- .../run-engine/src/engine/types.ts | 3 +- packages/cli-v3/package.json | 18 +- packages/cli-v3/src/cli/common.ts | 83 +-- .../cli-v3/src/entryPoints/dev-run-worker.ts | 22 +- .../src/entryPoints/managed-run-worker.ts | 23 +- packages/cli-v3/src/telemetry/tracing.ts | 75 --- packages/cli-v3/src/utilities/session.ts | 82 +-- packages/core/package.json | 21 +- packages/core/src/v3/apiClient/core.ts | 6 +- packages/core/src/v3/index.ts | 1 + packages/core/src/v3/isomorphic/index.ts | 1 + .../core/src/v3/isomorphic/traceContext.ts | 29 + packages/core/src/v3/otel/tracingSDK.ts | 261 ++++---- packages/core/src/v3/schemas/schemas.ts | 13 + .../core/src/v3/taskContext/otelProcessors.ts | 13 +- packages/core/src/v3/trace-context-api.ts | 5 + packages/core/src/v3/traceContext/api.ts | 74 +++ packages/core/src/v3/traceContext/manager.ts | 77 +++ packages/core/src/v3/traceContext/types.ts | 15 + packages/core/src/v3/tracer.ts | 4 - packages/core/src/v3/utils/globals.ts | 2 + packages/core/src/v3/workers/index.ts | 1 + packages/core/src/v3/workers/taskExecutor.ts | 29 +- packages/core/test/taskExecutor.test.ts | 2 +- packages/trigger-sdk/package.json | 3 +- packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/otel.ts | 7 + pnpm-lock.yaml | 582 +++++++++++------- references/d3-chat/package.json | 9 +- .../src/app/api/demo-batch-trigger/route.ts | 18 + .../app/api/demo-call-from-trigger/route.ts | 5 + .../d3-chat/src/app/api/demo-trigger/route.ts | 14 + references/d3-chat/src/instrumentation.ts | 5 + references/d3-chat/src/trigger/chat.ts | 25 +- 47 files changed, 1262 insertions(+), 691 deletions(-) create mode 100644 .changeset/five-nails-whisper.md delete mode 100644 packages/cli-v3/src/telemetry/tracing.ts create mode 100644 packages/core/src/v3/isomorphic/traceContext.ts create mode 100644 packages/core/src/v3/trace-context-api.ts create mode 100644 packages/core/src/v3/traceContext/api.ts create mode 100644 packages/core/src/v3/traceContext/manager.ts create mode 100644 packages/core/src/v3/traceContext/types.ts create mode 100644 packages/trigger-sdk/src/v3/otel.ts create mode 100644 references/d3-chat/src/app/api/demo-batch-trigger/route.ts create mode 100644 references/d3-chat/src/app/api/demo-call-from-trigger/route.ts create mode 100644 references/d3-chat/src/app/api/demo-trigger/route.ts create mode 100644 references/d3-chat/src/instrumentation.ts diff --git a/.changeset/five-nails-whisper.md b/.changeset/five-nails-whisper.md new file mode 100644 index 00000000000..185f4a0cf6a --- /dev/null +++ b/.changeset/five-nails-whisper.md @@ -0,0 +1,92 @@ +--- +"@trigger.dev/sdk": patch +--- + +External Trace Correlation & OpenTelemetry Package Updates. + +| Package | Previous Version | New Version | Change Type | +|---------|------------------|-------------|-------------| +| `@opentelemetry/api` | 1.9.0 | 1.9.0 | No change (stable API) | +| `@opentelemetry/api-logs` | 0.52.1 | 0.203.0 | Major update | +| `@opentelemetry/core` | - | 2.0.1 | New dependency | +| `@opentelemetry/exporter-logs-otlp-http` | 0.52.1 | 0.203.0 | Major update | +| `@opentelemetry/exporter-trace-otlp-http` | 0.52.1 | 0.203.0 | Major update | +| `@opentelemetry/instrumentation` | 0.52.1 | 0.203.0 | Major update | +| `@opentelemetry/instrumentation-fetch` | 0.52.1 | 0.203.0 | Major update | +| `@opentelemetry/resources` | 1.25.1 | 2.0.1 | Major update | +| `@opentelemetry/sdk-logs` | 0.52.1 | 0.203.0 | Major update | +| `@opentelemetry/sdk-node` | 0.52.1 | - | Removed (functionality consolidated) | +| `@opentelemetry/sdk-trace-base` | 1.25.1 | 2.0.1 | Major update | +| `@opentelemetry/sdk-trace-node` | 1.25.1 | 2.0.1 | Major update | +| `@opentelemetry/semantic-conventions` | 1.25.1 | 1.36.0 | Minor update | + +### External trace correlation and propagation + +We will now correlate your external traces with trigger.dev traces and logs when using our external exporters: + +```ts +import { defineConfig } from "@trigger.dev/sdk"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; + +export default defineConfig({ + project: process.env.TRIGGER_PROJECT_REF, + dirs: ["./src/trigger"], + telemetry: { + logExporters: [ + new OTLPLogExporter({ + url: "https://api.axiom.co/v1/logs", + headers: { + Authorization: `Bearer ${process.env.AXIOM_TOKEN}`, + "X-Axiom-Dataset": "test", + }, + }), + ], + exporters: [ + new OTLPTraceExporter({ + url: "https://api.axiom.co/v1/traces", + headers: { + Authorization: `Bearer ${process.env.AXIOM_TOKEN}`, + "X-Axiom-Dataset": "test", + }, + }), + ], + }, + maxDuration: 3600, +}); +``` + +You can also now propagate your external trace context when calling back into your own backend infra from inside a trigger.dev task: + +```ts +import { otel, task } from "@trigger.dev/sdk"; +import { context, propagation } from "@opentelemetry/api"; + +async function callNextjsApp() { + return await otel.withExternalTrace(async () => { + const headersObject = {}; + + // Now context.active() refers to your external trace context + propagation.inject(context.active(), headersObject); + + const result = await fetch("http://localhost:3000/api/demo-call-from-trigger", { + headers: new Headers(headersObject), + method: "POST", + body: JSON.stringify({ + message: "Hello from Trigger.dev", + }), + }); + + return result.json(); + }); +} + +export const myTask = task({ + id: "my-task", + run: async (payload: any) => { + await callNextjsApp() + } +}) +``` + + diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 0d9065e28a5..a3a5747846c 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -4,9 +4,10 @@ import { SemanticInternalAttributes, TaskRunContext, TaskRunError, + TriggerTraceContext, V3TaskRunContext, } from "@trigger.dev/core/v3"; -import { AttemptId, getMaxDuration } from "@trigger.dev/core/v3/isomorphic"; +import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { logger } from "~/services/logger.server"; import { eventRepository, rehydrateAttribute } from "~/v3/eventRepository.server"; @@ -173,6 +174,8 @@ export class SpanPresenter extends BasePresenter { const context = await this.#getTaskRunContext({ run, machine: machine ?? undefined }); + const externalTraceId = this.#getExternalTraceId(run.traceContext); + return { id: run.id, friendlyId: run.friendlyId, @@ -234,6 +237,7 @@ export class SpanPresenter extends BasePresenter { spanId: run.spanId, isCached: !!span.originalRun, machinePreset: machine?.name, + externalTraceId, }; } @@ -272,6 +276,7 @@ export class SpanPresenter extends BasePresenter { id: true, spanId: true, traceId: true, + traceContext: true, //metadata number: true, taskIdentifier: true, @@ -574,4 +579,26 @@ export class SpanPresenter extends BasePresenter { async #getV4TaskRunContext({ run }: { run: FindRunResult }): Promise { return engine.resolveTaskRunContext(run.id); } + + #getExternalTraceId(traceContext: unknown) { + if (!traceContext) { + return; + } + + const parsedTraceContext = TriggerTraceContext.safeParse(traceContext); + + if (!parsedTraceContext.success) { + return; + } + + const externalTraceparent = parsedTraceContext.data.external?.traceparent; + + if (!externalTraceparent) { + return; + } + + const parsedTraceparent = parseTraceparent(externalTraceparent); + + return parsedTraceparent?.traceId; + } } diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index a5e4e16225e..11613427a90 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -93,10 +93,9 @@ const { action, loader } = createActionApiRoute( const service = new TriggerTaskService(); try { - const traceContext = - traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context - ? { traceparent, tracestate } - : undefined; + const traceContext = isFromWorker + ? { traceparent, tracestate } + : { external: { traceparent, tracestate } }; const oneTimeUseToken = await getOneTimeUseToken(authentication); @@ -111,6 +110,14 @@ const { action, loader } = createActionApiRoute( traceContext, }); + logger.debug("[otelContext]", { + taskId: params.taskId, + headers, + options: body.options, + isFromWorker, + traceContext, + }); + const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL); const result = await service.call( diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index ba03919d499..93715bbdd59 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -103,10 +103,9 @@ const { action, loader } = createActionApiRoute( return cachedResponse; } - const traceContext = - traceparent && isFromWorker // If the request is from a worker, we should pass the trace context - ? { traceparent, tracestate } - : undefined; + const traceContext = isFromWorker + ? { traceparent, tracestate } + : { external: { traceparent, tracestate } }; const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined); 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 1559b2c29fc..4e232b888f2 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 @@ -743,6 +743,12 @@ function RunBody({ Run Engine {run.engine} + {run.externalTraceId && ( + + External Trace ID + {run.externalTraceId} + + )} {isAdmin && (
diff --git a/apps/webapp/app/runEngine/services/batchTrigger.server.ts b/apps/webapp/app/runEngine/services/batchTrigger.server.ts index 2a476e5a1ec..beadcc9cf73 100644 --- a/apps/webapp/app/runEngine/services/batchTrigger.server.ts +++ b/apps/webapp/app/runEngine/services/batchTrigger.server.ts @@ -42,7 +42,7 @@ export type BatchProcessingOptions = z.infer; export type BatchTriggerTaskServiceOptions = { triggerVersion?: string; - traceContext?: Record; + traceContext?: Record>; spanParentAsLink?: boolean; oneTimeUseToken?: string; }; diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 3847a3036b2..ea41c12c793 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -10,8 +10,14 @@ import { taskRunErrorEnhancer, taskRunErrorToString, TriggerTaskRequestBody, + TriggerTraceContext, } from "@trigger.dev/core/v3"; -import { RunId, stringifyDuration } from "@trigger.dev/core/v3/isomorphic"; +import { + parseTraceparent, + RunId, + serializeTraceparent, + stringifyDuration, +} from "@trigger.dev/core/v3/isomorphic"; import type { PrismaClientOrTransaction } from "@trigger.dev/database"; import { createTags } from "~/models/taskRunTag.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; @@ -253,7 +259,11 @@ export class RunEngineTriggerTaskService { payload: payloadPacket.data ?? "", payloadType: payloadPacket.dataType, context: body.context, - traceContext: event.traceContext, + traceContext: this.#propagateExternalTraceContext( + event.traceContext, + parentRun?.traceContext, + event.traceparent?.spanId + ), traceId: event.traceId, spanId: event.spanId, parentSpanId: @@ -341,4 +351,49 @@ export class RunEngineTriggerTaskService { } }); } + + #propagateExternalTraceContext( + traceContext: Record, + parentRunTraceContext: unknown, + parentSpanId: string | undefined + ): TriggerTraceContext { + if (!parentRunTraceContext) { + return traceContext; + } + + const parsedParentRunTraceContext = TriggerTraceContext.safeParse(parentRunTraceContext); + + if (!parsedParentRunTraceContext.success) { + return traceContext; + } + + const { external } = parsedParentRunTraceContext.data; + + if (!external) { + return traceContext; + } + + if (!external.traceparent) { + return traceContext; + } + + const parsedTraceparent = parseTraceparent(external.traceparent); + + if (!parsedTraceparent) { + return traceContext; + } + + const newExternalTraceparent = serializeTraceparent( + parsedTraceparent.traceId, + parentSpanId ?? parsedTraceparent.spanId + ); + + return { + ...traceContext, + external: { + ...external, + traceparent: newExternalTraceparent, + }, + }; + } } diff --git a/apps/webapp/app/runEngine/types.ts b/apps/webapp/app/runEngine/types.ts index 9523999d542..3564a5d717c 100644 --- a/apps/webapp/app/runEngine/types.ts +++ b/apps/webapp/app/runEngine/types.ts @@ -12,7 +12,7 @@ export type TriggerTaskServiceOptions = { idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; triggerVersion?: string; - traceContext?: Record; + traceContext?: Record; spanParentAsLink?: boolean; parentAsLinkType?: "replay" | "trigger"; batchId?: string; @@ -119,7 +119,7 @@ export interface TriggerTaskValidator { export type TracedEventSpan = { traceId: string; spanId: string; - traceContext: Record; + traceContext: Record; traceparent?: { traceId: string; spanId: string; diff --git a/apps/webapp/app/v3/environmentVariableRules.server.ts b/apps/webapp/app/v3/environmentVariableRules.server.ts index ddaee2b2493..fce17d03644 100644 --- a/apps/webapp/app/v3/environmentVariableRules.server.ts +++ b/apps/webapp/app/v3/environmentVariableRules.server.ts @@ -8,8 +8,6 @@ type VariableRule = const blacklistedVariables: VariableRule[] = [ { type: "exact", key: "TRIGGER_SECRET_KEY" }, { type: "exact", key: "TRIGGER_API_URL" }, - { type: "prefix", prefix: "OTEL_" }, - { type: "whitelist", key: "OTEL_LOG_LEVEL" }, ]; export function removeBlacklistedVariables( diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index ea6243f4a53..12d0a123694 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -17,6 +17,7 @@ import { } from "./repository"; import { removeBlacklistedVariables } from "../environmentVariableRules.server"; import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; +import { logger } from "~/services/logger.server"; function secretKeyProjectPrefix(projectId: string) { return `environmentvariable:${projectId}:`; @@ -837,11 +838,23 @@ export async function resolveVariablesForEnvironment( ? await resolveBuiltInDevVariables(runtimeEnvironment) : await resolveBuiltInProdVariables(runtimeEnvironment, parentEnvironment); - return deduplicateVariableArray([ + const overridableOtelVariables = + runtimeEnvironment.type === "DEVELOPMENT" + ? await resolveOverridableOtelDevVariables(runtimeEnvironment) + : []; + + const result = deduplicateVariableArray([ ...overridableTriggerVariables, + ...overridableOtelVariables, ...projectSecrets, ...builtInVariables, ]); + + logger.debug("Resolved variables", { + result, + }); + + return result; } async function resolveOverridableTriggerVariables( @@ -860,7 +873,7 @@ async function resolveOverridableTriggerVariables( async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironmentForEnvRepo) { let result: Array = [ { - key: "OTEL_EXPORTER_OTLP_ENDPOINT", + key: "TRIGGER_OTEL_EXPORTER_OTLP_ENDPOINT", value: env.DEV_OTEL_EXPORTER_OTLP_ENDPOINT ?? `${env.APP_ORIGIN.replace(/\/$/, "")}/otel`, }, { @@ -875,6 +888,42 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment if (env.DEV_OTEL_BATCH_PROCESSING_ENABLED === "1") { result = result.concat([ + { + key: "TRIGGER_OTEL_BATCH_PROCESSING_ENABLED", + value: "1", + }, + { + key: "TRIGGER_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE", + value: env.DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE, + }, + { + key: "TRIGGER_OTEL_SPAN_SCHEDULED_DELAY_MILLIS", + value: env.DEV_OTEL_SPAN_SCHEDULED_DELAY_MILLIS, + }, + { + key: "TRIGGER_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS", + value: env.DEV_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS, + }, + { + key: "TRIGGER_OTEL_SPAN_MAX_QUEUE_SIZE", + value: env.DEV_OTEL_SPAN_MAX_QUEUE_SIZE, + }, + { + key: "TRIGGER_OTEL_LOG_MAX_EXPORT_BATCH_SIZE", + value: env.DEV_OTEL_LOG_MAX_EXPORT_BATCH_SIZE, + }, + { + key: "TRIGGER_OTEL_LOG_SCHEDULED_DELAY_MILLIS", + value: env.DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS, + }, + { + key: "TRIGGER_OTEL_LOG_EXPORT_TIMEOUT_MILLIS", + value: env.DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS, + }, + { + key: "TRIGGER_OTEL_LOG_MAX_QUEUE_SIZE", + value: env.DEV_OTEL_LOG_MAX_QUEUE_SIZE, + }, { key: "OTEL_BATCH_PROCESSING_ENABLED", value: "1", @@ -919,6 +968,19 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment return [...result, ...commonVariables]; } +async function resolveOverridableOtelDevVariables( + runtimeEnvironment: RuntimeEnvironmentForEnvRepo +) { + let result: Array = [ + { + key: "OTEL_EXPORTER_OTLP_ENDPOINT", + value: env.DEV_OTEL_EXPORTER_OTLP_ENDPOINT ?? `${env.APP_ORIGIN.replace(/\/$/, "")}/otel`, + }, + ]; + + return result; +} + async function resolveBuiltInProdVariables( runtimeEnvironment: RuntimeEnvironmentForEnvRepo, parentEnvironment?: RuntimeEnvironmentForEnvRepo @@ -957,6 +1019,42 @@ async function resolveBuiltInProdVariables( if (env.PROD_OTEL_BATCH_PROCESSING_ENABLED === "1") { result = result.concat([ + { + key: "TRIGGER_OTEL_BATCH_PROCESSING_ENABLED", + value: "1", + }, + { + key: "TRIGGER_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE", + value: env.PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE, + }, + { + key: "TRIGGER_OTEL_SPAN_SCHEDULED_DELAY_MILLIS", + value: env.PROD_OTEL_SPAN_SCHEDULED_DELAY_MILLIS, + }, + { + key: "TRIGGER_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS", + value: env.PROD_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS, + }, + { + key: "TRIGGER_OTEL_SPAN_MAX_QUEUE_SIZE", + value: env.PROD_OTEL_SPAN_MAX_QUEUE_SIZE, + }, + { + key: "TRIGGER_OTEL_LOG_MAX_EXPORT_BATCH_SIZE", + value: env.PROD_OTEL_LOG_MAX_EXPORT_BATCH_SIZE, + }, + { + key: "TRIGGER_OTEL_LOG_SCHEDULED_DELAY_MILLIS", + value: env.PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS, + }, + { + key: "TRIGGER_OTEL_LOG_EXPORT_TIMEOUT_MILLIS", + value: env.PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS, + }, + { + key: "TRIGGER_OTEL_LOG_MAX_QUEUE_SIZE", + value: env.PROD_OTEL_LOG_MAX_QUEUE_SIZE, + }, { key: "OTEL_BATCH_PROCESSING_ENABLED", value: "1", diff --git a/apps/webapp/app/v3/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository.server.ts index dc4fdd43e53..d70deb3ef47 100644 --- a/apps/webapp/app/v3/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository.server.ts @@ -2,9 +2,14 @@ import { Attributes, AttributeValue, Link, trace, TraceFlags, Tracer } from "@op import { RandomIdGenerator } from "@opentelemetry/sdk-trace-base"; import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; import { + correctErrorStackTrace, + createPacketAttributesAsJson, ExceptionEventProperties, ExceptionSpanEvent, + flattenAttributes, + isExceptionSpanEvent, NULL_SENTINEL, + omit, PRIMARY_VARIANT, SemanticInternalAttributes, SpanEvent, @@ -13,28 +18,24 @@ import { TaskEventEnvironment, TaskEventStyle, TaskRunError, - correctErrorStackTrace, - createPacketAttributesAsJson, - flattenAttributes, - isExceptionSpanEvent, - omit, unflattenAttributes, } from "@trigger.dev/core/v3"; +import { parseTraceparent, serializeTraceparent } from "@trigger.dev/core/v3/isomorphic"; import { Prisma, TaskEvent, TaskEventKind, TaskEventStatus } from "@trigger.dev/database"; +import { nanoid } from "nanoid"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:stream"; import { Gauge } from "prom-client"; -import { $replica, PrismaClient, PrismaReplicaClient, prisma } from "~/db.server"; +import { $replica, prisma, PrismaClient, PrismaReplicaClient } from "~/db.server"; import { env } from "~/env.server"; import { metricsRegister } from "~/metrics.server"; +import { createRedisClient, RedisClient, RedisWithClusterOptions } from "~/redis.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; import { DynamicFlushScheduler } from "./dynamicFlushScheduler.server"; +import { TaskEventStore, TaskEventStoreTable } from "./taskEventStore.server"; import { startActiveSpan } from "./tracer.server"; -import { createRedisClient, RedisClient, RedisWithClusterOptions } from "~/redis.server"; import { startSpan } from "./tracing.server"; -import { nanoid } from "nanoid"; -import { TaskEventStore, TaskEventStoreTable } from "./taskEventStore.server"; const MAX_FLUSH_DEPTH = 5; @@ -80,7 +81,7 @@ export type SetAttribute = (key: keyof T, value: T[ke export type TraceEventOptions = { kind?: CreatableEventKind; - context?: Record; + context?: Record; spanParentAsLink?: boolean; parentAsLinkType?: "trigger" | "replay"; spanIdSeed?: string; @@ -932,7 +933,7 @@ export class EventRepository { traceId, spanId, parentId, - tracestate, + tracestate: typeof tracestate === "string" ? tracestate : undefined, message: message, serviceName: "api server", serviceNamespace: "trigger.dev", @@ -989,6 +990,11 @@ export class EventRepository { ): Promise { const propagatedContext = extractContextFromCarrier(options.context ?? {}); + logger.debug("[otelContext]", { + propagatedContext, + options, + }); + const start = process.hrtime.bigint(); const startTime = options.startTime ?? getNowInNanoseconds(); @@ -1002,7 +1008,8 @@ export class EventRepository { : this.generateSpanId(); const traceContext = { - traceparent: `00-${traceId}-${spanId}-01`, + ...options.context, + traceparent: serializeTraceparent(traceId, spanId), }; const links: Link[] = @@ -1087,7 +1094,7 @@ export class EventRepository { traceId, spanId, parentId, - tracestate, + tracestate: typeof tracestate === "string" ? tracestate : undefined, duration: options.incomplete ? 0 : duration, isPartial: failedWithError ? false : options.incomplete, isError: !!failedWithError, @@ -1486,36 +1493,21 @@ function excludePartialEventsWithCorrespondingFullEvent(batch: CreatableEvent[]) ); } -export function extractContextFromCarrier(carrier: Record) { +export function extractContextFromCarrier(carrier: Record) { const traceparent = carrier["traceparent"]; const tracestate = carrier["tracestate"]; + if (typeof traceparent !== "string") { + return undefined; + } + return { + ...carrier, traceparent: parseTraceparent(traceparent), tracestate, }; } -function parseTraceparent(traceparent?: string): { traceId: string; spanId: string } | undefined { - if (!traceparent) { - return undefined; - } - - const parts = traceparent.split("-"); - - if (parts.length !== 4) { - return undefined; - } - - const [version, traceId, spanId, flags] = parts; - - if (version !== "00") { - return undefined; - } - - return { traceId, spanId }; -} - function prepareEvent(event: QueriedEvent): PreparedEvent { return { ...event, diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index c43485cbb5b..bfb31e2499b 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -19,7 +19,7 @@ export type TriggerTaskServiceOptions = { idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; triggerVersion?: string; - traceContext?: Record; + traceContext?: Record; spanParentAsLink?: boolean; parentAsLinkType?: "replay" | "trigger"; batchId?: string; diff --git a/apps/webapp/test/environmentVariableRules.test.ts b/apps/webapp/test/environmentVariableRules.test.ts index af27dd3c7cd..39e3d9892ea 100644 --- a/apps/webapp/test/environmentVariableRules.test.ts +++ b/apps/webapp/test/environmentVariableRules.test.ts @@ -15,33 +15,6 @@ describe("removeBlacklistedVariables", () => { expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]); }); - it("should remove variables with blacklisted prefixes", () => { - const variables: EnvironmentVariable[] = [ - { key: "OTEL_SERVICE_NAME", value: "my-service" }, - { key: "OTEL_TRACE_SAMPLER", value: "always_on" }, - { key: "NORMAL_VAR", value: "normal" }, - ]; - - const result = removeBlacklistedVariables(variables); - - expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]); - }); - - it("should keep whitelisted variables even if they match a blacklisted prefix", () => { - const variables: EnvironmentVariable[] = [ - { key: "OTEL_LOG_LEVEL", value: "debug" }, - { key: "OTEL_SERVICE_NAME", value: "my-service" }, - { key: "NORMAL_VAR", value: "normal" }, - ]; - - const result = removeBlacklistedVariables(variables); - - expect(result).toEqual([ - { key: "OTEL_LOG_LEVEL", value: "debug" }, - { key: "NORMAL_VAR", value: "normal" }, - ]); - }); - it("should handle empty input array", () => { const variables: EnvironmentVariable[] = []; @@ -53,8 +26,6 @@ describe("removeBlacklistedVariables", () => { it("should handle mixed case variables", () => { const variables: EnvironmentVariable[] = [ { key: "trigger_secret_key", value: "secret123" }, // Different case - { key: "OTEL_LOG_LEVEL", value: "debug" }, - { key: "otel_service_name", value: "my-service" }, // Different case { key: "NORMAL_VAR", value: "normal" }, ]; @@ -64,8 +35,6 @@ describe("removeBlacklistedVariables", () => { // Note: The function is case-sensitive, so different case variables should pass through expect(result).toEqual([ { key: "trigger_secret_key", value: "secret123" }, - { key: "OTEL_LOG_LEVEL", value: "debug" }, - { key: "otel_service_name", value: "my-service" }, { key: "NORMAL_VAR", value: "normal" }, ]); }); @@ -73,17 +42,12 @@ describe("removeBlacklistedVariables", () => { it("should handle variables with empty values", () => { const variables: EnvironmentVariable[] = [ { key: "TRIGGER_SECRET_KEY", value: "" }, - { key: "OTEL_SERVICE_NAME", value: "" }, - { key: "OTEL_LOG_LEVEL", value: "" }, { key: "NORMAL_VAR", value: "" }, ]; const result = removeBlacklistedVariables(variables); - expect(result).toEqual([ - { key: "OTEL_LOG_LEVEL", value: "" }, - { key: "NORMAL_VAR", value: "" }, - ]); + expect(result).toEqual([{ key: "NORMAL_VAR", value: "" }]); }); it("should handle all types of rules in a single array", () => { @@ -91,11 +55,6 @@ describe("removeBlacklistedVariables", () => { // Exact matches (should be removed) { key: "TRIGGER_SECRET_KEY", value: "secret123" }, { key: "TRIGGER_API_URL", value: "https://api.example.com" }, - // Prefix matches (should be removed) - { key: "OTEL_SERVICE_NAME", value: "my-service" }, - { key: "OTEL_TRACE_SAMPLER", value: "always_on" }, - // Whitelist exception (should be kept) - { key: "OTEL_LOG_LEVEL", value: "debug" }, // Normal variables (should be kept) { key: "NORMAL_VAR", value: "normal" }, { key: "DATABASE_URL", value: "postgres://..." }, @@ -104,7 +63,6 @@ describe("removeBlacklistedVariables", () => { const result = removeBlacklistedVariables(variables); expect(result).toEqual([ - { key: "OTEL_LOG_LEVEL", value: "debug" }, { key: "NORMAL_VAR", value: "normal" }, { key: "DATABASE_URL", value: "postgres://..." }, ]); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index e15f90b1c54..7f22b6770de 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -6,6 +6,7 @@ import { MachinePresetName, RetryOptions, RunChainState, + TriggerTraceContext, } from "@trigger.dev/core/v3"; import { PrismaClient, PrismaReplicaClient } from "@trigger.dev/database"; import { Worker, type WorkerConcurrencyOptions } from "@trigger.dev/redis-worker"; @@ -89,7 +90,7 @@ export type TriggerParams = { payload: string; payloadType: string; context: any; - traceContext: Record; + traceContext: TriggerTraceContext; traceId: string; spanId: string; parentSpanId?: string; diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 3d4e4c8a9f2..24c68670aa3 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -82,17 +82,13 @@ "@depot/cli": "0.0.1-cli.2.80.0", "@modelcontextprotocol/sdk": "^1.6.1", "@opentelemetry/api": "1.9.0", - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/exporter-logs-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/instrumentation": "0.52.1", - "@opentelemetry/instrumentation-fetch": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-node": "0.52.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1", + "@opentelemetry/api-logs": "0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "0.203.0", + "@opentelemetry/instrumentation": "0.203.0", + "@opentelemetry/instrumentation-fetch": "0.203.0", + "@opentelemetry/resources": "2.0.1", + "@opentelemetry/sdk-trace-node": "2.0.1", + "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/build": "workspace:4.0.0-v4-beta.26", "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "ansi-escapes": "^7.0.0", diff --git a/packages/cli-v3/src/cli/common.ts b/packages/cli-v3/src/cli/common.ts index b607ca07ff5..3cf9f2aba1e 100644 --- a/packages/cli-v3/src/cli/common.ts +++ b/packages/cli-v3/src/cli/common.ts @@ -1,15 +1,13 @@ -import { flattenAttributes } from "@trigger.dev/core/v3"; -import { recordSpanException } from "@trigger.dev/core/v3/workers"; +import { outro } from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod"; -import { getTracer, provider } from "../telemetry/tracing.js"; import { fromZodError } from "zod-validation-error"; -import { logger } from "../utilities/logger.js"; -import { outro } from "@clack/prompts"; -import { chalkError } from "../utilities/cliOutput.js"; +import { BundleError } from "../build/bundle.js"; import { CLOUD_API_URL } from "../consts.js"; +import { chalkError } from "../utilities/cliOutput.js"; import { readAuthConfigCurrentProfileName } from "../utilities/configFiles.js"; -import { BundleError } from "../build/bundle.js"; +import { logger } from "../utilities/logger.js"; +import { trace } from "@opentelemetry/api"; export const CommonCommandOptions = z.object({ apiUrl: z.string().optional(), @@ -39,69 +37,52 @@ export class OutroCommandError extends SkipCommandError {} export async function handleTelemetry(action: () => Promise) { try { await action(); - - await provider?.forceFlush(); } catch (e) { - await provider?.forceFlush(); - process.exitCode = 1; } } -export const tracer = getTracer(); - export async function wrapCommandAction( name: string, schema: T, options: unknown, action: (opts: z.output) => Promise ): Promise { - return await tracer.startActiveSpan(name, async (span) => { - try { - const parsedOptions = schema.safeParse(options); - - if (!parsedOptions.success) { - throw new Error(fromZodError(parsedOptions.error).toString()); - } - - span.setAttributes({ - ...flattenAttributes(parsedOptions.data, "cli.options"), - }); - - logger.loggerLevel = parsedOptions.data.logLevel; - - logger.debug(`Running "${name}" with the following options`, { - options: options, - spanContext: span?.spanContext(), - }); - - const result = await action(parsedOptions.data); + try { + const parsedOptions = schema.safeParse(options); - span.end(); + if (!parsedOptions.success) { + throw new Error(fromZodError(parsedOptions.error).toString()); + } - return result; - } catch (e) { - if (e instanceof SkipLoggingError) { - recordSpanException(span, e); - } else if (e instanceof OutroCommandError) { - outro("Operation cancelled"); - } else if (e instanceof SkipCommandError) { - // do nothing - } else if (e instanceof BundleError) { - process.exit(1); - } else { - recordSpanException(span, e); + logger.loggerLevel = parsedOptions.data.logLevel; - logger.log(`${chalkError("X Error:")} ${e instanceof Error ? e.message : String(e)}`); - } + logger.debug(`Running "${name}" with the following options`, { + options: options, + }); - span.end(); + const result = await action(parsedOptions.data); - throw e; + return result; + } catch (e) { + if (e instanceof SkipLoggingError) { + // do nothing + } else if (e instanceof OutroCommandError) { + outro("Operation cancelled"); + } else if (e instanceof SkipCommandError) { + // do nothing + } else if (e instanceof BundleError) { + process.exit(1); + } else { + logger.log(`${chalkError("X Error:")} ${e instanceof Error ? e.message : String(e)}`); } - }); + + throw e; + } } +export const tracer = trace.getTracer("trigger.dev/cli"); + export function installExitHandler() { process.on("SIGINT", () => { process.exit(0); diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index 8a148eaa718..6a526a57c36 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -21,6 +21,7 @@ import { runtime, runTimelineMetrics, taskContext, + TaskRunContext, TaskRunErrorCodes, TaskRunExecution, timeout, @@ -29,6 +30,7 @@ import { waitUntil, WorkerManifest, WorkerToExecutorMessageCatalog, + traceContext, } from "@trigger.dev/core/v3"; import { TriggerTracer } from "@trigger.dev/core/v3/tracer"; import { @@ -52,6 +54,7 @@ import { TracingSDK, usage, UsageTimeoutManager, + StandardTraceContextManager, } from "@trigger.dev/core/v3/workers"; import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc"; import { readFile } from "node:fs/promises"; @@ -126,6 +129,9 @@ timeout.setGlobalManager(usageTimeoutManager); const standardResourceCatalog = new StandardResourceCatalog(); resourceCatalog.setGlobalResourceCatalog(standardResourceCatalog); +const standardTraceContextManager = new StandardTraceContextManager(); +traceContext.setGlobalManager(standardTraceContextManager); + const durableClock = new DurableClock(); clock.setGlobalClock(durableClock); const runMetadataManager = new StandardMetadataManager( @@ -175,11 +181,11 @@ async function doBootstrap() { const { config, handleError } = await importConfig(workerManifest.configPath); const tracingSDK = new TracingSDK({ - url: env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318", + url: env.TRIGGER_OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318", instrumentations: config.telemetry?.instrumentations ?? config.instrumentations ?? [], exporters: config.telemetry?.exporters ?? [], logExporters: config.telemetry?.logExporters ?? [], - diagLogLevel: (env.OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none", + diagLogLevel: (env.TRIGGER_OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none", forceFlushTimeoutMillis: 30_000, }); @@ -301,6 +307,7 @@ function resetExecutionEnvironment() { _sharedWorkerRuntime?.reset(); durableClock.reset(); taskContext.disable(); + standardTraceContextManager.reset(); log(`[${new Date().toISOString()}] Reset execution environment`); } @@ -337,6 +344,7 @@ const zodIpc = new ZodIpcConnection({ resetExecutionEnvironment(); + standardTraceContextManager.traceContext = traceContext; standardRunTimelineMetricsManager.registerMetricsFromExecution(metrics, isWarmStart); if (_isRunning) { @@ -361,6 +369,14 @@ const zodIpc = new ZodIpcConnection({ return; } + const ctx = TaskRunContext.parse(execution); + + taskContext.setGlobalTaskContext({ + ctx, + worker: metadata, + isWarmStart: isWarmStart ?? false, + }); + try { const { tracer, tracingSDK, consoleInterceptor, config, workerManifest } = await bootstrap(); @@ -516,7 +532,7 @@ const zodIpc = new ZodIpcConnection({ const signal = AbortSignal.any([_cancelController.signal, timeoutController.signal]); - const { result } = await executor.execute(execution, metadata, traceContext, signal); + const { result } = await executor.execute(execution, ctx, signal); if (_isRunning && !_isCancelled) { const usageSample = usage.stop(_executionMeasurement); diff --git a/packages/cli-v3/src/entryPoints/managed-run-worker.ts b/packages/cli-v3/src/entryPoints/managed-run-worker.ts index 21ed6a265f4..c659af93de8 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-worker.ts @@ -20,6 +20,7 @@ import { runtime, runTimelineMetrics, taskContext, + TaskRunContext, TaskRunErrorCodes, TaskRunExecution, timeout, @@ -28,6 +29,7 @@ import { waitUntil, WorkerManifest, WorkerToExecutorMessageCatalog, + traceContext, } from "@trigger.dev/core/v3"; import { TriggerTracer } from "@trigger.dev/core/v3/tracer"; import { @@ -52,6 +54,7 @@ import { TracingSDK, usage, UsageTimeoutManager, + StandardTraceContextManager, } from "@trigger.dev/core/v3/workers"; import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc"; import { readFile } from "node:fs/promises"; @@ -119,6 +122,9 @@ resourceCatalog.setGlobalResourceCatalog(standardResourceCatalog); const durableClock = new DurableClock(); clock.setGlobalClock(durableClock); +const standardTraceContextManager = new StandardTraceContextManager(); +traceContext.setGlobalManager(standardTraceContextManager); + const runMetadataManager = new StandardMetadataManager( apiClientManager.clientOrThrow(), getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev" @@ -166,9 +172,9 @@ async function doBootstrap() { ); const tracingSDK = new TracingSDK({ - url: env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318", + url: env.TRIGGER_OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318", instrumentations: config.instrumentations ?? [], - diagLogLevel: (env.OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none", + diagLogLevel: (env.TRIGGER_OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none", forceFlushTimeoutMillis: 30_000, exporters: config.telemetry?.exporters ?? [], logExporters: config.telemetry?.logExporters ?? [], @@ -287,6 +293,7 @@ function resetExecutionEnvironment() { _sharedWorkerRuntime?.reset(); durableClock.reset(); taskContext.disable(); + standardTraceContextManager.reset(); console.log(`[${new Date().toISOString()}] Reset execution environment`); } @@ -328,6 +335,8 @@ const zodIpc = new ZodIpcConnection({ resetExecutionEnvironment(); + standardTraceContextManager.traceContext = traceContext; + const prodManager = initializeUsageManager({ usageIntervalMs: getEnvVar("USAGE_HEARTBEAT_INTERVAL_MS"), usageEventUrl: getEnvVar("USAGE_EVENT_URL"), @@ -365,6 +374,14 @@ const zodIpc = new ZodIpcConnection({ return; } + const ctx = TaskRunContext.parse(execution); + + taskContext.setGlobalTaskContext({ + ctx, + worker: metadata, + isWarmStart: isWarmStart ?? false, + }); + try { const { tracer, tracingSDK, consoleInterceptor, config, workerManifest } = await bootstrap(); @@ -514,7 +531,7 @@ const zodIpc = new ZodIpcConnection({ const signal = AbortSignal.any([_cancelController.signal, timeoutController.signal]); - const { result } = await executor.execute(execution, metadata, traceContext, signal); + const { result } = await executor.execute(execution, ctx, signal); if (_isRunning && !_isCancelled) { const usageSample = usage.stop(_executionMeasurement); diff --git a/packages/cli-v3/src/telemetry/tracing.ts b/packages/cli-v3/src/telemetry/tracing.ts deleted file mode 100644 index e00e0668b59..00000000000 --- a/packages/cli-v3/src/telemetry/tracing.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; -import { registerInstrumentations } from "@opentelemetry/instrumentation"; -import { Resource, detectResourcesSync, processDetectorSync } from "@opentelemetry/resources"; -import { NodeTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node"; -import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch"; -import { DiagConsoleLogger, DiagLogLevel, diag, trace } from "@opentelemetry/api"; -import { - SEMRESATTRS_SERVICE_NAME, - SEMRESATTRS_SERVICE_VERSION, -} from "@opentelemetry/semantic-conventions"; -import { logger } from "../utilities/logger.js"; -import { VERSION } from "../version.js"; -import { env } from "std-env"; - -function initializeTracing(): NodeTracerProvider | undefined { - if ( - process.argv.includes("--skip-telemetry") || - env.TRIGGER_DEV_SKIP_TELEMETRY || // only for backwards compat - env.TRIGGER_TELEMETRY_DISABLED - ) { - logger.debug("📉 Telemetry disabled"); - return; - } - - if (env.OTEL_INTERNAL_DIAG_DEBUG) { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); - } - - const resource = detectResourcesSync({ - detectors: [processDetectorSync], - }).merge( - new Resource({ - [SEMRESATTRS_SERVICE_NAME]: "trigger.dev cli v3", - [SEMRESATTRS_SERVICE_VERSION]: VERSION, - }) - ); - - const traceProvider = new NodeTracerProvider({ - forceFlushTimeoutMillis: 30_000, - resource, - spanLimits: { - attributeCountLimit: 1000, - attributeValueLengthLimit: 2048, - eventCountLimit: 100, - attributePerEventCountLimit: 100, - linkCountLimit: 10, - attributePerLinkCountLimit: 100, - }, - }); - - const spanExporter = new OTLPTraceExporter({ - url: "https://otel.baselime.io/v1", - timeoutMillis: 5000, - headers: { - "x-api-key": "b6e0fbbaf8dc2524773d2152ae2e9eb5c7fbaa52", - }, - }); - - const spanProcessor = new SimpleSpanProcessor(spanExporter); - - traceProvider.addSpanProcessor(spanProcessor); - traceProvider.register(); - - registerInstrumentations({ - instrumentations: [new FetchInstrumentation()], - }); - - return traceProvider; -} - -export const provider = initializeTracing(); - -export function getTracer() { - return trace.getTracer("trigger.dev cli v3", VERSION); -} diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts index be9d9ebfd45..341947ff741 100644 --- a/packages/cli-v3/src/utilities/session.ts +++ b/packages/cli-v3/src/utilities/session.ts @@ -1,12 +1,9 @@ import { recordSpanException } from "@trigger.dev/core/v3/workers"; import { CliApiClient } from "../apiClient.js"; import { readAuthConfigProfile } from "./configFiles.js"; -import { getTracer } from "../telemetry/tracing.js"; import { logger } from "./logger.js"; import { GitMeta } from "@trigger.dev/core/v3"; -const tracer = getTracer(); - export type LoginResultOk = { ok: true; profile: string; @@ -31,63 +28,44 @@ export type LoginResult = }; export async function isLoggedIn(profile: string = "default"): Promise { - return await tracer.startActiveSpan("isLoggedIn", async (span) => { - try { - const config = readAuthConfigProfile(profile); - - if (!config?.accessToken || !config?.apiUrl) { - span.recordException(new Error("You must login first")); - span.end(); - return { ok: false as const, error: "You must login first" }; - } - - const apiClient = new CliApiClient(config.apiUrl, config.accessToken); - const userData = await apiClient.whoAmI(); - - if (!userData.success) { - recordSpanException(span, userData.error); - span.end(); - - return { - ok: false as const, - error: userData.error, - auth: { - apiUrl: config.apiUrl, - accessToken: config.accessToken, - }, - }; - } - - span.setAttributes({ - "login.userId": userData.data.userId, - "login.email": userData.data.email, - "login.dashboardUrl": userData.data.dashboardUrl, - "login.profile": profile, - }); - - span.end(); + try { + const config = readAuthConfigProfile(profile); + + if (!config?.accessToken || !config?.apiUrl) { + return { ok: false as const, error: "You must login first" }; + } + + const apiClient = new CliApiClient(config.apiUrl, config.accessToken); + const userData = await apiClient.whoAmI(); + if (!userData.success) { return { - ok: true as const, - profile, - userId: userData.data.userId, - email: userData.data.email, - dashboardUrl: userData.data.dashboardUrl, + ok: false as const, + error: userData.error, auth: { apiUrl: config.apiUrl, accessToken: config.accessToken, }, }; - } catch (e) { - recordSpanException(span, e); - span.end(); - - return { - ok: false as const, - error: e instanceof Error ? e.message : "Unknown error", - }; } - }); + + return { + ok: true as const, + profile, + userId: userData.data.userId, + email: userData.data.email, + dashboardUrl: userData.data.dashboardUrl, + auth: { + apiUrl: config.apiUrl, + accessToken: config.accessToken, + }, + }; + } catch (e) { + return { + ok: false as const, + error: e instanceof Error ? e.message : "Unknown error", + }; + } } export type GetEnvOptions = { diff --git a/packages/core/package.json b/packages/core/package.json index 05e781fda91..bf14dfdbd40 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -171,17 +171,16 @@ "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.0", - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/exporter-logs-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/instrumentation": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-node": "0.52.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1", + "@opentelemetry/api-logs": "0.203.0", + "@opentelemetry/core": "2.0.1", + "@opentelemetry/exporter-logs-otlp-http": "0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "0.203.0", + "@opentelemetry/instrumentation": "0.203.0", + "@opentelemetry/resources": "2.0.1", + "@opentelemetry/sdk-logs": "0.203.0", + "@opentelemetry/sdk-trace-base": "2.0.1", + "@opentelemetry/sdk-trace-node": "2.0.1", + "@opentelemetry/semantic-conventions": "1.36.0", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", diff --git a/packages/core/src/v3/apiClient/core.ts b/packages/core/src/v3/apiClient/core.ts index ec1fe8421e0..1f3dca03feb 100644 --- a/packages/core/src/v3/apiClient/core.ts +++ b/packages/core/src/v3/apiClient/core.ts @@ -4,7 +4,7 @@ import { RetryOptions } from "../schemas/index.js"; import { calculateNextRetryDelay } from "../utils/retries.js"; import { ApiConnectionError, ApiError, ApiSchemaValidationError } from "./errors.js"; -import { Attributes, context, propagation, Span } from "@opentelemetry/api"; +import { Attributes, context, propagation, Span, trace } from "@opentelemetry/api"; import { suppressTracing } from "@opentelemetry/core"; import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; import type { TriggerTracer } from "../tracer.js"; @@ -617,10 +617,6 @@ export function hasOwn(obj: Object, key: string): boolean { function injectPropagationHeadersIfInWorker(requestInit?: RequestInit): RequestInit | undefined { const headers = new Headers(requestInit?.headers); - if (headers.get("x-trigger-worker") !== "true") { - return requestInit; - } - const headersObject = Object.fromEntries(headers.entries()); propagation.inject(context.active(), headersObject); diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 3bd1fc45473..e1785e50493 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -9,6 +9,7 @@ export * from "./limits.js"; export * from "./logger-api.js"; export * from "./runtime-api.js"; export * from "./task-context-api.js"; +export * from "./trace-context-api.js"; export * from "./apiClientManager-api.js"; export * from "./usage-api.js"; export * from "./run-metadata-api.js"; diff --git a/packages/core/src/v3/isomorphic/index.ts b/packages/core/src/v3/isomorphic/index.ts index 53836fb0965..8e15c36d2a2 100644 --- a/packages/core/src/v3/isomorphic/index.ts +++ b/packages/core/src/v3/isomorphic/index.ts @@ -3,3 +3,4 @@ export * from "./duration.js"; export * from "./maxDuration.js"; export * from "./queueName.js"; export * from "./consts.js"; +export * from "./traceContext.js"; diff --git a/packages/core/src/v3/isomorphic/traceContext.ts b/packages/core/src/v3/isomorphic/traceContext.ts new file mode 100644 index 00000000000..b6754c4c6f6 --- /dev/null +++ b/packages/core/src/v3/isomorphic/traceContext.ts @@ -0,0 +1,29 @@ +export function parseTraceparent( + traceparent?: string +): { traceId: string; spanId: string } | undefined { + if (!traceparent) { + return undefined; + } + + const parts = traceparent.split("-"); + + if (parts.length !== 4) { + return undefined; + } + + const [version, traceId, spanId] = parts; + + if (version !== "00") { + return undefined; + } + + if (!traceId || !spanId) { + return undefined; + } + + return { traceId, spanId }; +} + +export function serializeTraceparent(traceId: string, spanId: string) { + return `00-${traceId}-${spanId}-01`; +} diff --git a/packages/core/src/v3/otel/tracingSDK.ts b/packages/core/src/v3/otel/tracingSDK.ts index c2d7b3d3420..7fded903cdd 100644 --- a/packages/core/src/v3/otel/tracingSDK.ts +++ b/packages/core/src/v3/otel/tracingSDK.ts @@ -1,25 +1,19 @@ import { DiagConsoleLogger, DiagLogLevel, TracerProvider, diag } from "@opentelemetry/api"; -import { RandomIdGenerator } from "@opentelemetry/sdk-trace-base"; import { logs } from "@opentelemetry/api-logs"; +import { TraceState } from "@opentelemetry/core"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { registerInstrumentations, type Instrumentation } from "@opentelemetry/instrumentation"; -import { - DetectorSync, - IResource, - Resource, - ResourceAttributes, - ResourceDetectionConfig, - detectResourcesSync, - processDetectorSync, -} from "@opentelemetry/resources"; +import { detectResources, processDetector, resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, - LoggerProvider, LogRecordExporter, + LogRecordProcessor, + LoggerProvider, ReadableLogRecord, SimpleLogRecordProcessor, } from "@opentelemetry/sdk-logs"; +import { RandomIdGenerator, SpanProcessor } from "@opentelemetry/sdk-trace-base"; import { BatchSpanProcessor, NodeTracerProvider, @@ -40,45 +34,14 @@ import { OTEL_SPAN_EVENT_COUNT_LIMIT, } from "../limits.js"; import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; +import { taskContext } from "../task-context-api.js"; import { TaskContextLogProcessor, TaskContextSpanProcessor, } from "../taskContext/otelProcessors.js"; +import { traceContext } from "../trace-context-api.js"; import { getEnvVar } from "../utils/getEnv.js"; -class AsyncResourceDetector implements DetectorSync { - private _promise: Promise; - private _resolver?: (value: ResourceAttributes) => void; - private _resolved: boolean = false; - - constructor() { - this._promise = new Promise((resolver) => { - this._resolver = resolver; - }); - } - - get isResolved() { - return this._resolved; - } - - detect(_config?: ResourceDetectionConfig): Resource { - return new Resource({}, this._promise); - } - - resolveWithAttributes(attributes: ResourceAttributes) { - if (!this._resolver) { - throw new Error("Resolver not available"); - } - - if (this._resolved) { - return; - } - - this._resolved = true; - this._resolver(attributes); - } -} - export type TracingDiagnosticLogLevel = | "none" | "error" @@ -91,7 +54,6 @@ export type TracingDiagnosticLogLevel = export type TracingSDKConfig = { url: string; forceFlushTimeoutMillis?: number; - resource?: IResource; instrumentations?: Instrumentation[]; exporters?: SpanExporter[]; logExporters?: LogRecordExporter[]; @@ -101,7 +63,6 @@ export type TracingSDKConfig = { const idGenerator = new RandomIdGenerator(); export class TracingSDK { - public readonly asyncResourceDetector = new AsyncResourceDetector(); private readonly _logProvider: LoggerProvider; private readonly _spanExporter: SpanExporter; private readonly _traceProvider: NodeTracerProvider; @@ -112,83 +73,96 @@ export class TracingSDK { constructor(private readonly config: TracingSDKConfig) { setLogLevel(config.diagLogLevel ?? "none"); - const envResourceAttributesSerialized = getEnvVar("OTEL_RESOURCE_ATTRIBUTES"); + const envResourceAttributesSerialized = getEnvVar("TRIGGER_OTEL_RESOURCE_ATTRIBUTES"); const envResourceAttributes = envResourceAttributesSerialized ? JSON.parse(envResourceAttributesSerialized) : {}; - const commonResources = detectResourcesSync({ - detectors: [this.asyncResourceDetector, processDetectorSync], + const commonResources = detectResources({ + detectors: [processDetector], }) .merge( - new Resource({ + resourceFromAttributes({ [SemanticResourceAttributes.CLOUD_PROVIDER]: "trigger.dev", [SemanticResourceAttributes.SERVICE_NAME]: - getEnvVar("OTEL_SERVICE_NAME") ?? "trigger.dev", + getEnvVar("TRIGGER_OTEL_SERVICE_NAME") ?? "trigger.dev", [SemanticInternalAttributes.TRIGGER]: true, [SemanticInternalAttributes.CLI_VERSION]: VERSION, [SemanticInternalAttributes.SDK_VERSION]: VERSION, [SemanticInternalAttributes.SDK_LANGUAGE]: "typescript", }) ) - .merge(config.resource ?? new Resource({})) - .merge(new Resource(envResourceAttributes)); - - const traceProvider = new NodeTracerProvider({ - forceFlushTimeoutMillis: config.forceFlushTimeoutMillis, - resource: commonResources, - spanLimits: { - attributeCountLimit: OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, - attributeValueLengthLimit: OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, - eventCountLimit: OTEL_SPAN_EVENT_COUNT_LIMIT, - attributePerEventCountLimit: OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT, - linkCountLimit: OTEL_LINK_COUNT_LIMIT, - attributePerLinkCountLimit: OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT, - }, - }); + .merge(resourceFromAttributes(envResourceAttributes)) + .merge(resourceFromAttributes(taskContext.resourceAttributes)); const spanExporter = new OTLPTraceExporter({ url: `${config.url}/v1/traces`, timeoutMillis: config.forceFlushTimeoutMillis, }); - traceProvider.addSpanProcessor( + const spanProcessors: Array = []; + + spanProcessors.push( new TaskContextSpanProcessor( - traceProvider.getTracer("trigger-dev-worker", VERSION), - getEnvVar("OTEL_BATCH_PROCESSING_ENABLED") === "1" + VERSION, + getEnvVar("TRIGGER_OTEL_BATCH_PROCESSING_ENABLED") === "1" ? new BatchSpanProcessor(spanExporter, { - maxExportBatchSize: parseInt(getEnvVar("OTEL_SPAN_MAX_EXPORT_BATCH_SIZE") ?? "64"), + maxExportBatchSize: parseInt( + getEnvVar("TRIGGER_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE") ?? "64" + ), scheduledDelayMillis: parseInt( - getEnvVar("OTEL_SPAN_SCHEDULED_DELAY_MILLIS") ?? "200" + getEnvVar("TRIGGER_OTEL_SPAN_SCHEDULED_DELAY_MILLIS") ?? "200" ), exportTimeoutMillis: parseInt( - getEnvVar("OTEL_SPAN_EXPORT_TIMEOUT_MILLIS") ?? "30000" + getEnvVar("TRIGGER_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS") ?? "30000" ), - maxQueueSize: parseInt(getEnvVar("OTEL_SPAN_MAX_QUEUE_SIZE") ?? "512"), + maxQueueSize: parseInt(getEnvVar("TRIGGER_OTEL_SPAN_MAX_QUEUE_SIZE") ?? "512"), }) : new SimpleSpanProcessor(spanExporter) ) ); const externalTraceId = idGenerator.generateTraceId(); + const externalTraceContext = traceContext.getExternalTraceContext(); for (const exporter of config.exporters ?? []) { - traceProvider.addSpanProcessor( - getEnvVar("OTEL_BATCH_PROCESSING_ENABLED") === "1" - ? new BatchSpanProcessor(new ExternalSpanExporterWrapper(exporter, externalTraceId), { - maxExportBatchSize: parseInt(getEnvVar("OTEL_SPAN_MAX_EXPORT_BATCH_SIZE") ?? "64"), - scheduledDelayMillis: parseInt( - getEnvVar("OTEL_SPAN_SCHEDULED_DELAY_MILLIS") ?? "200" - ), - exportTimeoutMillis: parseInt( - getEnvVar("OTEL_SPAN_EXPORT_TIMEOUT_MILLIS") ?? "30000" - ), - maxQueueSize: parseInt(getEnvVar("OTEL_SPAN_MAX_QUEUE_SIZE") ?? "512"), - }) - : new SimpleSpanProcessor(new ExternalSpanExporterWrapper(exporter, externalTraceId)) + spanProcessors.push( + getEnvVar("TRIGGER_OTEL_BATCH_PROCESSING_ENABLED") === "1" + ? new BatchSpanProcessor( + new ExternalSpanExporterWrapper(exporter, externalTraceId, externalTraceContext), + { + maxExportBatchSize: parseInt( + getEnvVar("TRIGGER_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE") ?? "64" + ), + scheduledDelayMillis: parseInt( + getEnvVar("TRIGGER_OTEL_SPAN_SCHEDULED_DELAY_MILLIS") ?? "200" + ), + exportTimeoutMillis: parseInt( + getEnvVar("TRIGGER_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS") ?? "30000" + ), + maxQueueSize: parseInt(getEnvVar("TRIGGER_OTEL_SPAN_MAX_QUEUE_SIZE") ?? "512"), + } + ) + : new SimpleSpanProcessor( + new ExternalSpanExporterWrapper(exporter, externalTraceId, externalTraceContext) + ) ); } + const traceProvider = new NodeTracerProvider({ + forceFlushTimeoutMillis: config.forceFlushTimeoutMillis, + resource: commonResources, + spanLimits: { + attributeCountLimit: OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, + attributeValueLengthLimit: OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, + eventCountLimit: OTEL_SPAN_EVENT_COUNT_LIMIT, + attributePerEventCountLimit: OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT, + linkCountLimit: OTEL_LINK_COUNT_LIMIT, + attributePerLinkCountLimit: OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT, + }, + spanProcessors, + }); + traceProvider.register(); registerInstrumentations({ @@ -200,50 +174,67 @@ export class TracingSDK { url: `${config.url}/v1/logs`, }); - // To start a logger, you first need to initialize the Logger provider. - const loggerProvider = new LoggerProvider({ - resource: commonResources, - logRecordLimits: { - attributeCountLimit: OTEL_LOG_ATTRIBUTE_COUNT_LIMIT, - attributeValueLengthLimit: OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT, - }, - }); - - loggerProvider.addLogRecordProcessor( + const logProcessors: Array = [ new TaskContextLogProcessor( - getEnvVar("OTEL_BATCH_PROCESSING_ENABLED") === "1" + getEnvVar("TRIGGER_OTEL_BATCH_PROCESSING_ENABLED") === "1" ? new BatchLogRecordProcessor(logExporter, { - maxExportBatchSize: parseInt(getEnvVar("OTEL_LOG_MAX_EXPORT_BATCH_SIZE") ?? "64"), - scheduledDelayMillis: parseInt(getEnvVar("OTEL_LOG_SCHEDULED_DELAY_MILLIS") ?? "200"), - exportTimeoutMillis: parseInt(getEnvVar("OTEL_LOG_EXPORT_TIMEOUT_MILLIS") ?? "30000"), - maxQueueSize: parseInt(getEnvVar("OTEL_LOG_MAX_QUEUE_SIZE") ?? "512"), + maxExportBatchSize: parseInt( + getEnvVar("TRIGGER_OTEL_LOG_MAX_EXPORT_BATCH_SIZE") ?? "64" + ), + scheduledDelayMillis: parseInt( + getEnvVar("TRIGGER_OTEL_LOG_SCHEDULED_DELAY_MILLIS") ?? "200" + ), + exportTimeoutMillis: parseInt( + getEnvVar("TRIGGER_OTEL_LOG_EXPORT_TIMEOUT_MILLIS") ?? "30000" + ), + maxQueueSize: parseInt(getEnvVar("TRIGGER_OTEL_LOG_MAX_QUEUE_SIZE") ?? "512"), }) : new SimpleLogRecordProcessor(logExporter) - ) - ); + ), + ]; for (const externalLogExporter of config.logExporters ?? []) { - loggerProvider.addLogRecordProcessor( - getEnvVar("OTEL_BATCH_PROCESSING_ENABLED") === "1" + logProcessors.push( + getEnvVar("TRIGGER_OTEL_BATCH_PROCESSING_ENABLED") === "1" ? new BatchLogRecordProcessor( - new ExternalLogRecordExporterWrapper(externalLogExporter, externalTraceId), + new ExternalLogRecordExporterWrapper( + externalLogExporter, + externalTraceId, + externalTraceContext + ), { - maxExportBatchSize: parseInt(getEnvVar("OTEL_LOG_MAX_EXPORT_BATCH_SIZE") ?? "64"), + maxExportBatchSize: parseInt( + getEnvVar("TRIGGER_OTEL_LOG_MAX_EXPORT_BATCH_SIZE") ?? "64" + ), scheduledDelayMillis: parseInt( - getEnvVar("OTEL_LOG_SCHEDULED_DELAY_MILLIS") ?? "200" + getEnvVar("TRIGGER_OTEL_LOG_SCHEDULED_DELAY_MILLIS") ?? "200" ), exportTimeoutMillis: parseInt( - getEnvVar("OTEL_LOG_EXPORT_TIMEOUT_MILLIS") ?? "30000" + getEnvVar("TRIGGER_OTEL_LOG_EXPORT_TIMEOUT_MILLIS") ?? "30000" ), - maxQueueSize: parseInt(getEnvVar("OTEL_LOG_MAX_QUEUE_SIZE") ?? "512"), + maxQueueSize: parseInt(getEnvVar("TRIGGER_OTEL_LOG_MAX_QUEUE_SIZE") ?? "512"), } ) : new SimpleLogRecordProcessor( - new ExternalLogRecordExporterWrapper(externalLogExporter, externalTraceId) + new ExternalLogRecordExporterWrapper( + externalLogExporter, + externalTraceId, + externalTraceContext + ) ) ); } + // To start a logger, you first need to initialize the Logger provider. + const loggerProvider = new LoggerProvider({ + resource: commonResources, + logRecordLimits: { + attributeCountLimit: OTEL_LOG_ATTRIBUTE_COUNT_LIMIT, + attributeValueLengthLimit: OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT, + }, + processors: logProcessors, + }); + this._logProvider = loggerProvider; this._spanExporter = spanExporter; this._traceProvider = traceProvider; @@ -298,7 +289,10 @@ function setLogLevel(level: TracingDiagnosticLogLevel) { class ExternalSpanExporterWrapper { constructor( private underlyingExporter: SpanExporter, - private externalTraceId: string + private externalTraceId: string, + private externalTraceContext: + | { traceId: string; spanId: string; tracestate?: string } + | undefined ) {} private transformSpan(span: ReadableSpan): ReadableSpan | undefined { @@ -307,14 +301,38 @@ class ExternalSpanExporterWrapper { return; } + const externalTraceId = this.externalTraceContext + ? this.externalTraceContext.traceId + : this.externalTraceId; + + const isAttemptSpan = span.attributes[SemanticInternalAttributes.SPAN_ATTEMPT]; + const spanContext = span.spanContext(); + let parentSpanContext = span.parentSpanContext; + + if (parentSpanContext) { + parentSpanContext = { + ...parentSpanContext, + traceId: externalTraceId, + }; + } + + if (isAttemptSpan && this.externalTraceContext) { + parentSpanContext = { + ...parentSpanContext, + traceId: externalTraceId, + spanId: this.externalTraceContext.spanId, + traceState: this.externalTraceContext.tracestate + ? new TraceState(this.externalTraceContext.tracestate) + : undefined, + traceFlags: parentSpanContext?.traceFlags ?? 0, + }; + } return { ...span, - spanContext: () => ({ ...spanContext, traceId: this.externalTraceId }), - parentSpanId: span.attributes[SemanticInternalAttributes.SPAN_ATTEMPT] - ? undefined - : span.parentSpanId, + spanContext: () => ({ ...spanContext, traceId: externalTraceId }), + parentSpanContext, }; } @@ -344,7 +362,10 @@ class ExternalSpanExporterWrapper { class ExternalLogRecordExporterWrapper { constructor( private underlyingExporter: LogRecordExporter, - private externalTraceId: string + private externalTraceId: string, + private externalTraceContext: + | { traceId: string; spanId: string; tracestate?: string } + | undefined ) {} export(logs: any[], resultCallback: (result: any) => void): void { @@ -359,12 +380,14 @@ class ExternalLogRecordExporterWrapper { transformLogRecord(logRecord: ReadableLogRecord): ReadableLogRecord { // If there's no spanContext, or if the externalTraceId is not set, return the original logRecord. - if (!logRecord.spanContext || !this.externalTraceId) { + if (!logRecord.spanContext || !this.externalTraceId || !this.externalTraceContext) { return logRecord; } // Capture externalTraceId for use within the proxy's scope. - const { externalTraceId } = this; + const externalTraceId = this.externalTraceContext + ? this.externalTraceContext.traceId + : this.externalTraceId; return new Proxy(logRecord, { get(target, prop, receiver) { diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index bd32d848ff3..ccd0fa18809 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -296,3 +296,16 @@ export const RunChainState = z.object({ }); export type RunChainState = z.infer; + +export const TriggerTraceContext = z.object({ + traceparent: z.string().optional(), + tracestate: z.string().optional(), + external: z + .object({ + traceparent: z.string().optional(), + tracestate: z.string().optional(), + }) + .optional(), +}); + +export type TriggerTraceContext = z.infer; diff --git a/packages/core/src/v3/taskContext/otelProcessors.ts b/packages/core/src/v3/taskContext/otelProcessors.ts index db30624ff6e..16f93d42a9b 100644 --- a/packages/core/src/v3/taskContext/otelProcessors.ts +++ b/packages/core/src/v3/taskContext/otelProcessors.ts @@ -1,17 +1,16 @@ -import { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { Context, trace, Tracer } from "@opentelemetry/api"; +import { LogRecordProcessor, SdkLogRecord } from "@opentelemetry/sdk-logs"; import { Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"; import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; -import { Context } from "@opentelemetry/api"; -import { flattenAttributes } from "../utils/flattenAttributes.js"; import { taskContext } from "../task-context-api.js"; -import { Tracer } from "@opentelemetry/api"; +import { flattenAttributes } from "../utils/flattenAttributes.js"; export class TaskContextSpanProcessor implements SpanProcessor { private _innerProcessor: SpanProcessor; private _tracer: Tracer; - constructor(tracer: Tracer, innerProcessor: SpanProcessor) { - this._tracer = tracer; + constructor(version: string, innerProcessor: SpanProcessor) { + this._tracer = trace.getTracer("trigger-dev-worker", version); this._innerProcessor = innerProcessor; } @@ -91,7 +90,7 @@ export class TaskContextLogProcessor implements LogRecordProcessor { forceFlush(): Promise { return this._innerProcessor.forceFlush(); } - onEmit(logRecord: LogRecord, context?: Context | undefined): void { + onEmit(logRecord: SdkLogRecord, context?: Context | undefined): void { // Adds in the context attributes to the log record if (taskContext.ctx) { logRecord.setAttributes( diff --git a/packages/core/src/v3/trace-context-api.ts b/packages/core/src/v3/trace-context-api.ts new file mode 100644 index 00000000000..1a4333fbe20 --- /dev/null +++ b/packages/core/src/v3/trace-context-api.ts @@ -0,0 +1,5 @@ +// Split module-level variable definition into separate files to allow +// tree-shaking on each api instance. +import { TraceContextAPI } from "./traceContext/api.js"; +/** Entrypoint for trace context API */ +export const traceContext = TraceContextAPI.getInstance(); diff --git a/packages/core/src/v3/traceContext/api.ts b/packages/core/src/v3/traceContext/api.ts new file mode 100644 index 00000000000..3b21e0668a1 --- /dev/null +++ b/packages/core/src/v3/traceContext/api.ts @@ -0,0 +1,74 @@ +import { context, Context } from "@opentelemetry/api"; +import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; +import { TraceContextManager } from "./types.js"; + +const API_NAME = "trace-context"; + +class NoopTraceContextManager implements TraceContextManager { + getTraceContext() { + return {}; + } + + reset() {} + + getExternalTraceContext() { + return undefined; + } + + extractContext(): Context { + return context.active(); + } + + withExternalTrace(fn: () => T): T { + return fn(); + } +} + +const NOOP_TRACE_CONTEXT_MANAGER = new NoopTraceContextManager(); + +export class TraceContextAPI implements TraceContextManager { + private static _instance?: TraceContextAPI; + + private constructor() {} + + public static getInstance(): TraceContextAPI { + if (!this._instance) { + this._instance = new TraceContextAPI(); + } + + return this._instance; + } + + public setGlobalManager(manager: TraceContextManager): boolean { + return registerGlobal(API_NAME, manager); + } + + public disable() { + unregisterGlobal(API_NAME); + } + + public reset() { + this.#getManager().reset(); + this.disable(); + } + + public getTraceContext() { + return this.#getManager().getTraceContext(); + } + + public getExternalTraceContext() { + return this.#getManager().getExternalTraceContext(); + } + + public extractContext() { + return this.#getManager().extractContext(); + } + + public withExternalTrace(fn: () => T): T { + return this.#getManager().withExternalTrace(fn); + } + + #getManager(): TraceContextManager { + return getGlobal(API_NAME) ?? NOOP_TRACE_CONTEXT_MANAGER; + } +} diff --git a/packages/core/src/v3/traceContext/manager.ts b/packages/core/src/v3/traceContext/manager.ts new file mode 100644 index 00000000000..aa7f92b7488 --- /dev/null +++ b/packages/core/src/v3/traceContext/manager.ts @@ -0,0 +1,77 @@ +import { Context, context, propagation, trace, TraceFlags } from "@opentelemetry/api"; +import { TraceContextManager } from "./types.js"; + +export class StandardTraceContextManager implements TraceContextManager { + public traceContext: Record = {}; + + getTraceContext() { + return this.traceContext; + } + + reset() { + this.traceContext = {}; + } + + getExternalTraceContext() { + return extractExternalTraceContext(this.traceContext?.external); + } + + extractContext(): Context { + return propagation.extract(context.active(), this.traceContext ?? {}); + } + + withExternalTrace(fn: () => T): T { + const externalTraceContext = this.getExternalTraceContext(); + + if (!externalTraceContext) { + return fn(); + } + + // Get the current active span context to extract the span ID + const currentSpanContext = trace.getActiveSpan()?.spanContext(); + + if (!currentSpanContext) { + throw new Error( + "No active span found. withExternalSpan must be called within an active span context." + ); + } + + const spanContext = { + traceId: externalTraceContext.traceId, + spanId: currentSpanContext.spanId, + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }; + + const contextWithSpan = trace.setSpanContext(context.active(), spanContext); + + return context.with(contextWithSpan, fn); + } +} + +function extractExternalTraceContext(traceContext: unknown) { + if (typeof traceContext !== "object" || traceContext === null) { + return undefined; + } + + const tracestate = + "tracestate" in traceContext && typeof traceContext.tracestate === "string" + ? traceContext.tracestate + : undefined; + + if ("traceparent" in traceContext && typeof traceContext.traceparent === "string") { + const [version, traceId, spanId] = traceContext.traceparent.split("-"); + + if (!traceId || !spanId) { + return undefined; + } + + return { + traceId, + spanId, + tracestate: tracestate, + }; + } + + return undefined; +} diff --git a/packages/core/src/v3/traceContext/types.ts b/packages/core/src/v3/traceContext/types.ts new file mode 100644 index 00000000000..a1130cdf569 --- /dev/null +++ b/packages/core/src/v3/traceContext/types.ts @@ -0,0 +1,15 @@ +import { Context } from "@opentelemetry/api"; + +export interface TraceContextManager { + getTraceContext(): Record; + extractContext(): Context; + reset(): void; + getExternalTraceContext(): + | { + traceId: string; + spanId: string; + tracestate?: string; + } + | undefined; + withExternalTrace(fn: () => T): T; +} diff --git a/packages/core/src/v3/tracer.ts b/packages/core/src/v3/tracer.ts index 4adf8268b56..5b213917592 100644 --- a/packages/core/src/v3/tracer.ts +++ b/packages/core/src/v3/tracer.ts @@ -63,10 +63,6 @@ export class TriggerTracer { return this._logger; } - extractContext(traceContext?: Record) { - return propagation.extract(context.active(), traceContext ?? {}); - } - startActiveSpan( name: string, fn: (span: Span) => Promise, diff --git a/packages/core/src/v3/utils/globals.ts b/packages/core/src/v3/utils/globals.ts index e59539b343b..570bb34150f 100644 --- a/packages/core/src/v3/utils/globals.ts +++ b/packages/core/src/v3/utils/globals.ts @@ -8,6 +8,7 @@ import type { RuntimeManager } from "../runtime/manager.js"; import { RunTimelineMetricsManager } from "../runTimelineMetrics/types.js"; import { TaskContext } from "../taskContext/types.js"; import { TimeoutManager } from "../timeout/types.js"; +import { TraceContextManager } from "../traceContext/types.js"; import { UsageManager } from "../usage/types.js"; import { WaitUntilManager } from "../waitUntil/types.js"; import { _globalThis } from "./platform.js"; @@ -66,4 +67,5 @@ type TriggerDotDevGlobalAPI = { ["run-timeline-metrics"]?: RunTimelineMetricsManager; ["lifecycle-hooks"]?: LifecycleHooksManager; ["locals"]?: LocalsManager; + ["trace-context"]?: TraceContextManager; }; diff --git a/packages/core/src/v3/workers/index.ts b/packages/core/src/v3/workers/index.ts index 5ff626f2eaf..613fe330256 100644 --- a/packages/core/src/v3/workers/index.ts +++ b/packages/core/src/v3/workers/index.ts @@ -28,3 +28,4 @@ export { WarmStartClient, type WarmStartClientOptions } from "../workers/warmSta export { StandardLifecycleHooksManager } from "../lifecycleHooks/manager.js"; export { StandardLocalsManager } from "../locals/manager.js"; export { populateEnv } from "./populateEnv.js"; +export { StandardTraceContextManager } from "../traceContext/manager.js"; diff --git a/packages/core/src/v3/workers/taskExecutor.ts b/packages/core/src/v3/workers/taskExecutor.ts index eafe00b1f32..38e7d7c19a6 100644 --- a/packages/core/src/v3/workers/taskExecutor.ts +++ b/packages/core/src/v3/workers/taskExecutor.ts @@ -1,5 +1,5 @@ -import { Context, context, SpanKind, trace } from "@opentelemetry/api"; -import { VERSION } from "../../version.js"; +import { Context, context, SpanKind } from "@opentelemetry/api"; +import { promiseWithResolvers } from "../../utils.js"; import { ApiError, RateLimitError } from "../apiClient/errors.js"; import { ConsoleInterceptor } from "../consoleInterceptor.js"; import { @@ -17,6 +17,7 @@ import { lifecycleHooks, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, runMetadata, + traceContext, waitUntil, } from "../index.js"; import { @@ -31,7 +32,6 @@ import { runTimelineMetrics } from "../run-timeline-metrics-api.js"; import { COLD_VARIANT, RetryOptions, - ServerBackgroundWorker, TaskRunContext, TaskRunErrorCodes, TaskRunExecution, @@ -40,7 +40,6 @@ import { WARM_VARIANT, } from "../schemas/index.js"; import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; -import { taskContext } from "../task-context-api.js"; import { TriggerTracer } from "../tracer.js"; import { tryCatch } from "../tryCatch.js"; import { HandleErrorModificationOptions, TaskMetadataWithFunctions } from "../types/index.js"; @@ -52,7 +51,6 @@ import { stringifyIO, } from "../utils/ioSerialization.js"; import { calculateNextRetryDelay } from "../utils/retries.js"; -import { promiseWithResolvers } from "../../utils.js"; export type TaskExecutorOptions = { tracingSDK: TracingSDK; @@ -93,12 +91,9 @@ export class TaskExecutor { async execute( execution: TaskRunExecution, - worker: ServerBackgroundWorker, - traceContext: Record, - signal: AbortSignal, - isWarmStart?: boolean + ctx: TaskRunContext, + signal: AbortSignal ): Promise<{ result: TaskRunExecutionResult }> { - const ctx = TaskRunContext.parse(execution); const attemptMessage = `Attempt ${execution.attempt.number}`; const originalPacket = { @@ -106,22 +101,10 @@ export class TaskExecutor { dataType: execution.run.payloadType, }; - taskContext.setGlobalTaskContext({ - ctx, - worker, - isWarmStart: isWarmStart ?? this._isWarmStart, - }); - if (execution.run.metadata) { runMetadata.enterWithMetadata(execution.run.metadata); } - if (!this._tracingSDK.asyncResourceDetector.isResolved) { - this._tracingSDK.asyncResourceDetector.resolveWithAttributes({ - ...taskContext.resourceAttributes, - }); - } - const result = await this._tracer.startActiveSpan( attemptMessage, async (span) => { @@ -369,7 +352,7 @@ export class TaskExecutor { ? runTimelineMetrics.convertMetricsToSpanEvents() : undefined, }, - this._tracer.extractContext(traceContext), + traceContext.extractContext(), signal ); diff --git a/packages/core/test/taskExecutor.test.ts b/packages/core/test/taskExecutor.test.ts index d78cc57cd7f..229a952fff0 100644 --- a/packages/core/test/taskExecutor.test.ts +++ b/packages/core/test/taskExecutor.test.ts @@ -1942,5 +1942,5 @@ function executeTask( const $signal = signal ? signal : new AbortController().signal; - return executor.execute(execution, worker, {}, $signal); + return executor.execute(execution, execution, $signal); } diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 8b2ce0db4e5..d070d2049c5 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -50,8 +50,7 @@ }, "dependencies": { "@opentelemetry/api": "1.9.0", - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/semantic-conventions": "1.25.1", + "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", "chalk": "^5.2.0", "cronstrue": "^2.21.0", diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 4527cd6d01e..94e3e23cd03 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -13,6 +13,7 @@ export * from "./metadata.js"; export * from "./timeout.js"; export * from "./webhooks.js"; export * from "./locals.js"; +export * from "./otel.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/otel.ts b/packages/trigger-sdk/src/v3/otel.ts new file mode 100644 index 00000000000..e80c77562eb --- /dev/null +++ b/packages/trigger-sdk/src/v3/otel.ts @@ -0,0 +1,7 @@ +import { traceContext } from "@trigger.dev/core/v3"; + +export const otel = { + withExternalTrace: (fn: () => T): T => { + return traceContext.withExternalTrace(fn); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90b13719569..0c91127b613 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,7 +304,7 @@ importers: version: 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + version: 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-express': specifier: ^0.36.1 version: 0.36.1(@opentelemetry/api@1.9.0) @@ -322,7 +322,7 @@ importers: version: 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + version: 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': specifier: 1.25.1 version: 1.25.1(@opentelemetry/api@1.9.0) @@ -1276,38 +1276,26 @@ importers: specifier: 1.9.0 version: 1.9.0 '@opentelemetry/api-logs': - specifier: 0.52.1 - version: 0.52.1 - '@opentelemetry/exporter-logs-otlp-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.203.0 + version: 0.203.0 '@opentelemetry/exporter-trace-otlp-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) '@opentelemetry/instrumentation-fetch': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) '@opentelemetry/resources': - specifier: 1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) - '@opentelemetry/sdk-trace-base': - specifier: 1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) + specifier: 2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': - specifier: 1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) + specifier: 2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': - specifier: 1.25.1 - version: 1.25.1 + specifier: 1.36.0 + version: 1.36.0 '@trigger.dev/build': specifier: workspace:4.0.0-v4-beta.26 version: link:../build @@ -1526,38 +1514,35 @@ importers: specifier: 1.9.0 version: 1.9.0 '@opentelemetry/api-logs': - specifier: 0.52.1 - version: 0.52.1 + specifier: 0.203.0 + version: 0.203.0 '@opentelemetry/core': - specifier: ^1.30.1 - version: 1.30.1(@opentelemetry/api@1.9.0) + specifier: 2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-logs-otlp-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) '@opentelemetry/resources': - specifier: 1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) + specifier: 2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': - specifier: 1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) + specifier: 2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': - specifier: 1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) + specifier: 2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': - specifier: 1.25.1 - version: 1.25.1 + specifier: 1.36.0 + version: 1.36.0 dequal: specifier: ^2.0.3 version: 2.0.3 @@ -1815,12 +1800,9 @@ importers: '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 - '@opentelemetry/api-logs': - specifier: 0.52.1 - version: 0.52.1 '@opentelemetry/semantic-conventions': - specifier: 1.25.1 - version: 1.25.1 + specifier: 1.36.0 + version: 1.36.0 '@trigger.dev/core': specifier: workspace:4.0.0-v4-beta.26 version: link:../core @@ -1913,12 +1895,24 @@ importers: '@e2b/code-interpreter': specifier: ^1.1.0 version: 1.1.0 + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/api-logs': + specifier: ^0.203.0 + version: 0.203.0 '@opentelemetry/exporter-logs-otlp-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/sdk-logs': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.4)(@types/react@19.0.12)(react-dom@19.0.0)(react@19.0.0) @@ -1934,6 +1928,9 @@ importers: '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk + '@vercel/otel': + specifier: ^1.13.0 + version: 1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0)(@opentelemetry/resources@1.25.1)(@opentelemetry/sdk-logs@0.203.0)(@opentelemetry/sdk-metrics@1.25.1)(@opentelemetry/sdk-trace-base@1.25.1) '@vercel/postgres': specifier: ^0.10.0 version: 0.10.0 @@ -2469,7 +2466,7 @@ importers: version: 1.25.1(@opentelemetry/api@1.4.1) '@opentelemetry/sdk-logs': specifier: ^0.49.1 - version: 0.49.1(@opentelemetry/api-logs@0.52.1)(@opentelemetry/api@1.4.1) + version: 0.49.1(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.4.1) '@opentelemetry/sdk-node': specifier: ^0.49.1 version: 0.49.1(@opentelemetry/api@1.4.1) @@ -3019,7 +3016,7 @@ packages: dependencies: '@andrewbranch/untar.js': 1.0.3 fflate: 0.8.2 - semver: 7.6.3 + semver: 7.7.2 ts-expose-internals-conditionally: 1.0.0-empty.0 typescript: 5.3.3 validate-npm-package-name: 5.0.0 @@ -4817,7 +4814,7 @@ packages: '@babel/traverse': 7.24.7 '@babel/types': 7.24.0 convert-source-map: 1.9.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4839,7 +4836,7 @@ packages: '@babel/traverse': 7.24.7 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5416,7 +5413,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.24.7 '@babel/types': 7.24.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5434,7 +5431,7 @@ packages: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5448,7 +5445,7 @@ packages: '@babel/parser': 7.27.0 '@babel/template': 7.25.0 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5544,7 +5541,7 @@ packages: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.6.3 + semver: 7.7.2 dev: false /@changesets/assemble-release-plan@5.2.4(patch_hash=3wuhjtl4hjck4itk3w32z4cd5u): @@ -5555,7 +5552,7 @@ packages: '@changesets/get-dependents-graph': 1.3.6 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 - semver: 7.6.3 + semver: 7.7.2 dev: false patched: true @@ -5629,7 +5626,7 @@ packages: '@manypkg/get-packages': 1.1.3 chalk: 2.4.2 fs-extra: 7.0.1 - semver: 7.6.3 + semver: 7.7.2 dev: false /@changesets/get-github-info@0.5.2: @@ -7955,7 +7952,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 espree: 9.6.0 globals: 13.19.0 ignore: 5.2.4 @@ -8205,13 +8202,6 @@ packages: '@grpc/proto-loader': 0.7.13 '@js-sdsl/ordered-map': 4.4.2 - /@grpc/grpc-js@1.8.17: - resolution: {integrity: sha512-DGuSbtMFbaRsyffMf+VEkVu8HkSXEUfO3UyGJNtqxW9ABdtTIA+2UXAJpwbJS+xfQxuwqLUeELmL6FuZkOqPxw==} - engines: {node: ^8.13.0 || >=10.10.0} - dependencies: - '@grpc/proto-loader': 0.7.7 - '@types/node': 20.14.14 - /@grpc/proto-loader@0.7.13: resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==} engines: {node: '>=6'} @@ -8222,17 +8212,6 @@ packages: protobufjs: 7.3.2 yargs: 17.7.2 - /@grpc/proto-loader@0.7.7: - resolution: {integrity: sha512-1TIeXOi8TuSCQprPItwoMymZXxWT0CPxUhkrkeCUH+D8U7QDwQ6b7SUz2MaLuWM2llT+J/TVFLmQI5KtML3BhQ==} - engines: {node: '>=6'} - hasBin: true - dependencies: - '@types/long': 4.0.2 - lodash.camelcase: 4.3.0 - long: 4.0.0 - protobufjs: 7.3.2 - yargs: 17.7.2 - /@hapi/boom@10.0.1: resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} dependencies: @@ -8332,7 +8311,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -9679,7 +9658,7 @@ packages: resolution: {integrity: sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: - semver: 7.6.3 + semver: 7.7.2 /@npmcli/git@4.1.0: resolution: {integrity: sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==} @@ -9691,7 +9670,7 @@ packages: proc-log: 3.0.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.6.3 + semver: 7.7.2 which: 3.0.1 transitivePeerDependencies: - bluebird @@ -9718,7 +9697,7 @@ packages: json-parse-even-better-errors: 3.0.0 normalize-package-data: 5.0.0 proc-log: 3.0.0 - semver: 7.6.3 + semver: 7.7.2 transitivePeerDependencies: - bluebird dev: true @@ -9748,6 +9727,12 @@ packages: resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} dev: false + /@opentelemetry/api-logs@0.203.0: + resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} + engines: {node: '>=8.0.0'} + dependencies: + '@opentelemetry/api': 1.9.0 + /@opentelemetry/api-logs@0.49.1: resolution: {integrity: sha512-kaNl/T7WzyMUQHQlVq7q0oV4Kev6+0xFwqzofryC66jgGMacd0QH5TwfpbUwSTby+SdAdprAe5UKMvBw4tKS5Q==} engines: {node: '>=14'} @@ -9766,6 +9751,7 @@ packages: engines: {node: '>=14'} dependencies: '@opentelemetry/api': 1.9.0 + dev: false /@opentelemetry/api-logs@0.57.0: resolution: {integrity: sha512-l1aJ30CXeauVYaI+btiynHpw341LthkMTv3omi1VJDX14werY2Wmv9n1yudMsq9HuY0m8PvXEVX4d8zxEb+WRg==} @@ -9830,6 +9816,15 @@ packages: '@opentelemetry/api': 1.9.0 dev: false + /@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + dev: false + /@opentelemetry/core@1.22.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==} engines: {node: '>=14'} @@ -9889,6 +9884,30 @@ packages: '@opentelemetry/semantic-conventions': 1.28.0 dev: false + /@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.36.0 + dev: false + + /@opentelemetry/exporter-logs-otlp-http@0.203.0(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + dev: false + /@opentelemetry/exporter-logs-otlp-http@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-3QoBnIGCmEkujynUP0mK155QtOM0MSf9FNrEw7u9ieCFsoMiyatg2hPp+alEDONJ8N8wGEK+wP2q3icgXBiggw==} engines: {node: '>=14'} @@ -9952,7 +9971,7 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 dependencies: - '@grpc/grpc-js': 1.8.17 + '@grpc/grpc-js': 1.12.6 '@opentelemetry/api': 1.4.1 '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.4.1) '@opentelemetry/otlp-grpc-exporter-base': 0.49.1(@opentelemetry/api@1.4.1) @@ -9967,7 +9986,7 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 dependencies: - '@grpc/grpc-js': 1.8.17 + '@grpc/grpc-js': 1.12.6 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-grpc-exporter-base': 0.52.1(@opentelemetry/api@1.9.0) @@ -9976,6 +9995,20 @@ packages: '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) dev: false + /@opentelemetry/exporter-trace-otlp-http@0.203.0(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-ZDiaswNYo0yq/cy1bBLJFe691izEJ6IgNmkjm4C6kE9ub/OMQqDXORx2D2j8fzTBTxONyzusbaZlqtfmyqURPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + dev: false + /@opentelemetry/exporter-trace-otlp-http@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-KOLtZfZvIrpGZLVvblKsiVQT7gQUZNKcUUH24Zz6Xbi7LJb9Vt6xtUZFYdR5IIjvt47PIqBKDWUQlU0o1wAsRw==} engines: {node: '>=14'} @@ -10080,7 +10113,7 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 transitivePeerDependencies: @@ -10094,7 +10127,7 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 '@types/connect': 3.4.38 @@ -10123,7 +10156,7 @@ packages: '@opentelemetry/api': 1.4.1 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.4.1) '@opentelemetry/instrumentation': 0.49.1(@opentelemetry/api@1.4.1) - '@opentelemetry/semantic-conventions': 1.25.1 + '@opentelemetry/semantic-conventions': 1.36.0 transitivePeerDependencies: - supports-color dev: true @@ -10149,13 +10182,28 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 transitivePeerDependencies: - supports-color dev: false + /@opentelemetry/instrumentation-fetch@0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0): + resolution: {integrity: sha512-Z+mls3rOP2BaVykDZLLZPvchjj9l2oj3dYG1GTnrc27Y8o3biE+5M1b0izblycbbQHXjMPHQCpmjHbLMQuWtBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/sdk-trace-web': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/instrumentation-fetch@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-hizhULZXlq02y8YC0vPQ4WtUWiXcwxPdEqHBy8p75jzF9rAuP/ldrVr0Oxvz5Xr9qQcdEOFLvEl0ZxbVL76WKw==} engines: {node: '>=14'} @@ -10171,21 +10219,6 @@ packages: - supports-color dev: true - /@opentelemetry/instrumentation-fetch@0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0): - resolution: {integrity: sha512-EJDQXdv1ZGyBifox+8BK+hP0tg29abNPdScE+lW77bUVrThD5vn2dOo+blAS3Z8Od+eqTUTDzXVDIFjGgTK01w==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) - '@opentelemetry/sdk-trace-web': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.25.1 - transitivePeerDependencies: - - supports-color - dev: false - /@opentelemetry/instrumentation-fs@0.19.1(@opentelemetry/api@1.9.0): resolution: {integrity: sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==} engines: {node: '>=14'} @@ -10193,7 +10226,7 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color @@ -10230,7 +10263,7 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 transitivePeerDependencies: @@ -10260,7 +10293,7 @@ packages: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.25.1 semver: 7.6.3 transitivePeerDependencies: @@ -10330,7 +10363,7 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 transitivePeerDependencies: @@ -10369,7 +10402,7 @@ packages: '@opentelemetry/api': ^1.3.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 transitivePeerDependencies: @@ -10456,7 +10489,7 @@ packages: '@opentelemetry/api': ^1.7.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color @@ -10475,6 +10508,20 @@ packages: - supports-color dev: true + /@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0): + resolution: {integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + import-in-the-middle: 1.11.0 + require-in-the-middle: 7.1.1(supports-color@10.0.0) + transitivePeerDependencies: + - supports-color + dev: false + /@opentelemetry/instrumentation@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-0DLtWtaIppuNNRRllSD4bjU8ZIiLp1cDXvJEbp752/Zf+y3gaLNaoGRGIlX4UHhcsrmtL+P2qxi3Hodi8VuKiQ==} engines: {node: '>=14'} @@ -10534,10 +10581,10 @@ packages: dependencies: '@opentelemetry/api': 1.4.1 '@opentelemetry/api-logs': 0.51.1 - '@types/shimmer': 1.0.2 + '@types/shimmer': 1.2.0 import-in-the-middle: 1.7.4 require-in-the-middle: 7.1.1(supports-color@10.0.0) - semver: 7.6.3 + semver: 7.7.2 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -10551,16 +10598,16 @@ packages: dependencies: '@opentelemetry/api': 1.4.1 '@opentelemetry/api-logs': 0.52.1 - '@types/shimmer': 1.0.2 + '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.0 require-in-the-middle: 7.1.1(supports-color@10.0.0) - semver: 7.6.3 + semver: 7.7.2 shimmer: 1.2.1 transitivePeerDependencies: - supports-color dev: false - /@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0): + /@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0): resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} engines: {node: '>=14'} peerDependencies: @@ -10568,10 +10615,10 @@ packages: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.52.1 - '@types/shimmer': 1.0.2 + '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.0 require-in-the-middle: 7.1.1(supports-color@10.0.0) - semver: 7.6.3 + semver: 7.7.2 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -10594,6 +10641,17 @@ packages: - supports-color dev: false + /@opentelemetry/otlp-exporter-base@0.203.0(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + dev: false + /@opentelemetry/otlp-exporter-base@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==} engines: {node: '>=14'} @@ -10632,7 +10690,7 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 dependencies: - '@grpc/grpc-js': 1.8.17 + '@grpc/grpc-js': 1.12.6 '@opentelemetry/api': 1.4.1 '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.4.1) '@opentelemetry/otlp-exporter-base': 0.49.1(@opentelemetry/api@1.4.1) @@ -10645,7 +10703,7 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 dependencies: - '@grpc/grpc-js': 1.8.17 + '@grpc/grpc-js': 1.12.6 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-exporter-base': 0.52.1(@opentelemetry/api@1.9.0) @@ -10664,6 +10722,22 @@ packages: protobufjs: 7.3.2 dev: true + /@opentelemetry/otlp-transformer@0.203.0(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + protobufjs: 7.3.2 + dev: false + /@opentelemetry/otlp-transformer@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-Z+koA4wp9L9e3jkFacyXTGphSWTbOKjwwXMpb0CxNb0kjTHGUxhYRN8GnkLFsFo5NbZPjP07hwAqeEG/uCratQ==} engines: {node: '>=14'} @@ -10842,7 +10916,30 @@ packages: '@opentelemetry/semantic-conventions': 1.28.0 dev: false - /@opentelemetry/sdk-logs@0.49.1(@opentelemetry/api-logs@0.49.1)(@opentelemetry/api@1.4.1): + /@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + dev: false + + /@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + dev: false + + /@opentelemetry/sdk-logs@0.49.1(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==} engines: {node: '>=14'} peerDependencies: @@ -10850,12 +10947,12 @@ packages: '@opentelemetry/api-logs': '>=0.39.1' dependencies: '@opentelemetry/api': 1.4.1 - '@opentelemetry/api-logs': 0.49.1 + '@opentelemetry/api-logs': 0.203.0 '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.4.1) '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.4.1) dev: true - /@opentelemetry/sdk-logs@0.49.1(@opentelemetry/api-logs@0.52.1)(@opentelemetry/api@1.4.1): + /@opentelemetry/sdk-logs@0.49.1(@opentelemetry/api-logs@0.49.1)(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==} engines: {node: '>=14'} peerDependencies: @@ -10863,7 +10960,7 @@ packages: '@opentelemetry/api-logs': '>=0.39.1' dependencies: '@opentelemetry/api': 1.4.1 - '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/api-logs': 0.49.1 '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.4.1) '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.4.1) dev: true @@ -10927,6 +11024,17 @@ packages: '@opentelemetry/resources': 1.30.0(@opentelemetry/api@1.9.0) dev: false + /@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + dev: false + /@opentelemetry/sdk-node@0.49.1(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-feBIT85ndiSHXsQ2gfGpXC/sNeX4GCHLksC4A9s/bfpUbbgbCSl0RvzZlmEpCHarNrkZMwFRi4H0xFfgvJEjrg==} engines: {node: '>=14'} @@ -10951,7 +11059,7 @@ packages: - supports-color dev: true - /@opentelemetry/sdk-node@0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0): + /@opentelemetry/sdk-node@0.52.1(@opentelemetry/api@1.9.0): resolution: {integrity: sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==} engines: {node: '>=14'} peerDependencies: @@ -10964,7 +11072,7 @@ packages: '@opentelemetry/exporter-trace-otlp-http': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-proto': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-zipkin': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.0) @@ -11047,6 +11155,18 @@ packages: '@opentelemetry/semantic-conventions': 1.28.0 dev: false + /@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + dev: false + /@opentelemetry/sdk-trace-node@1.22.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-gTGquNz7ue8uMeiWPwp3CU321OstQ84r7PCDtOaCicjbJxzvO8RZMlEC4geOipTeiF88kss5n6w+//A0MhP1lQ==} engines: {node: '>=14'} @@ -11059,7 +11179,7 @@ packages: '@opentelemetry/propagator-b3': 1.22.0(@opentelemetry/api@1.4.1) '@opentelemetry/propagator-jaeger': 1.22.0(@opentelemetry/api@1.4.1) '@opentelemetry/sdk-trace-base': 1.22.0(@opentelemetry/api@1.4.1) - semver: 7.6.3 + semver: 7.7.2 dev: true /@opentelemetry/sdk-trace-node@1.25.1(@opentelemetry/api@1.4.1): @@ -11092,6 +11212,18 @@ packages: semver: 7.6.3 dev: false + /@opentelemetry/sdk-trace-node@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + dev: false + /@opentelemetry/sdk-trace-web@1.22.0(@opentelemetry/api@1.4.1): resolution: {integrity: sha512-id5bUhWYg475xbm4hjwWA4PnWM4duNK1EyFRkZxa3BZNuCITwiKCLvDkVhlE9RK2kvuDOPmcRxgSbU1apF9/1w==} engines: {node: '>=14'} @@ -11104,16 +11236,15 @@ packages: '@opentelemetry/semantic-conventions': 1.22.0 dev: true - /@opentelemetry/sdk-trace-web@1.25.1(@opentelemetry/api@1.9.0): - resolution: {integrity: sha512-SS6JaSkHngcBCNdWGthzcvaKGRnDw2AeP57HyTEileLToJ7WLMeV+064iRlVyoT4+e77MRp2T2dDSrmaUyxoNg==} - engines: {node: '>=14'} + /@opentelemetry/sdk-trace-web@2.0.1(@opentelemetry/api@1.9.0): + resolution: {integrity: sha512-R4/i0rISvAujG4Zwk3s6ySyrWG+Db3SerZVM4jZ2lEzjrNylF7nRAy1hVvWe8gTbwIxX+6w6ZvZwdtl2C7UQHQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.25.1 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) dev: false /@opentelemetry/semantic-conventions@1.22.0: @@ -11132,7 +11263,6 @@ packages: /@opentelemetry/semantic-conventions@1.36.0: resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} engines: {node: '>=14'} - dev: false /@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.0): resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} @@ -11141,7 +11271,7 @@ packages: '@opentelemetry/api': ^1.1.0 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) dev: false /@pkgjs/parseargs@0.11.0: @@ -11330,7 +11460,7 @@ packages: '@opentelemetry/api': ^1.8 dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color dev: false @@ -11427,7 +11557,7 @@ packages: engines: {node: '>=18'} hasBin: true dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -11444,11 +11574,11 @@ packages: engines: {node: '>=18'} hasBin: true dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.4.0 - semver: 7.6.3 + semver: 7.7.2 tar-fs: 3.0.9 unbzip2-stream: 1.4.3 yargs: 17.7.2 @@ -19552,7 +19682,7 @@ packages: dependencies: '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.4.1) '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.4.1) - '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/semantic-conventions': 1.36.0 '@traceloop/ai-semantic-conventions': 0.10.0 js-tiktoken: 1.0.14 tslib: 2.6.2 @@ -19912,9 +20042,6 @@ packages: resolution: {integrity: sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==} dev: true - /@types/long@4.0.2: - resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - /@types/marked@4.0.8: resolution: {integrity: sha512-HVNzMT5QlWCOdeuBsgXP8EZzKUf0+AXzN+sLmjvaB3ZlLqO+e4u0uXrdw9ub69wBKFs+c6/pA4r9sy6cCDvImw==} dev: true @@ -20211,7 +20338,6 @@ packages: /@types/shimmer@1.2.0: resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} - dev: false /@types/simple-oauth2@5.0.4: resolution: {integrity: sha512-4SvTfmAa1fGUa1d07j9vIiC4o92bGh0ihPXmtS05udMMmNwVIaU2nZ706cC4wI8cJxOlHD4P/d5tzqvWYd+KxA==} @@ -20419,7 +20545,7 @@ packages: '@typescript-eslint/scope-manager': 5.59.6 '@typescript-eslint/types': 5.59.6 '@typescript-eslint/typescript-estree': 5.59.6(typescript@5.5.4) - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 eslint: 8.31.0 typescript: 5.5.4 transitivePeerDependencies: @@ -20446,7 +20572,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.59.6(typescript@5.5.4) '@typescript-eslint/utils': 5.59.6(eslint@8.31.0)(typescript@5.5.4) - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 eslint: 8.31.0 tsutils: 3.21.0(typescript@5.5.4) typescript: 5.5.4 @@ -20470,10 +20596,10 @@ packages: dependencies: '@typescript-eslint/types': 5.59.6 '@typescript-eslint/visitor-keys': 5.59.6 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.6.3 + semver: 7.7.2 tsutils: 3.21.0(typescript@5.5.4) typescript: 5.5.4 transitivePeerDependencies: @@ -20494,7 +20620,7 @@ packages: '@typescript-eslint/typescript-estree': 5.59.6(typescript@5.5.4) eslint: 8.31.0 eslint-scope: 5.1.1 - semver: 7.6.3 + semver: 7.7.2 transitivePeerDependencies: - supports-color - typescript @@ -20676,6 +20802,27 @@ packages: resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} dev: true + /@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0)(@opentelemetry/resources@1.25.1)(@opentelemetry/sdk-logs@0.203.0)(@opentelemetry/sdk-metrics@1.25.1)(@opentelemetry/sdk-trace-base@1.25.1): + resolution: {integrity: sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': '>=1.7.0 <2.0.0' + '@opentelemetry/api-logs': '>=0.46.0 <0.200.0' + '@opentelemetry/instrumentation': '>=0.46.0 <0.200.0' + '@opentelemetry/resources': '>=1.19.0 <2.0.0' + '@opentelemetry/sdk-logs': '>=0.46.0 <0.200.0' + '@opentelemetry/sdk-metrics': '>=1.19.0 <2.0.0' + '@opentelemetry/sdk-trace-base': '>=1.19.0 <2.0.0' + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) + dev: false + /@vercel/postgres@0.10.0: resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==} engines: {node: '>=18.14'} @@ -20698,7 +20845,7 @@ packages: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -21179,20 +21326,12 @@ packages: negotiator: 1.0.0 dev: false - /acorn-import-assertions@1.9.0(acorn@8.12.1): - resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} - peerDependencies: - acorn: ^8 - dependencies: - acorn: 8.12.1 - /acorn-import-assertions@1.9.0(acorn@8.14.1): resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} peerDependencies: acorn: ^8 dependencies: acorn: 8.14.1 - dev: false /acorn-import-attributes@1.9.5(acorn@8.12.1): resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} @@ -21222,7 +21361,6 @@ packages: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: acorn: 8.14.1 - dev: true /acorn-node@1.8.2: resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} @@ -21276,7 +21414,7 @@ packages: engines: {node: '>= 6.0.0'} requiresBuild: true dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -21284,7 +21422,7 @@ packages: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -22216,7 +22354,7 @@ packages: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -22577,7 +22715,7 @@ packages: /capnp-ts@0.7.0: resolution: {integrity: sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==} dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -23691,7 +23829,7 @@ packages: ms: 2.1.3 supports-color: 10.0.0 - /debug@4.4.0(supports-color@10.0.0): + /debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} peerDependencies: @@ -23701,9 +23839,8 @@ packages: optional: true dependencies: ms: 2.1.3 - supports-color: 10.0.0 - /debug@4.4.1: + /debug@4.4.1(supports-color@10.0.0): resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: @@ -23713,7 +23850,7 @@ packages: optional: true dependencies: ms: 2.1.3 - dev: false + supports-color: 10.0.0 /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} @@ -23977,7 +24114,7 @@ packages: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 readable-stream: 3.6.0 split-ca: 1.0.1 ssh2: 1.16.0 @@ -25030,7 +25167,7 @@ packages: eslint: '*' eslint-plugin-import: '*' dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 enhanced-resolve: 5.15.0 eslint: 8.31.0 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.59.6)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) @@ -25399,8 +25536,8 @@ packages: resolution: {integrity: sha512-1FH/IiruXZ84tpUlm0aCUEwMl2Ho5ilqVh0VvQXw+byAz/4SAciyHLlfmL5WYqsvD38oymdUwBss0LtK8m4s/A==} 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.2 /esprima@4.0.1: @@ -25727,7 +25864,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -25998,7 +26135,7 @@ packages: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -26479,7 +26616,7 @@ packages: dependencies: basic-ftp: 5.0.3 data-uri-to-buffer: 5.0.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) fs-extra: 8.1.0 transitivePeerDependencies: - supports-color @@ -26709,9 +26846,9 @@ packages: '@types/node': 20.14.14 '@types/semver': 7.5.1 chalk: 4.1.2 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 interpret: 3.1.1 - semver: 7.6.3 + semver: 7.7.2 tslib: 2.6.2 yargs: 17.7.2 transitivePeerDependencies: @@ -27008,7 +27145,7 @@ packages: dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color dev: false @@ -27019,7 +27156,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -27028,7 +27165,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color dev: false @@ -27047,7 +27184,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -27056,7 +27193,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color dev: true @@ -27066,7 +27203,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color dev: false @@ -27076,7 +27213,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color dev: false @@ -27180,8 +27317,8 @@ packages: /import-in-the-middle@1.7.1: resolution: {integrity: sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg==} dependencies: - acorn: 8.12.1 - acorn-import-assertions: 1.9.0(acorn@8.12.1) + acorn: 8.14.1 + acorn-import-assertions: 1.9.0(acorn@8.14.1) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 @@ -27770,7 +27907,7 @@ packages: engines: {node: '>=10'} dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -28522,9 +28659,6 @@ packages: chalk: 4.1.2 is-unicode-supported: 0.1.0 - /long@4.0.0: - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} - /long@5.2.3: resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} @@ -29500,7 +29634,7 @@ packages: resolution: {integrity: sha512-6Mj0yHLdUZjHnOPgr5xfWIMqMWS12zDN6iws9SLuSz76W8jTtAv24MN4/CL7gJrl5vtxGInkkqDv/JIoRsQOvA==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.0.6 micromark-factory-space: 1.0.0 @@ -29524,7 +29658,7 @@ packages: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -29621,7 +29755,7 @@ packages: hasBin: true dependencies: '@cspotcode/source-map-support': 0.8.1 - acorn: 8.12.1 + acorn: 8.14.1 acorn-walk: 8.3.2 capnp-ts: 0.7.0 exit-hook: 2.2.1 @@ -30397,7 +30531,7 @@ packages: nopt: 5.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.6.3 + semver: 7.7.2 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -30458,7 +30592,7 @@ packages: dependencies: hosted-git-info: 6.1.1 is-core-module: 2.14.0 - semver: 7.6.3 + semver: 7.7.2 validate-npm-package-license: 3.0.4 dev: true @@ -31134,7 +31268,7 @@ packages: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) get-uri: 6.0.1 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 @@ -31150,7 +31284,7 @@ packages: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) get-uri: 6.0.1 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -32357,7 +32491,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 lru-cache: 7.18.3 @@ -32373,7 +32507,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -32435,7 +32569,7 @@ packages: dependencies: '@puppeteer/browsers': 2.4.0 chromium-bidi: 0.6.5(devtools-protocol@0.0.1342118) - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) devtools-protocol: 0.0.1342118 typed-query-selector: 2.12.0 ws: 8.18.0(bufferutil@4.0.9) @@ -32452,7 +32586,7 @@ packages: dependencies: '@puppeteer/browsers': 2.10.6 chromium-bidi: 7.2.0(devtools-protocol@0.0.1464554) - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) devtools-protocol: 0.0.1464554 typed-query-selector: 2.12.0 ws: 8.18.3 @@ -33546,7 +33680,7 @@ packages: remix-auth: ^3.6.0 dependencies: '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 remix-auth: 3.6.0(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0) transitivePeerDependencies: - supports-color @@ -33673,7 +33807,7 @@ packages: resolution: {integrity: sha512-OScOjQjrrjhAdFpQmnkE/qbIBGCRFhQB/YaJhcC3CPOlmhe7llnW46Ac1J5+EjcNXOTnDdpF96Erw/yedsGksQ==} engines: {node: '>=8.6.0'} dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -34131,7 +34265,7 @@ packages: resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} engines: {node: '>= 18'} dependencies: - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -34237,7 +34371,7 @@ packages: dependencies: color: 4.2.3 detect-libc: 2.0.3 - semver: 7.6.3 + semver: 7.7.2 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -34584,7 +34718,7 @@ packages: requiresBuild: true dependencies: agent-base: 6.0.2 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) socks: 2.8.3 transitivePeerDependencies: - supports-color @@ -34596,7 +34730,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) socks: 2.8.3 transitivePeerDependencies: - supports-color @@ -34607,7 +34741,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) socks: 2.8.3 transitivePeerDependencies: - supports-color @@ -35198,7 +35332,7 @@ packages: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 fast-safe-stringify: 2.1.1 form-data: 4.0.4 formidable: 3.5.1 @@ -35748,7 +35882,7 @@ packages: archiver: 7.0.1 async-lock: 1.4.1 byline: 5.0.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 docker-compose: 0.24.8 dockerode: 4.0.6 get-port: 7.1.0 @@ -36259,7 +36393,7 @@ packages: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 esbuild: 0.25.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -36904,7 +37038,7 @@ packages: /unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} dependencies: - acorn: 8.12.1 + acorn: 8.14.1 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 @@ -37288,7 +37422,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) mlly: 1.7.1 pathe: 1.1.2 picocolors: 1.1.1 @@ -37312,7 +37446,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 5.2.7(@types/node@20.14.14) @@ -37481,7 +37615,7 @@ packages: '@vitest/spy': 3.1.4 '@vitest/utils': 3.1.4 chai: 5.2.0 - debug: 4.4.0(supports-color@10.0.0) + debug: 4.4.0 expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 @@ -37588,7 +37722,7 @@ packages: hasBin: true dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.12.1 + acorn: 8.14.1 acorn-walk: 8.3.2 commander: 7.2.0 debounce: 1.2.1 diff --git a/references/d3-chat/package.json b/references/d3-chat/package.json index 36dc313f2b3..2d14a1d22ec 100644 --- a/references/d3-chat/package.json +++ b/references/d3-chat/package.json @@ -22,13 +22,18 @@ "@ai-sdk/anthropic": "^1.2.4", "@ai-sdk/openai": "1.3.3", "@e2b/code-interpreter": "^1.1.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/exporter-logs-otlp-http": "0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "0.203.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/sdk-logs": "^0.203.0", "@radix-ui/react-avatar": "^1.1.3", "@slack/web-api": "7.9.1", "@trigger.dev/python": "workspace:*", "@trigger.dev/react-hooks": "workspace:*", "@trigger.dev/sdk": "workspace:*", - "@opentelemetry/exporter-logs-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", + "@vercel/otel": "^1.13.0", "@vercel/postgres": "^0.10.0", "ai": "4.2.5", "class-variance-authority": "^0.7.1", diff --git a/references/d3-chat/src/app/api/demo-batch-trigger/route.ts b/references/d3-chat/src/app/api/demo-batch-trigger/route.ts new file mode 100644 index 00000000000..9342830c329 --- /dev/null +++ b/references/d3-chat/src/app/api/demo-batch-trigger/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { tasks } from "@trigger.dev/sdk"; +import type { todoChat } from "@/trigger/chat"; + +export async function POST(request: Request) { + const body = await request.json(); + + const handle = await tasks.batchTrigger("todo-chat", [ + { + payload: { + input: body.input, + userId: "123", + }, + }, + ]); + + return NextResponse.json({ handle }); +} diff --git a/references/d3-chat/src/app/api/demo-call-from-trigger/route.ts b/references/d3-chat/src/app/api/demo-call-from-trigger/route.ts new file mode 100644 index 00000000000..940c844d06d --- /dev/null +++ b/references/d3-chat/src/app/api/demo-call-from-trigger/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + return NextResponse.json({ body: "Hello, world!" }); +} diff --git a/references/d3-chat/src/app/api/demo-trigger/route.ts b/references/d3-chat/src/app/api/demo-trigger/route.ts new file mode 100644 index 00000000000..f97874f2620 --- /dev/null +++ b/references/d3-chat/src/app/api/demo-trigger/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { tasks } from "@trigger.dev/sdk"; +import type { todoChat } from "@/trigger/chat"; + +export async function POST(request: Request) { + const body = await request.json(); + + const handle = await tasks.trigger("todo-chat", { + input: body.input, + userId: "123", + }); + + return NextResponse.json({ handle }); +} diff --git a/references/d3-chat/src/instrumentation.ts b/references/d3-chat/src/instrumentation.ts new file mode 100644 index 00000000000..ad3e547447b --- /dev/null +++ b/references/d3-chat/src/instrumentation.ts @@ -0,0 +1,5 @@ +import { registerOTel } from "@vercel/otel"; + +export function register() { + registerOTel({ serviceName: "d3-chat" }); +} diff --git a/references/d3-chat/src/trigger/chat.ts b/references/d3-chat/src/trigger/chat.ts index 7ac4d77f671..9870cbc17dc 100644 --- a/references/d3-chat/src/trigger/chat.ts +++ b/references/d3-chat/src/trigger/chat.ts @@ -1,7 +1,7 @@ import { anthropic } from "@ai-sdk/anthropic"; import { openai } from "@ai-sdk/openai"; import { ai } from "@trigger.dev/sdk/ai"; -import { logger, metadata, runs, schemaTask, tasks, wait } from "@trigger.dev/sdk/v3"; +import { logger, metadata, runs, schemaTask, tasks, wait, otel } from "@trigger.dev/sdk/v3"; import { sql } from "@vercel/postgres"; import { CoreMessage, @@ -17,6 +17,25 @@ import { sendSQLApprovalMessage } from "../lib/slack"; import { crawler } from "./crawler"; import { chartTool } from "./sandbox"; import { QueryApproval } from "./schemas"; +import { context, propagation } from "@opentelemetry/api"; + +async function callNextjsApp() { + return await otel.withExternalTrace(async () => { + const headersObject = {}; + + propagation.inject(context.active(), headersObject); + + const result = await fetch("http://localhost:3000/api/demo-call-from-trigger", { + headers: new Headers(headersObject), + method: "POST", + body: JSON.stringify({ + message: "Hello from Trigger.dev", + }), + }); + + return result.json(); + }); +} const queryApprovalTask = schemaTask({ id: "query-approval", @@ -29,6 +48,8 @@ const queryApprovalTask = schemaTask({ run: async ({ userId, input, query }) => { logger.info("queryApproval: starting", { projectRef: process.env.TRIGGER_PROJECT_REF }); + await callNextjsApp(); + const token = await wait.createToken({ tags: [`user:${userId}`, "approval"], timeout: "5m", // timeout in 5 minutes @@ -129,6 +150,8 @@ export const todoChat = schemaTask({ run: async ({ input, userId }, { signal }) => { metadata.set("user_id", userId); + logger.info("todoChat: starting", { input, userId }); + const system = ` You are a SQL (postgres) expert who can turn natural language descriptions for a todo app into a SQL query which can then be executed against a SQL database. Here is the schema: From f82a4e9651152089c05abf42bb81ce64dab6f248 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:08:33 +0100 Subject: [PATCH 049/641] fix(playwright): improve chrome installation and fix spinner duplication on narrow terminals (#2347) * headless: false will now install binaries for headless mode too * fix spinner message duplication on narrow terminals * add changeset --- .changeset/silent-lobsters-march.md | 7 +++ packages/build/src/extensions/playwright.ts | 23 +++++++- packages/cli-v3/src/utilities/windows.ts | 63 ++++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 .changeset/silent-lobsters-march.md diff --git a/.changeset/silent-lobsters-march.md b/.changeset/silent-lobsters-march.md new file mode 100644 index 00000000000..a6ca8a3b5e3 --- /dev/null +++ b/.changeset/silent-lobsters-march.md @@ -0,0 +1,7 @@ +--- +"trigger.dev": patch +"@trigger.dev/build": patch +--- + +- Improve playwright non-headless chrome installation +- Prevent spinner message duplication in narrow terminals \ No newline at end of file diff --git a/packages/build/src/extensions/playwright.ts b/packages/build/src/extensions/playwright.ts index 0f675d85ed1..0931a4855c7 100644 --- a/packages/build/src/extensions/playwright.ts +++ b/packages/build/src/extensions/playwright.ts @@ -295,10 +295,29 @@ class PlaywrightExtension implements BuildExtension { * We save this output to a file and then parse it to get the download urls for the browsers. */ instructions.push(`RUN npx playwright install --dry-run > /tmp/browser-info.txt`); + + // Determine which browsers to actually install + const browsersToInstall = new Set(); + this.options.browsers.forEach((browser) => { - const browserType = browser === "chromium" ? "chromium-headless-shell" : browser; + if (browser === "chromium") { + if (this.options.headless) { + // For headless mode, only install chromium-headless-shell + browsersToInstall.add("chromium-headless-shell"); + } else { + // For non-headless mode, install both chromium and chromium-headless-shell + // This allows users to easily switch between headless and non-headless mode + browsersToInstall.add("chromium"); + browsersToInstall.add("chromium-headless-shell"); + } + } else { + browsersToInstall.add(browser); + } + }); + + Array.from(browsersToInstall).forEach((browser) => { instructions.push( - `RUN grep -A5 "browser: ${browserType}" /tmp/browser-info.txt > /tmp/${browser}-info.txt`, + `RUN grep -A5 -m1 "browser: ${browser}" /tmp/browser-info.txt > /tmp/${browser}-info.txt`, `RUN INSTALL_DIR=$(grep "Install location:" /tmp/${browser}-info.txt | cut -d':' -f2- | xargs) && \ DIR_NAME=$(basename "$INSTALL_DIR") && \ diff --git a/packages/cli-v3/src/utilities/windows.ts b/packages/cli-v3/src/utilities/windows.ts index 4e8a6ad7915..5b1f12125dd 100644 --- a/packages/cli-v3/src/utilities/windows.ts +++ b/packages/cli-v3/src/utilities/windows.ts @@ -7,6 +7,67 @@ export function escapeImportPath(path: string) { return isWindows ? path.replaceAll("\\", "\\\\") : path; } +// Removes ANSI escape sequences to get actual visible length +function getVisibleLength(str: string): number { + return ( + str + // Remove terminal hyperlinks: \u001b]8;;URL\u0007TEXT\u001b]8;;\u0007 + .replace(/\u001b]8;;[^\u0007]*\u0007/g, "") + // Remove standard ANSI escape sequences (colors, cursor movement, etc.) + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").length + ); +} + +function truncateMessage(msg: string, maxLength?: number): string { + const terminalWidth = maxLength ?? process.stdout.columns ?? 80; + const availableWidth = terminalWidth - 5; // Reserve some space for the spinner and padding + const visibleLength = getVisibleLength(msg); + + if (visibleLength <= availableWidth) { + return msg; + } + + // We need to truncate based on visible characters, but preserve ANSI sequences + // Simple approach: truncate character by character until we fit + let truncated = msg; + while (getVisibleLength(truncated) > availableWidth - 3) { + truncated = truncated.slice(0, -1); + } + + return truncated + "..."; +} + +const wrappedClackSpinner = () => { + let currentMessage = ""; + let isActive = false; + + const handleResize = () => { + if (isActive && currentMessage) { + spinner.message(truncateMessage(currentMessage)); + } + }; + + const spinner = clackSpinner(); + + return { + start: (msg?: string): void => { + currentMessage = msg ?? ""; + isActive = true; + process.stdout.on("resize", handleResize); + spinner.start(truncateMessage(currentMessage)); + }, + stop: (msg?: string, code?: number): void => { + isActive = false; + process.stdout.off("resize", handleResize); + spinner.stop(truncateMessage(msg ?? ""), code); + }, + message: (msg?: string): void => { + currentMessage = msg ?? ""; + spinner.message(truncateMessage(currentMessage)); + }, + }; +}; + const ballmerSpinner = () => ({ start: (msg?: string): void => { log.step(msg ?? ""); @@ -21,4 +82,4 @@ const ballmerSpinner = () => ({ // This will become unecessary with the next clack release, the bug was fixed here: // https://github.com/natemoo-re/clack/pull/182 -export const spinner = () => (isWindows ? ballmerSpinner() : clackSpinner()); +export const spinner = () => (isWindows ? ballmerSpinner() : wrappedClackSpinner()); From 1294076484c68c78d9e840d78ea1094fb8df3732 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 6 Aug 2025 13:44:56 +0100 Subject: [PATCH 050/641] feat: index json schemas on tasks and schemaTask (#2351) * Add payload schema handling for task indexing This change introduces support for handling payload schemas during task indexing. By incorporating the `payloadSchema` attribute into various components, we ensure that each task's payload structure is clearly defined and can be validated before processing. - Updated the TaskManifest and task metadata structures to include an optional `payloadSchema` attribute. This addition allows for more robust validation and handling of task payloads. - Enhanced several core modules to export and utilize the new `getSchemaToJsonSchema` function, providing easier conversion of schema types to JSON schemas. - Modified the database schema to store the `payloadSchema` attribute, ensuring that the payload schema information is persisted. - The change helps in maintaining consistency in data handling and improves the integrity of task data across the application. * Refactor: Remove getSchemaToJsonSchema in favor of schemaToJsonSchema The `getSchemaToJsonSchema` function was removed and replaced with `schemaToJsonSchema` across the codebase. This update introduces a new `@trigger.dev/schema-to-json` package to handle conversions of schema validation libraries to JSON Schema format, centralizing the functionality and improving maintainability. - Removed `getSchemaToJsonSchema` exports and references. - Added new schema conversion utility `@trigger.dev/schema-to-json`. - Updated `trigger-sdk` package to utilize `schemaToJsonSchema` for payloads. - Extensive testing coverage included to ensure conversion accuracy across various schema libraries including Zod, Yup, ArkType, Effect, and TypeBox. - The update ensures consistent and reliable schema conversions, facilitating future enhancements and supporting additional schema libraries. * Add support for Zod 4 in schema-to-json This change enhances the schema-to-json package by adding support for Zod version 4, which introduces the native `toJsonSchema` method. This method facilitates a direct conversion of Zod schemas to JSON Schema format, improving performance and reducing reliance on the `zod-to-json-schema` library. - Updated README to reflect Zod 4 support with native method and retained support for Zod 3 via existing library. - Modified package.json to allow installation of both Zod 3 and 4 versions. - Implemented handling for Zod 4 schemas in `src/index.ts` using their native method. - Added a test case to verify the proper conversion of Zod 4 schemas to JSON Schema. - Included a script for updating the package version based on the root package.json. - Introduced a specific TypeScript config for source files. * Revise schema-to-json for bundle safety and tests The package @trigger.dev/schema-to-json has been revised to ensure bundle safety by removing direct dependencies on schema libraries such as Zod, Yup, and Effect. This change minimizes bundle size and enhances tree-shaking by allowing external conversion libraries to be utilized only at runtime if necessary. As a result, the README was updated to reflect this usage pattern. - Introduced `initializeSchemaConverters` function to load necessary conversion libraries at runtime, keeping the base package slim. - Adjusted test suite to initialize converters before tests, ensuring accurate testing of schema conversion capabilities. - Updated `schemaToJsonSchema` function to dynamically check for availability of conversion libraries, improving flexibility without increasing the package size. - Added configuration files for Vitest to support the new testing framework, reflecting the transition from previous test setups. These enhancements ensure that only the schema libraries actively used in an application are bundled, optimizing performance and resource usage. * Refine JSON Schema typing across packages The changes introduce stricter typing for JSON Schema-related definitions, specifically replacing vague types with more precise ones, such as using `z.record(z.unknown())` instead of `z.any()` and `Record` in place of `any`. This is part of an effort to better align with common practices and improve type safety in the packages. - Updated the `payloadSchema` in several files to use `z.record(z.unknown())`, enhancing the type strictness and consistency with JSON Schema Draft 7 recommendations. - Added `@types/json-schema` as a dependency, utilizing its definitions for improved type clarity and adherence to best practices in TypeScript. - Modified various comments to explicitly mention JSON Schema Draft 7, ensuring developers are aware of the JSON Schema version being implemented. - These adjustments are informed by research into how popular libraries and tools handle JSON Schema typing, aiming to integrate best practices for improved maintainability and interoperability. * Add JSON Schema examples using various libraries The change introduces extensive examples of using JSON Schemas in the 'references/hello-world' project within the 'trigger.dev' repository. These examples utilize libraries like Zod, Yup, and TypeBox for JSON Schema conversion and validation. The new examples demonstrate different use cases, including automatic conversion with schemaTask, manual schema provision, and schema conversion at build time. We also updated the dependencies in 'package.json' to include the necessary libraries for schema conversion and validation. - Included examples of processing tasks with JSON Schema using libraries such as Zod, Yup, TypeBox, and ArkType. - Showcased schema conversion techniques and type-safe JSON Schema creation. - Updated 'package.json' to ensure all necessary dependencies for schema operations are available. - Created illustrative scripts that cover task management from user processing to complex schema implementations. * Refactor SDK to encapsulate schema-to-json package The previous implementation required users to directly import and initialize functions from the `@trigger.dev/schema-to-json` package, which was not the intended user experience. This change refactors the SDK so that all necessary functions and types from `@trigger.dev/schema-to-json` are encapsulated within the `@trigger.dev/*` packages. - The examples in `usage.ts` have been updated to clearly mark `@trigger.dev/schema-to-json` as an internal-only package. - Re-export JSON Schema types and conversions in the SDK to improve developer experience (DX). - Removed unnecessary direct dependencies on `@trigger.dev/schema-to-json` from user-facing code, ensuring initialization and conversion logic is handled internally. - Replaced instances where users were required to manually perform schema conversions with automatic handling within the SDK for simplification and better maintainability. * Add JSONSchema type for payloadSchema in tasks The change was necessary to improve type safety by using a proper JSONSchema type definition instead of a generic Record. This enhances the developer experience and ensures that task payloads conform to the JSON Schema Draft 7 specification. The JSONSchema type is now re-exported from the SDK for user convenience, hiding internal complexity and maintaining a seamless developer experience. - Added JSONSchema type based on Draft 7 specification - Updated task metadata and options to use JSONSchema type - Hid internal schema conversion logic from users by re-exporting types from SDK - Improved bundle safety and dependency management * Add JSON schema testing and revert package dependencies This commit introduces a comprehensive set of JSON schema testing within the monorepo, specifically adding a new test project in `references/json-schema-test`. This includes a variety of schema definitions and tasks utilizing multiple validation libraries to ensure robust type-checking and runtime validation. Additionally, the dependency versions for `@effect/schema` have been adjusted from `^0.76.5` to `^0.75.5` to maintain compatibility across the project components. This ensures consistent behavior and compatibility with existing code bases without introducing breaking changes or unexpected behavior due to version discrepancies. Key updates include: - Added new test project with extensive schema validation tests. - Ensured type safety across various task implementations. - Reverted dependency versions to ensure compatibility. - Created multiple schema tasks using libraries like Zod, Yup, and others for thorough testing. * Refactor JSON Schema test files for clarity Whitespace and formatting changes were applied across the `json-schema-test` reference project to enhance code readability and cohesion. This included removing unnecessary trailing spaces and ensuring consistent indentation patterns, which improves maintainability and readability by following the project's code style guidelines. - Renamed JSONSchema type annotations to adhere to TypeScript conventions, ensuring that all schema definitions properly satisfy the JSONSchema interface. - Restructured some object declarations for improved clarity, especially within complex schema definitions. - These adjustments are crucial for better future maintainability, reducing potential developer errors when interacting with these test schemas. * Fixed some stuff * WIP * we now convert schema to jsonSchema on the CLI side via the indexing * Remove the json-schema-test reference project * Improve schema-to-json peer deps and fix effect schema * Explain the casting and match the version numbers * Fixed a bunch more schema stuff * Don't clean files that might be written to * Don't use a custom version of vitest in the new package * fix attw in schema-to-json --- .../services/createBackgroundWorker.server.ts | 4 +- ...eateDeploymentBackgroundWorkerV3.server.ts | 5 +- ...eateDeploymentBackgroundWorkerV4.server.ts | 5 +- .../migration.sql | 2 + .../database/prisma/schema.prisma | 2 + packages/cli-v3/package.json | 1 + .../src/entryPoints/dev-index-worker.ts | 24 +- .../src/entryPoints/managed-index-worker.ts | 24 +- .../core/src/v3/resource-catalog/catalog.ts | 3 +- .../core/src/v3/resource-catalog/index.ts | 6 +- .../resource-catalog/noopResourceCatalog.ts | 6 +- .../standardResourceCatalog.ts | 17 +- packages/core/src/v3/schemas/resources.ts | 2 + packages/core/src/v3/schemas/schemas.ts | 1 + packages/core/src/v3/types/index.ts | 1 + packages/core/src/v3/types/jsonSchema.ts | 76 ++++ packages/core/src/v3/types/tasks.ts | 17 + packages/schema-to-json/.gitignore | 6 + packages/schema-to-json/README.md | 151 +++++++ packages/schema-to-json/package.json | 107 +++++ packages/schema-to-json/src/index.ts | 238 ++++++++++ packages/schema-to-json/tests/index.test.ts | 350 +++++++++++++++ packages/schema-to-json/tsconfig.json | 11 + packages/schema-to-json/tsconfig.src.json | 11 + packages/schema-to-json/tsconfig.test.json | 11 + packages/schema-to-json/vitest.config.ts | 8 + packages/trigger-sdk/package.json | 2 +- packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/schemas.ts | 2 + packages/trigger-sdk/src/v3/shared.ts | 26 +- pnpm-lock.yaml | 154 ++++++- references/hello-world/package.json | 5 +- references/hello-world/src/trigger/example.ts | 4 +- .../hello-world/src/trigger/jsonSchema.ts | 413 ++++++++++++++++++ .../hello-world/src/trigger/jsonSchemaApi.ts | 343 +++++++++++++++ .../src/trigger/jsonSchemaSimple.ts | 235 ++++++++++ 36 files changed, 2234 insertions(+), 40 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql create mode 100644 packages/core/src/v3/types/jsonSchema.ts create mode 100644 packages/schema-to-json/.gitignore create mode 100644 packages/schema-to-json/README.md create mode 100644 packages/schema-to-json/package.json create mode 100644 packages/schema-to-json/src/index.ts create mode 100644 packages/schema-to-json/tests/index.test.ts create mode 100644 packages/schema-to-json/tsconfig.json create mode 100644 packages/schema-to-json/tsconfig.src.json create mode 100644 packages/schema-to-json/tsconfig.test.json create mode 100644 packages/schema-to-json/vitest.config.ts create mode 100644 packages/trigger-sdk/src/v3/schemas.ts create mode 100644 references/hello-world/src/trigger/jsonSchema.ts create mode 100644 references/hello-world/src/trigger/jsonSchemaApi.ts create mode 100644 references/hello-world/src/trigger/jsonSchemaSimple.ts diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 5ca2d5d3871..ea43bbe4252 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -77,7 +77,8 @@ export class CreateBackgroundWorkerService extends BaseService { version: nextVersion, runtimeEnvironmentId: environment.id, projectId: project.id, - metadata: body.metadata, + // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it + metadata: body.metadata as Prisma.InputJsonValue, contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, @@ -280,6 +281,7 @@ async function createWorkerTask( fileId: tasksToBackgroundFiles?.get(task.id) ?? null, maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null, queueId: queue.id, + payloadSchema: task.payloadSchema as any, }, }); } catch (error) { diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts index 76be016528b..e093f2c2006 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts @@ -1,5 +1,5 @@ import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; -import type { BackgroundWorker } from "@trigger.dev/database"; +import type { BackgroundWorker, Prisma } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { socketIo } from "../handleSocketIo.server"; @@ -48,7 +48,8 @@ export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService { version: deployment.version, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - metadata: body.metadata, + // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it + metadata: body.metadata as Prisma.InputJsonValue, contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 2fb32966de8..cc73a8569d9 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -1,6 +1,6 @@ import { CreateBackgroundWorkerRequestBody, logger, tryCatch } from "@trigger.dev/core/v3"; import { BackgroundWorkerId } from "@trigger.dev/core/v3/isomorphic"; -import type { BackgroundWorker, WorkerDeployment } from "@trigger.dev/database"; +import type { BackgroundWorker, Prisma, WorkerDeployment } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { @@ -65,7 +65,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { version: deployment.version, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - metadata: body.metadata, + // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it + metadata: body.metadata as Prisma.InputJsonValue, contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, diff --git a/internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql b/internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql new file mode 100644 index 00000000000..bbbb6694fd0 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BackgroundWorkerTask" ADD COLUMN "payloadSchema" JSONB; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 211ff2b355f..ce12dcf7c78 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -510,6 +510,8 @@ model BackgroundWorkerTask { triggerSource TaskTriggerSource @default(STANDARD) + payloadSchema Json? + @@unique([workerId, slug]) // Quick lookup of task identifiers @@index([projectId, slug]) diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 24c68670aa3..74e935590e1 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -91,6 +91,7 @@ "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/build": "workspace:4.0.0-v4-beta.26", "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", + "@trigger.dev/schema-to-json": "workspace:4.0.0-v4-beta.26", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index d44ac53fe59..7b40ecf9d46 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -18,6 +18,7 @@ import { registerResources } from "../indexing/registerResources.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; +import { schemaToJsonSchema, initializeSchemaConverters } from "@trigger.dev/schema-to-json"; sourceMapSupport.install({ handleUncaughtExceptions: false, @@ -100,7 +101,7 @@ async function bootstrap() { const { buildManifest, importErrors, config, timings } = await bootstrap(); -let tasks = resourceCatalog.listTaskManifests(); +let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings if (config.retries?.default) { @@ -190,3 +191,24 @@ await new Promise((resolve) => { resolve(); }, 10); }); + +async function convertSchemasToJsonSchemas(tasks: TaskManifest[]): Promise { + await initializeSchemaConverters(); + + const convertedTasks = tasks.map((task) => { + const schema = resourceCatalog.getTaskSchema(task.id); + + if (schema) { + try { + const result = schemaToJsonSchema(schema); + return { ...task, payloadSchema: result?.jsonSchema }; + } catch { + return task; + } + } + + return task; + }); + + return convertedTasks; +} diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index 845ece47afe..03c4ff4b146 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -18,6 +18,7 @@ import { registerResources } from "../indexing/registerResources.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; +import { schemaToJsonSchema, initializeSchemaConverters } from "@trigger.dev/schema-to-json"; sourceMapSupport.install({ handleUncaughtExceptions: false, @@ -100,7 +101,7 @@ async function bootstrap() { const { buildManifest, importErrors, config, timings } = await bootstrap(); -let tasks = resourceCatalog.listTaskManifests(); +let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings if (config.retries?.default) { @@ -196,3 +197,24 @@ await new Promise((resolve) => { resolve(); }, 10); }); + +async function convertSchemasToJsonSchemas(tasks: TaskManifest[]): Promise { + await initializeSchemaConverters(); + + const convertedTasks = tasks.map((task) => { + const schema = resourceCatalog.getTaskSchema(task.id); + + if (schema) { + try { + const result = schemaToJsonSchema(schema); + return { ...task, payloadSchema: result?.jsonSchema }; + } catch { + return task; + } + } + + return task; + }); + + return convertedTasks; +} diff --git a/packages/core/src/v3/resource-catalog/catalog.ts b/packages/core/src/v3/resource-catalog/catalog.ts index 725899c0d13..9ad14dd8484 100644 --- a/packages/core/src/v3/resource-catalog/catalog.ts +++ b/packages/core/src/v3/resource-catalog/catalog.ts @@ -1,5 +1,5 @@ import { QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; export interface ResourceCatalog { setCurrentFileContext(filePath: string, entryPoint: string): void; @@ -13,4 +13,5 @@ export interface ResourceCatalog { registerWorkerManifest(workerManifest: WorkerManifest): void; registerQueueMetadata(queue: QueueManifest): void; listQueueManifests(): Array; + getTaskSchema(id: string): TaskSchema | undefined; } diff --git a/packages/core/src/v3/resource-catalog/index.ts b/packages/core/src/v3/resource-catalog/index.ts index 6773f1b6217..a564648fcc3 100644 --- a/packages/core/src/v3/resource-catalog/index.ts +++ b/packages/core/src/v3/resource-catalog/index.ts @@ -1,7 +1,7 @@ const API_NAME = "resource-catalog"; import { QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; import { type ResourceCatalog } from "./catalog.js"; import { NoopResourceCatalog } from "./noopResourceCatalog.js"; @@ -65,6 +65,10 @@ export class ResourceCatalogAPI { return this.#getCatalog().getTask(id); } + public getTaskSchema(id: string): TaskSchema | undefined { + return this.#getCatalog().getTaskSchema(id); + } + public taskExists(id: string): boolean { return this.#getCatalog().taskExists(id); } diff --git a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts index b0e0f73056b..53a953393aa 100644 --- a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts @@ -1,5 +1,5 @@ import { QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { ResourceCatalog } from "./catalog.js"; export class NoopResourceCatalog implements ResourceCatalog { @@ -31,6 +31,10 @@ export class NoopResourceCatalog implements ResourceCatalog { return undefined; } + getTaskSchema(id: string): TaskSchema | undefined { + return undefined; + } + taskExists(id: string): boolean { return false; } diff --git a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts index 7468b63d801..3b8eaa7d67d 100644 --- a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts @@ -5,10 +5,11 @@ import { WorkerManifest, QueueManifest, } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { ResourceCatalog } from "./catalog.js"; export class StandardResourceCatalog implements ResourceCatalog { + private _taskSchemas: Map = new Map(); private _taskMetadata: Map = new Map(); private _taskFunctions: Map = new Map(); private _taskFileMetadata: Map = new Map(); @@ -72,6 +73,10 @@ export class StandardResourceCatalog implements ResourceCatalog { this._taskMetadata.set(task.id, metadata); this._taskFunctions.set(task.id, fns); + + if (task.schema) { + this._taskSchemas.set(task.id, task.schema); + } } updateTaskMetadata(id: string, updates: Partial): void { @@ -107,15 +112,21 @@ export class StandardResourceCatalog implements ResourceCatalog { continue; } - result.push({ + const taskManifest = { ...metadata, ...fileMetadata, - }); + }; + + result.push(taskManifest); } return result; } + getTaskSchema(id: string): TaskSchema | undefined { + return this._taskSchemas.get(id); + } + listQueueManifests(): Array { return Array.from(this._queueMetadata.values()); } diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts index ec2b180bbcd..08764906ede 100644 --- a/packages/core/src/v3/schemas/resources.ts +++ b/packages/core/src/v3/schemas/resources.ts @@ -13,6 +13,8 @@ export const TaskResource = z.object({ triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), + // JSONSchema type - using z.unknown() for runtime validation to accept JSONSchema7 + payloadSchema: z.unknown().optional(), }); export type TaskResource = z.infer; diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index ccd0fa18809..233068c0b7b 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -189,6 +189,7 @@ const taskMetadata = { triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), + payloadSchema: z.unknown().optional(), }; export const TaskMetadata = z.object(taskMetadata); diff --git a/packages/core/src/v3/types/index.ts b/packages/core/src/v3/types/index.ts index 55cc4d3a122..ea2bc8d5580 100644 --- a/packages/core/src/v3/types/index.ts +++ b/packages/core/src/v3/types/index.ts @@ -7,6 +7,7 @@ export * from "./tasks.js"; export * from "./idempotencyKeys.js"; export * from "./tools.js"; export * from "./queues.js"; +export * from "./jsonSchema.js"; type ResolveEnvironmentVariablesOptions = { variables: Record | Array<{ name: string; value: string }>; diff --git a/packages/core/src/v3/types/jsonSchema.ts b/packages/core/src/v3/types/jsonSchema.ts new file mode 100644 index 00000000000..7abf241a662 --- /dev/null +++ b/packages/core/src/v3/types/jsonSchema.ts @@ -0,0 +1,76 @@ +/** + * JSON Schema type definition - compatible with JSON Schema Draft 7 + * Based on the JSONSchema7 type from @types/json-schema but defined inline to avoid import issues + */ +export interface JSONSchema { + $id?: string; + $ref?: string; + $schema?: string; + $comment?: string; + + type?: JSONSchemaType | JSONSchemaType[]; + enum?: any[]; + const?: any; + + // Number/Integer validations + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: number; + minimum?: number; + exclusiveMinimum?: number; + + // String validations + maxLength?: number; + minLength?: number; + pattern?: string; + format?: string; + + // Array validations + items?: JSONSchema | JSONSchema[]; + additionalItems?: JSONSchema | boolean; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + contains?: JSONSchema; + + // Object validations + maxProperties?: number; + minProperties?: number; + required?: string[]; + properties?: Record; + patternProperties?: Record; + additionalProperties?: JSONSchema | boolean; + dependencies?: Record; + propertyNames?: JSONSchema; + + // Conditional schemas + if?: JSONSchema; + then?: JSONSchema; + else?: JSONSchema; + + // Boolean logic + allOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + not?: JSONSchema; + + // Metadata + title?: string; + description?: string; + default?: any; + readOnly?: boolean; + writeOnly?: boolean; + examples?: any[]; + + // Additional properties for extensibility + [key: string]: any; +} + +export type JSONSchemaType = + | "string" + | "number" + | "integer" + | "boolean" + | "object" + | "array" + | "null"; diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index f9595b51e67..66c9d98d5f3 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -28,6 +28,7 @@ import { QueueOptions } from "./queues.js"; import { AnySchemaParseFn, inferSchemaIn, inferSchemaOut, Schema } from "./schemas.js"; import { inferToolParameters, ToolTaskParameters } from "./tools.js"; import { Prettify } from "./utils.js"; +import { JSONSchema } from "./jsonSchema.js"; export type Queue = QueueOptions; export type TaskSchema = Schema; @@ -339,6 +340,12 @@ type CommonTaskOptions< * onFailure is called after a task run has failed (meaning the run function threw an error and won't be retried anymore) */ onFailure?: OnFailureHookFunction; + + /** + * JSON Schema for the task payload. This will be synced to the server during indexing. + * Should be a valid JSON Schema Draft 7 object. + */ + jsonSchema?: JSONSchema; }; export type TaskOptions< @@ -348,6 +355,15 @@ export type TaskOptions< TInitOutput extends InitOutput = any, > = CommonTaskOptions; +// Task options when payloadSchema is provided - payload should be any +export type TaskOptionsWithSchema< + TIdentifier extends string, + TOutput = unknown, + TInitOutput extends InitOutput = any, +> = CommonTaskOptions & { + jsonSchema: JSONSchema; +}; + export type TaskWithSchemaOptions< TIdentifier extends string, TSchema extends TaskSchema | undefined = undefined, @@ -881,6 +897,7 @@ export type TaskMetadataWithFunctions = TaskMetadata & { onStart?: (payload: any, params: StartFnParams) => Promise; parsePayload?: AnySchemaParseFn; }; + schema?: TaskSchema; }; export type RunTypes = { diff --git a/packages/schema-to-json/.gitignore b/packages/schema-to-json/.gitignore new file mode 100644 index 00000000000..c887152393c --- /dev/null +++ b/packages/schema-to-json/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.tshy +.tshy-build +*.log +.DS_Store \ No newline at end of file diff --git a/packages/schema-to-json/README.md b/packages/schema-to-json/README.md new file mode 100644 index 00000000000..9576b6e8007 --- /dev/null +++ b/packages/schema-to-json/README.md @@ -0,0 +1,151 @@ +# @trigger.dev/schema-to-json + +Convert various schema validation libraries to JSON Schema format. + +## Installation + +```bash +npm install @trigger.dev/schema-to-json +``` + +## Important: Bundle Safety + +This package is designed to be **bundle-safe**. It does NOT bundle any schema libraries (zod, yup, etc.) as dependencies. Instead: + +1. **Built-in conversions** work immediately (ArkType, Zod 4, TypeBox) +2. **External conversions** (Zod 3, Yup, Effect) require the conversion libraries to be available at runtime + +This design ensures that: +- ✅ Your bundle size stays small +- ✅ You only include the schema libraries you actually use +- ✅ Tree-shaking works properly +- ✅ No unnecessary dependencies are installed + +## Supported Schema Libraries + +- ✅ **Zod** - Full support + - Zod 4: Native support via built-in `toJsonSchema` method (no external deps needed) + - Zod 3: Requires `zod-to-json-schema` to be installed +- ✅ **Yup** - Requires `@sodaru/yup-to-json-schema` to be installed +- ✅ **ArkType** - Native support (built-in `toJsonSchema` method) +- ✅ **Effect/Schema** - Requires `effect` or `@effect/schema` to be installed +- ✅ **TypeBox** - Native support (already JSON Schema compliant) +- ⏳ **Valibot** - Coming soon +- ⏳ **Superstruct** - Coming soon +- ⏳ **Runtypes** - Coming soon + +## Usage + +### Basic Usage (Built-in conversions only) + +```typescript +import { schemaToJsonSchema } from '@trigger.dev/schema-to-json'; +import { type } from 'arktype'; + +// Works immediately for schemas with built-in conversion +const arkSchema = type({ + name: 'string', + age: 'number', +}); + +const result = schemaToJsonSchema(arkSchema); +console.log(result); +// { jsonSchema: {...}, schemaType: 'arktype' } +``` + +### Full Usage (With external conversion libraries) + +```typescript +import { schemaToJsonSchema, initializeSchemaConverters } from '@trigger.dev/schema-to-json'; +import { z } from 'zod'; + +// Initialize converters once in your app (loads conversion libraries if available) +await initializeSchemaConverters(); + +// Now you can convert Zod 3, Yup, and Effect schemas +const zodSchema = z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), +}); + +const result = schemaToJsonSchema(zodSchema); +console.log(result); +// { +// jsonSchema: { +// type: 'object', +// properties: { +// name: { type: 'string' }, +// age: { type: 'number' }, +// email: { type: 'string', format: 'email' } +// }, +// required: ['name', 'age', 'email'] +// }, +// schemaType: 'zod' +// } +``` + +## API + +### `schemaToJsonSchema(schema, options?)` + +Convert a schema to JSON Schema format. + +**Parameters:** +- `schema` - The schema to convert +- `options` (optional) + - `name` - Name to use for the schema (supported by some converters) + - `additionalProperties` - Additional properties to merge into the result + +**Returns:** +- `{ jsonSchema, schemaType }` - The converted JSON Schema and detected type +- `undefined` - If the schema cannot be converted + +### `initializeSchemaConverters()` + +Initialize the external conversion libraries. Call this once in your application if you need to convert schemas that don't have built-in JSON Schema support (Zod 3, Yup, Effect). + +**Returns:** `Promise` + +### `canConvertSchema(schema)` + +Check if a schema can be converted to JSON Schema. + +**Returns:** `boolean` + +### `detectSchemaType(schema)` + +Detect the type of schema. + +**Returns:** `'zod' | 'yup' | 'arktype' | 'effect' | 'valibot' | 'superstruct' | 'runtypes' | 'typebox' | 'unknown'` + +### `areConvertersInitialized()` + +Check which conversion libraries are available. + +**Returns:** `{ zod: boolean, yup: boolean, effect: boolean }` + +## Peer Dependencies + +Each schema library is an optional peer dependency. Install only the ones you need: + +```bash +# For Zod +npm install zod + +# For Yup +npm install yup + +# For ArkType +npm install arktype + +# For Effect +npm install effect @effect/schema + +# For TypeBox +npm install @sinclair/typebox +``` + +## License + +MIT \ No newline at end of file diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json new file mode 100644 index 00000000000..d095e6f220f --- /dev/null +++ b/packages/schema-to-json/package.json @@ -0,0 +1,107 @@ +{ + "name": "@trigger.dev/schema-to-json", + "version": "4.0.0-v4-beta.26", + "description": "Convert various schema validation libraries to JSON Schema", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/schema-to-json" + }, + "type": "module", + "engines": { + "node": ">=18.20.0" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, + "scripts": { + "clean": "rimraf dist", + "build": "pnpm run clean && pnpm run build:tshy && pnpm run update-version", + "build:tshy": "tshy", + "dev": "tshy --watch", + "typecheck": "tsc -p tsconfig.src.json --noEmit", + "test": "vitest", + "update-version": "tsx ../../scripts/updateVersion.ts", + "check-exports": "attw --pack ." + }, + "dependencies": { + "@trigger.dev/core": "workspace:*", + "zod-to-json-schema": "^3.24.5", + "@sodaru/yup-to-json-schema": "^2.0.1" + }, + "devDependencies": { + "arktype": "^2.0.0", + "effect": "^3.11.11", + "runtypes": "^6.7.0", + "superstruct": "^2.0.2", + "tshy": "^3.0.2", + "@sinclair/typebox": "^0.34.3", + "valibot": "^1.1.0", + "yup": "^1.7.0", + "zod": "^3.24.1 || ^4.0.0", + "rimraf": "6.0.1", + "@arethetypeswrong/cli": "^0.15.4" + }, + "peerDependencies": { + "arktype": ">=2.0.0", + "effect": ">=3.0.0", + "runtypes": ">=5.0.0", + "superstruct": ">=0.14.2", + "@sinclair/typebox": ">=0.34.30", + "valibot": ">=0.41.0", + "yup": ">=1.0.0", + "zod": "^3.24.1 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "runtypes": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "tshy": { + "selfLink": false, + "exports": { + ".": "./src/index.ts" + }, + "project": "./tsconfig.src.json" + }, + "main": "./dist/commonjs/index.js", + "types": "./dist/commonjs/index.d.ts", + "module": "./dist/esm/index.js" +} diff --git a/packages/schema-to-json/src/index.ts b/packages/schema-to-json/src/index.ts new file mode 100644 index 00000000000..604ec8d78c6 --- /dev/null +++ b/packages/schema-to-json/src/index.ts @@ -0,0 +1,238 @@ +// Import JSONSchema from core to ensure compatibility +import type { JSONSchema } from "@trigger.dev/core/v3"; + +export type Schema = unknown; +export type { JSONSchema }; + +export interface ConversionOptions { + /** + * The name to use for the schema in the JSON Schema + */ + name?: string; + /** + * Additional JSON Schema properties to merge + */ + additionalProperties?: Record; +} + +export interface ConversionResult { + /** + * The JSON Schema representation (JSON Schema Draft 7) + */ + jsonSchema: JSONSchema; + /** + * The detected schema type + */ + schemaType: + | "zod" + | "yup" + | "arktype" + | "effect" + | "valibot" + | "superstruct" + | "runtypes" + | "typebox" + | "unknown"; +} + +/** + * Convert a schema from various validation libraries to JSON Schema + * + * This function attempts to convert schemas without requiring external dependencies to be bundled. + * It will only succeed if: + * 1. The schema has built-in JSON Schema conversion (ArkType, Zod 4, TypeBox) + * 2. The required conversion library is available at runtime (zod-to-json-schema, @sodaru/yup-to-json-schema, etc.) + * + * @param schema The schema to convert + * @param options Optional conversion options + * @returns The conversion result or undefined if conversion is not possible + */ +export function schemaToJsonSchema( + schema: Schema, + options?: ConversionOptions +): ConversionResult | undefined { + const parser = schema as any; + + // Check if schema has a built-in toJsonSchema method (e.g., ArkType, Zod 4) + if (typeof parser.toJsonSchema === "function") { + try { + const jsonSchema = parser.toJsonSchema(); + // Determine if it's Zod or ArkType based on other methods + const schemaType = + typeof parser.parseAsync === "function" || typeof parser.parse === "function" + ? "zod" + : "arktype"; + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType, + }; + } catch (error) { + // If toJsonSchema fails, continue to other checks + } + } + + // Check if it's a TypeBox schema (has Static and Kind symbols) + if (parser[Symbol.for("TypeBox.Kind")] !== undefined) { + // TypeBox schemas are already JSON Schema compliant + return { + jsonSchema: options?.additionalProperties + ? { ...parser, ...options.additionalProperties } + : parser, + schemaType: "typebox", + }; + } + + // For schemas that need external libraries, we need to check if they're available + // This approach avoids bundling the dependencies while still allowing runtime usage + + // Check if it's a Zod schema (without built-in toJsonSchema) + if (typeof parser.parseAsync === "function" || typeof parser.parse === "function") { + try { + // Try to access zod-to-json-schema if it's available + // @ts-ignore - This is intentionally dynamic + if (typeof globalThis.__zodToJsonSchema !== "undefined") { + // @ts-ignore + const { zodToJsonSchema } = globalThis.__zodToJsonSchema; + const jsonSchema = options?.name + ? zodToJsonSchema(parser, options.name) + : zodToJsonSchema(parser); + + if (jsonSchema && typeof jsonSchema === "object" && "$schema" in jsonSchema) { + const { $schema, ...rest } = jsonSchema as any; + return { + jsonSchema: options?.additionalProperties + ? { ...rest, ...options.additionalProperties } + : rest, + schemaType: "zod", + }; + } + + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType: "zod", + }; + } + } catch (error) { + // Library not available + } + } + + // Check if it's a Yup schema + if (typeof parser.validateSync === "function" && typeof parser.describe === "function") { + try { + // @ts-ignore + if (typeof globalThis.__yupToJsonSchema !== "undefined") { + // @ts-ignore + const { convertSchema } = globalThis.__yupToJsonSchema; + const jsonSchema = convertSchema(parser); + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType: "yup", + }; + } + } catch (error) { + // Library not available + } + } + + // Check if it's an Effect schema + if (typeof parser.ast === "object" && typeof parser.ast._tag === "string") { + try { + // @ts-ignore + if (typeof globalThis.__effectJsonSchema !== "undefined") { + // @ts-ignore + const { JSONSchema } = globalThis.__effectJsonSchema; + const jsonSchema = JSONSchema.make(parser); + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType: "effect", + }; + } + } catch (error) { + // Library not available + } + } + + // Future schema types can be added here... + + // Unknown schema type + return undefined; +} + +/** + * Initialize the schema conversion libraries + * This should be called by the consuming application if they want to enable + * conversion for schemas that don't have built-in JSON Schema support + */ +export async function initializeSchemaConverters(): Promise { + try { + // @ts-ignore + globalThis.__zodToJsonSchema = await import("zod-to-json-schema"); + } catch { + // Zod conversion not available + } + + try { + // @ts-ignore + globalThis.__yupToJsonSchema = await import("@sodaru/yup-to-json-schema"); + } catch { + // Yup conversion not available + } + + try { + // Try Effect first, then @effect/schema + let module; + try { + module = await import("effect"); + } catch {} + + if (module?.JSONSchema) { + // @ts-ignore + globalThis.__effectJsonSchema = { JSONSchema: module.JSONSchema }; + } + } catch { + // Effect conversion not available + } +} + +/** + * Check if a schema can be converted to JSON Schema + */ +export function canConvertSchema(schema: Schema): boolean { + const result = schemaToJsonSchema(schema); + return result !== undefined; +} + +/** + * Get the detected schema type + */ +export function detectSchemaType(schema: Schema): ConversionResult["schemaType"] { + const result = schemaToJsonSchema(schema); + return result?.schemaType ?? "unknown"; +} + +/** + * Check if the conversion libraries are initialized + */ +export function areConvertersInitialized(): { + zod: boolean; + yup: boolean; + effect: boolean; +} { + return { + // @ts-ignore + zod: typeof globalThis.__zodToJsonSchema !== "undefined", + // @ts-ignore + yup: typeof globalThis.__yupToJsonSchema !== "undefined", + // @ts-ignore + effect: typeof globalThis.__effectJsonSchema !== "undefined", + }; +} diff --git a/packages/schema-to-json/tests/index.test.ts b/packages/schema-to-json/tests/index.test.ts new file mode 100644 index 00000000000..e64ddc96a50 --- /dev/null +++ b/packages/schema-to-json/tests/index.test.ts @@ -0,0 +1,350 @@ +import { z } from "zod"; +import * as y from "yup"; +// @ts-ignore +import { type } from "arktype"; +import { Schema } from "effect"; +import { Type } from "@sinclair/typebox"; +import { + schemaToJsonSchema, + canConvertSchema, + detectSchemaType, + initializeSchemaConverters, + areConvertersInitialized, +} from "../src/index.js"; + +// Initialize converters before running tests +beforeAll(async () => { + await initializeSchemaConverters(); +}); + +describe("schemaToJsonSchema", () => { + describe("Initialization", () => { + it("should have converters initialized", () => { + const status = areConvertersInitialized(); + expect(status.zod).toBe(true); + expect(status.yup).toBe(true); + expect(status.effect).toBe(true); + }); + }); + + describe("Zod schemas", () => { + it("should convert a simple Zod object schema", () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("zod"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string", format: "email" }, + }, + required: ["name", "age", "email"], + }); + }); + + it("should convert a Zod schema with optional fields", () => { + const schema = z.object({ + id: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["id"], + }); + }); + + it("should handle Zod schema with name option", () => { + const schema = z.object({ + value: z.number(), + }); + + const result = schemaToJsonSchema(schema, { name: "MySchema" }); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toBeDefined(); + // The exact structure depends on zod-to-json-schema implementation + }); + + it("should handle Zod 4 schema with built-in toJsonSchema method", () => { + // Mock a Zod 4 schema with toJsonSchema method + const mockZod4Schema = { + parse: (val: unknown) => val, + parseAsync: async (val: unknown) => val, + toJsonSchema: () => ({ + type: "object", + properties: { + id: { type: "string" }, + count: { type: "number" }, + }, + required: ["id", "count"], + }), + }; + + const result = schemaToJsonSchema(mockZod4Schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("zod"); + expect(result?.jsonSchema).toEqual({ + type: "object", + properties: { + id: { type: "string" }, + count: { type: "number" }, + }, + required: ["id", "count"], + }); + }); + }); + + describe("Yup schemas", () => { + it("should convert a simple Yup object schema", () => { + const schema = y.object({ + name: y.string().required(), + age: y.number().required(), + email: y.string().email().required(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("yup"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string", format: "email" }, + }, + required: ["name", "age", "email"], + }); + }); + + it("should convert a Yup schema with optional fields", () => { + const schema = y.object({ + id: y.string().required(), + description: y.string(), + count: y.number().min(0).max(100), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string" }, + count: { type: "number", minimum: 0, maximum: 100 }, + }, + required: ["id"], + }); + }); + }); + + describe("ArkType schemas", () => { + it("should convert a simple ArkType schema", () => { + const schema = type({ + name: "string", + age: "number", + active: "boolean", + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("arktype"); + expect(result?.jsonSchema).toBeDefined(); + expect(result?.jsonSchema.type).toBe("object"); + }); + + it("should convert an ArkType schema with optional fields", () => { + const schema = type({ + id: "string", + "description?": "string", + "tags?": "string[]", + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toBeDefined(); + expect(result?.jsonSchema.type).toBe("object"); + }); + }); + + describe("Effect schemas", () => { + it("should convert a simple Effect schema", () => { + const schema = Schema.Struct({ + name: Schema.String, + age: Schema.Number, + active: Schema.Boolean, + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("effect"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + active: { type: "boolean" }, + }, + required: ["name", "age", "active"], + }); + }); + + it("should convert an Effect schema with optional fields", () => { + const schema = Schema.Struct({ + id: Schema.String, + description: Schema.optional(Schema.String), + count: Schema.optional(Schema.Number), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toBeDefined(); + expect(result?.jsonSchema.type).toBe("object"); + }); + }); + + describe("TypeBox schemas", () => { + it("should convert a simple TypeBox schema", () => { + const schema = Type.Object({ + name: Type.String(), + age: Type.Number(), + active: Type.Boolean(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("typebox"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + active: { type: "boolean" }, + }, + required: ["name", "age", "active"], + }); + }); + + it("should convert a TypeBox schema with optional fields", () => { + const schema = Type.Object({ + id: Type.String(), + description: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["id"], + }); + }); + }); + + describe("Additional options", () => { + it("should merge additional properties", () => { + const schema = z.object({ + value: z.number(), + }); + + const result = schemaToJsonSchema(schema, { + additionalProperties: { + title: "My Schema", + description: "A test schema", + "x-custom": "custom value", + }, + }); + + expect(result).toBeDefined(); + expect(result?.jsonSchema.title).toBe("My Schema"); + expect(result?.jsonSchema.description).toBe("A test schema"); + expect(result?.jsonSchema["x-custom"]).toBe("custom value"); + }); + }); + + describe("Unsupported schemas", () => { + it("should return undefined for unsupported schema types", () => { + const invalidSchema = { notASchema: true }; + const result = schemaToJsonSchema(invalidSchema); + expect(result).toBeUndefined(); + }); + + it("should return undefined for plain functions", () => { + const fn = (value: unknown) => typeof value === "string"; + const result = schemaToJsonSchema(fn); + expect(result).toBeUndefined(); + }); + }); +}); + +describe("canConvertSchema", () => { + it("should return true for supported schemas", () => { + expect(canConvertSchema(z.string())).toBe(true); + expect(canConvertSchema(y.string())).toBe(true); + expect(canConvertSchema(type("string"))).toBe(true); + expect(canConvertSchema(Schema.String)).toBe(true); + expect(canConvertSchema(Type.String())).toBe(true); + }); + + it("should return false for unsupported schemas", () => { + expect(canConvertSchema({ notASchema: true })).toBe(false); + expect(canConvertSchema(() => true)).toBe(false); + }); +}); + +describe("detectSchemaType", () => { + it("should detect Zod schemas", () => { + expect(detectSchemaType(z.string())).toBe("zod"); + }); + + it("should detect Yup schemas", () => { + expect(detectSchemaType(y.string())).toBe("yup"); + }); + + it("should detect ArkType schemas", () => { + expect(detectSchemaType(type("string"))).toBe("arktype"); + }); + + it("should detect Effect schemas", () => { + expect(detectSchemaType(Schema.String)).toBe("effect"); + }); + + it("should detect TypeBox schemas", () => { + expect(detectSchemaType(Type.String())).toBe("typebox"); + }); + + it("should return unknown for unsupported schemas", () => { + expect(detectSchemaType({ notASchema: true })).toBe("unknown"); + }); +}); diff --git a/packages/schema-to-json/tsconfig.json b/packages/schema-to-json/tsconfig.json new file mode 100644 index 00000000000..5bf5eba8d50 --- /dev/null +++ b/packages/schema-to-json/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "references": [ + { + "path": "./tsconfig.src.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} \ No newline at end of file diff --git a/packages/schema-to-json/tsconfig.src.json b/packages/schema-to-json/tsconfig.src.json new file mode 100644 index 00000000000..93f59a20f5c --- /dev/null +++ b/packages/schema-to-json/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["src/**/*.test.ts"], + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/packages/schema-to-json/tsconfig.test.json b/packages/schema-to-json/tsconfig.test.json new file mode 100644 index 00000000000..c68227ee396 --- /dev/null +++ b/packages/schema-to-json/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": ["./tests/**/*.ts"], + "references": [{ "path": "./tsconfig.src.json" }], + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "types": ["vitest/globals"] + } +} diff --git a/packages/schema-to-json/vitest.config.ts b/packages/schema-to-json/vitest.config.ts new file mode 100644 index 00000000000..c7da6b38e14 --- /dev/null +++ b/packages/schema-to-json/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); \ No newline at end of file diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index d070d2049c5..fe4c7d5023d 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -77,7 +77,7 @@ "zod": "3.23.8" }, "peerDependencies": { - "zod": "^3.0.0", + "zod": "^3.0.0 || ^4.0.0", "ai": "^4.2.0" }, "peerDependenciesMeta": { diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 94e3e23cd03..a9a833fe52e 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -14,6 +14,7 @@ export * from "./timeout.js"; export * from "./webhooks.js"; export * from "./locals.js"; export * from "./otel.js"; +export * from "./schemas.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/schemas.ts b/packages/trigger-sdk/src/v3/schemas.ts new file mode 100644 index 00000000000..65be53024e5 --- /dev/null +++ b/packages/trigger-sdk/src/v3/schemas.ts @@ -0,0 +1,2 @@ +// Re-export JSON Schema types for user convenience +export type { JSONSchema } from "@trigger.dev/core/v3"; \ No newline at end of file diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 487c16308e8..05300dc4f97 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -76,6 +76,7 @@ import type { TaskBatchOutputHandle, TaskIdentifier, TaskOptions, + TaskOptionsWithSchema, TaskOutput, TaskOutputHandle, TaskPayload, @@ -128,6 +129,16 @@ export function queue(options: QueueOptions): Queue { return options; } +// Overload: when payloadSchema is provided, payload type should be any +export function createTask< + TIdentifier extends string, + TOutput = unknown, + TInitOutput extends InitOutput = any, +>( + params: TaskOptionsWithSchema +): Task; + +// Overload: normal case without payloadSchema export function createTask< TIdentifier extends string, TInput = void, @@ -135,7 +146,18 @@ export function createTask< TInitOutput extends InitOutput = any, >( params: TaskOptions -): Task { +): Task; + +export function createTask< + TIdentifier extends string, + TInput = void, + TOutput = unknown, + TInitOutput extends InitOutput = any, +>( + params: + | TaskOptions + | TaskOptionsWithSchema +): Task | Task { const task: Task = { id: params.id, description: params.description, @@ -204,6 +226,7 @@ export function createTask< retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined, machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine, maxDuration: params.maxDuration, + payloadSchema: params.jsonSchema, fns: { run: params.run, }, @@ -338,6 +361,7 @@ export function createSchemaTask< run: params.run, parsePayload, }, + schema: params.schema, }); const queue = params.queue; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c91127b613..c2d7021ef0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1302,6 +1302,9 @@ importers: '@trigger.dev/core': specifier: workspace:4.0.0-v4-beta.26 version: link:../core + '@trigger.dev/schema-to-json': + specifier: workspace:4.0.0-v4-beta.26 + version: link:../schema-to-json ansi-escapes: specifier: ^7.0.0 version: 7.0.0 @@ -1795,6 +1798,52 @@ importers: specifier: 4.17.0 version: 4.17.0 + packages/schema-to-json: + dependencies: + '@sodaru/yup-to-json-schema': + specifier: ^2.0.1 + version: 2.0.1 + '@trigger.dev/core': + specifier: workspace:* + version: link:../core + zod-to-json-schema: + specifier: ^3.24.5 + version: 3.24.5(zod@3.25.76) + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.15.4 + version: 0.15.4 + '@sinclair/typebox': + specifier: ^0.34.3 + version: 0.34.38 + arktype: + specifier: ^2.0.0 + version: 2.1.20 + effect: + specifier: ^3.11.11 + version: 3.17.1 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + runtypes: + specifier: ^6.7.0 + version: 6.7.0 + superstruct: + specifier: ^2.0.2 + version: 2.0.2 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + valibot: + specifier: ^1.1.0 + version: 1.1.0(typescript@5.5.4) + yup: + specifier: ^1.7.0 + version: 1.7.0 + zod: + specifier: ^3.24.1 || ^4.0.0 + version: 3.25.76 + packages/trigger-sdk: dependencies: '@opentelemetry/api': @@ -2111,12 +2160,18 @@ importers: references/hello-world: dependencies: + '@sinclair/typebox': + specifier: ^0.34.3 + version: 0.34.38 '@trigger.dev/build': specifier: workspace:* version: link:../../packages/build '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk + arktype: + specifier: ^2.0.0 + version: 2.1.20 openai: specifier: ^4.97.0 version: 4.97.0(ws@8.12.0)(zod@3.23.8) @@ -2126,6 +2181,9 @@ importers: replicate: specifier: ^1.0.1 version: 1.0.1 + yup: + specifier: ^1.6.1 + version: 1.6.1 zod: specifier: 3.23.8 version: 3.23.8 @@ -3056,10 +3114,18 @@ packages: '@ark/util': 0.18.0 dev: false + /@ark/schema@0.46.0: + resolution: {integrity: sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==} + dependencies: + '@ark/util': 0.46.0 + /@ark/util@0.18.0: resolution: {integrity: sha512-TpHY532LKQwwYHui5NN/eO/6eSiSMvf652YNt1BsV7fya7RzXL27IiU9x4bm7jTFZxLQGYDQTB7nw41TqeuF4g==} dev: false + /@ark/util@0.46.0: + resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} + /@arr/every@1.0.1: resolution: {integrity: sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==} engines: {node: '>=4'} @@ -6193,6 +6259,7 @@ packages: /@effect/schema@0.72.2(effect@3.7.2): resolution: {integrity: sha512-/x1BIA2pqcUidNrOMmwYe6Z58KtSgHSc5iJu7bNwIxi2LHMVuUao1BvpI5x6i7T/zkoi4dd1S6qasZzJIYDjdw==} + deprecated: this package has been merged into the main effect package peerDependencies: effect: ^3.7.2 dependencies: @@ -6202,11 +6269,12 @@ packages: /@effect/schema@0.75.5(effect@3.17.1): resolution: {integrity: sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw==} + deprecated: this package has been merged into the main effect package peerDependencies: effect: ^3.9.2 dependencies: effect: 3.17.1 - fast-check: 3.22.0 + fast-check: 3.23.2 dev: false /@electric-sql/client@0.4.0: @@ -8982,10 +9050,6 @@ packages: '@jridgewell/trace-mapping': 0.3.25 dev: true - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - dev: false - /@jridgewell/sourcemap-codec@1.5.0: resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -10156,7 +10220,7 @@ packages: '@opentelemetry/api': 1.4.1 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.4.1) '@opentelemetry/instrumentation': 0.49.1(@opentelemetry/api@1.4.1) - '@opentelemetry/semantic-conventions': 1.36.0 + '@opentelemetry/semantic-conventions': 1.25.1 transitivePeerDependencies: - supports-color dev: true @@ -11263,6 +11327,7 @@ packages: /@opentelemetry/semantic-conventions@1.36.0: resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} engines: {node: '>=14'} + dev: false /@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.0): resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} @@ -17847,6 +17912,9 @@ packages: resolution: {integrity: sha512-75232GRx3wp3P7NP+yc4nRK3XUAnaQShxTAzapgmQrgs0QvSq0/mOJGoZXRpH15cFCKyys+4laCPbBselqJ5Ag==} dev: false + /@sinclair/typebox@0.34.38: + resolution: {integrity: sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==} + /@sindresorhus/is@0.14.0: resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} engines: {node: '>=6'} @@ -19040,6 +19108,10 @@ packages: - supports-color dev: false + /@sodaru/yup-to-json-schema@2.0.1: + resolution: {integrity: sha512-lWb0Wiz8KZ9ip/dY1eUqt7fhTPmL24p6Hmv5Fd9pzlzAdw/YNcWZr+tiCT4oZ4Zyxzi9+1X4zv82o7jYvcFxYA==} + dev: false + /@splinetool/react-spline@2.2.6(@splinetool/runtime@1.9.98)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-y9L2VEbnC6FNZZu8XMmWM9YTTTWal6kJVfP05Amf0QqDNzCSumKsJxZyGUODvuCmiAvy0PfIfEsiVKnSxvhsDw==} peerDependencies: @@ -19067,7 +19139,6 @@ packages: /@standard-schema/spec@1.0.0: resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - dev: false /@stricli/auto-complete@1.2.0: resolution: {integrity: sha512-r9/msiloVmTF95mdhe04Uzqei1B0ZofhYRLeiPqpJ1W1RMCC8p9iW7kqBZEbALl2aRL5ZK9OEW3Q1cIejH7KEQ==} @@ -21874,6 +21945,12 @@ packages: '@ark/util': 0.18.0 dev: false + /arktype@2.1.20: + resolution: {integrity: sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==} + dependencies: + '@ark/schema': 0.46.0 + '@ark/util': 0.46.0 + /array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -24311,7 +24388,6 @@ packages: dependencies: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 - dev: false /effect@3.7.2: resolution: {integrity: sha512-pV7l1+LSZFvVObj4zuy4nYiBaC7qZOfrKV6s/Ef4p3KueiQwZFgamazklwyZ+x7Nyj2etRDFvHE/xkThTfQD1w==} @@ -25890,7 +25966,6 @@ packages: engines: {node: '>=8.0.0'} dependencies: pure-rand: 6.1.0 - dev: false /fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -28774,7 +28849,7 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 dev: false /magicast@0.3.4: @@ -29765,7 +29840,7 @@ packages: workerd: 1.20240806.0 ws: 8.18.0(bufferutil@4.0.9) youch: 3.3.3 - zod: 3.23.8 + zod: 3.25.76 transitivePeerDependencies: - bufferutil - supports-color @@ -32326,8 +32401,8 @@ packages: '@mrleebo/prisma-ast': 0.7.0 '@prisma/generator-helper': 5.3.1 '@prisma/internals': 5.3.1 - typescript: 5.5.4 - zod: 3.23.8 + typescript: 5.8.3 + zod: 3.25.76 transitivePeerDependencies: - encoding - supports-color @@ -32447,7 +32522,6 @@ packages: /property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} - dev: false /property-information@6.2.0: resolution: {integrity: sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==} @@ -32619,7 +32693,6 @@ packages: /pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - dev: false /purgecss@2.3.0: resolution: {integrity: sha512-BE5CROfVGsx2XIhxGuZAT7rTH9lLeQx/6M0P7DTXQH4IUc3BBzs9JUzt4yzGf3JrH9enkeq6YJBe9CTtkm1WmQ==} @@ -34060,7 +34133,6 @@ packages: /runtypes@6.7.0: resolution: {integrity: sha512-3TLdfFX8YHNFOhwHrSJza6uxVBmBrEjnNQlNXvXCdItS0Pdskfg5vVXUTWIN+Y23QR09jWpSl99UHkA83m4uWA==} - dev: false /rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -35353,7 +35425,6 @@ packages: /superstruct@2.0.2: resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} engines: {node: '>=14.0.0'} - dev: false /supertest@7.0.0: resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} @@ -35944,7 +36015,6 @@ packages: /tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} - dev: false /tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} @@ -36111,7 +36181,6 @@ packages: /toposort@2.0.2: resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} - dev: false /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} @@ -36595,7 +36664,6 @@ packages: /type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - dev: false /type-fest@4.10.3: resolution: {integrity: sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==} @@ -36781,6 +36849,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -37320,6 +37394,17 @@ packages: typescript: 5.5.4 dev: false + /valibot@1.1.0(typescript@5.5.4): + resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.5.4 + dev: true + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: @@ -37572,7 +37657,7 @@ packages: dependencies: '@types/node': 20.14.14 esbuild: 0.20.2 - postcss: 8.5.3 + postcss: 8.5.4 rollup: 4.36.0 optionalDependencies: fsevents: 2.3.3 @@ -38258,6 +38343,24 @@ packages: type-fest: 2.19.0 dev: false + /yup@1.6.1: + resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: false + + /yup@1.7.0: + resolution: {integrity: sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==} + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: true + /zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -38316,6 +38419,14 @@ packages: dependencies: zod: 3.23.8 + /zod-to-json-schema@3.24.5(zod@3.25.76): + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + peerDependencies: + zod: ^3.24.1 + dependencies: + zod: 3.25.76 + dev: false + /zod-validation-error@1.5.0(zod@3.23.8): resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==} engines: {node: '>=16.0.0'} @@ -38330,7 +38441,6 @@ packages: /zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - dev: false /zustand@4.5.5(@types/react@18.2.69)(react@18.2.0): resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} diff --git a/references/hello-world/package.json b/references/hello-world/package.json index 89dbeea9110..35299405299 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -8,10 +8,13 @@ "dependencies": { "@trigger.dev/build": "workspace:*", "@trigger.dev/sdk": "workspace:*", + "arktype": "^2.0.0", "openai": "^4.97.0", "puppeteer-core": "^24.15.0", "replicate": "^1.0.1", - "zod": "3.23.8" + "yup": "^1.6.1", + "zod": "3.23.8", + "@sinclair/typebox": "^0.34.3" }, "scripts": { "dev": "trigger dev", diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 1eb7f18916a..14fdd0faad5 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -14,12 +14,12 @@ export const helloWorldTask = task({ logger.info("Hello, world from the onStart hook", { payload, init }); }, run: async (payload: any, { ctx }) => { - logger.info("Hello, world from the init", { ctx, payload }); + logger.info("Hello, world froms the init", { ctx, payload }); logger.info("env vars", { env: process.env, }); - logger.debug("debug: Hello, world!", { payload }); + logger.debug("debug: Hello, worlds!", { payload }); logger.info("info: Hello, world!", { payload }); logger.log("log: Hello, world!", { payload }); logger.warn("warn: Hello, world!", { payload }); diff --git a/references/hello-world/src/trigger/jsonSchema.ts b/references/hello-world/src/trigger/jsonSchema.ts new file mode 100644 index 00000000000..f347f9d3899 --- /dev/null +++ b/references/hello-world/src/trigger/jsonSchema.ts @@ -0,0 +1,413 @@ +import { task, schemaTask, logger, type JSONSchema } from "@trigger.dev/sdk/v3"; +import { z } from "zod"; +import * as y from "yup"; +import { type } from "arktype"; +import { Type, Static } from "@sinclair/typebox"; + +// =========================================== +// Example 1: Using schemaTask with Zod +// =========================================== +const userSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1), + email: z.string().email(), + age: z.number().int().min(0).max(150), + preferences: z + .object({ + newsletter: z.boolean().default(false), + theme: z.enum(["light", "dark"]).default("light"), + }) + .optional(), +}); + +export const processUserWithZod = schemaTask({ + id: "json-schema-zod-example", + schema: userSchema, + run: async (payload, { ctx }) => { + // payload is fully typed based on the Zod schema + logger.info("Processing user with Zod schema", { + userId: payload.id, + userName: payload.name, + }); + + // The schema is automatically converted to JSON Schema and synced + return { + processed: true, + userId: payload.id, + welcomeMessage: `Welcome ${payload.name}!`, + }; + }, +}); + +// =========================================== +// Example 2: Using plain task with manual JSON Schema +// =========================================== +export const processOrderManualSchema = task({ + id: "json-schema-manual-example", + // Manually provide JSON Schema for the payload + jsonSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + title: "Order Processing Request", + description: "Schema for processing customer orders", + properties: { + orderId: { + type: "string", + pattern: "^ORD-[0-9]+$", + description: "Order ID in format ORD-XXXXX", + }, + customerId: { + type: "string", + format: "uuid", + }, + items: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: { + productId: { type: "string" }, + quantity: { type: "integer", minimum: 1 }, + price: { type: "number", minimum: 0, multipleOf: 0.01 }, + }, + required: ["productId", "quantity", "price"], + additionalProperties: false, + }, + }, + totalAmount: { + type: "number", + minimum: 0, + multipleOf: 0.01, + }, + status: { + type: "string", + enum: ["pending", "processing", "shipped", "delivered"], + default: "pending", + }, + }, + required: ["orderId", "customerId", "items", "totalAmount"], + additionalProperties: false, + } satisfies JSONSchema, + run: async (payload, { ctx }) => { + logger.info("Processing order with manual JSON Schema", { + orderId: payload.orderId, + }); + + // Note: With plain tasks, the payload is typed as 'any' + // The JSON Schema will be used for documentation and validation on the server + return { + processed: true, + orderId: payload.orderId, + status: "processing", + }; + }, +}); + +// =========================================== +// Example 3: Using schemaTask with Yup +// =========================================== +const productSchema = y.object({ + sku: y + .string() + .required() + .matches(/^[A-Z]{3}-[0-9]{5}$/), + name: y.string().required().min(3).max(100), + description: y.string().max(500), + price: y.number().required().positive(), + categories: y.array().of(y.string()).min(1).required(), + inStock: y.boolean().default(true), +}); + +export const processProductWithYup = schemaTask({ + id: "json-schema-yup-example", + schema: productSchema, + run: async (payload, { ctx }) => { + logger.info("Processing product with Yup schema", { + sku: payload.sku, + name: payload.name, + }); + + return { + processed: true, + sku: payload.sku, + message: `Product ${payload.name} has been processed`, + }; + }, +}); + +// =========================================== +// Example 4: Using schemaTask with ArkType +// =========================================== +const invoiceSchema = type({ + invoiceNumber: "string", + date: "Date", + dueDate: "Date", + "discount?": "number", + lineItems: [ + { + description: "string", + quantity: "number", + unitPrice: "number", + }, + ], + customer: { + id: "string", + name: "string", + "taxId?": "string", + }, +}); + +export const processInvoiceWithArkType = schemaTask({ + id: "json-schema-arktype-example", + schema: invoiceSchema, + run: async (payload, { ctx }) => { + logger.info("Processing invoice with ArkType schema", { + invoiceNumber: payload.invoiceNumber, + customerName: payload.customer.name, + }); + + const total = payload.lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0); + + const discount = payload.discount || 0; + const finalAmount = total * (1 - discount / 100); + + return { + processed: true, + invoiceNumber: payload.invoiceNumber, + totalAmount: finalAmount, + }; + }, +}); + +// =========================================== +// Example 5: Using TypeBox (already JSON Schema) +// =========================================== +const eventSchema = Type.Object({ + eventId: Type.String({ format: "uuid" }), + eventType: Type.Union([ + Type.Literal("user.created"), + Type.Literal("user.updated"), + Type.Literal("user.deleted"), + Type.Literal("order.placed"), + Type.Literal("order.shipped"), + ]), + timestamp: Type.Integer({ minimum: 0 }), + userId: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + payload: Type.Unknown(), +}); + +type EventType = Static; + +export const processEventWithTypeBox = task({ + id: "json-schema-typebox-example", + // TypeBox schemas are already JSON Schema compliant + jsonSchema: eventSchema, + run: async (payload: EventType, { ctx }) => { + // Cast to get TypeScript type safety + const event = payload; + + logger.info("Processing event with TypeBox schema", { + eventId: event.eventId, + eventType: event.eventType, + userId: event.userId, + }); + + // Handle different event types + switch (event.eventType) { + case "user.created": + logger.info("New user created", { userId: event.userId }); + break; + case "order.placed": + logger.info("Order placed", { userId: event.userId }); + break; + default: + logger.info("Event processed", { eventType: event.eventType }); + } + + return { + processed: true, + eventId: event.eventId, + eventType: event.eventType, + }; + }, +}); + +// =========================================== +// Example 6: Using plain task with a Zod schema +// =========================================== +// If you need to use a plain task but have a Zod schema, +// you should use schemaTask instead for better DX. +// This example shows what NOT to do: + +const notificationSchema = z.object({ + recipientId: z.string(), + type: z.enum(["email", "sms", "push"]), + subject: z.string().optional(), + message: z.string(), + priority: z.enum(["low", "normal", "high"]).default("normal"), + scheduledFor: z.date().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +// ❌ Don't do this - use schemaTask instead! +export const sendNotificationBadExample = task({ + id: "json-schema-dont-do-this", + run: async (payload, { ctx }) => { + // You'd have to manually validate + const notification = notificationSchema.parse(payload); + + logger.info("This is not ideal - use schemaTask instead!"); + + return { sent: true }; + }, +}); + +// ✅ Do this instead - much better! +export const sendNotificationGoodExample = schemaTask({ + id: "json-schema-do-this-instead", + schema: notificationSchema, + run: async (notification, { ctx }) => { + // notification is already validated and typed! + logger.info("Sending notification", { + recipientId: notification.recipientId, + type: notification.type, + priority: notification.priority, + }); + + // Simulate sending notification + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return { + sent: true, + notificationId: ctx.run.id, + recipientId: notification.recipientId, + type: notification.type, + }; + }, +}); + +// =========================================== +// Example 7: Complex nested schema with references +// =========================================== +const addressSchema = z.object({ + street: z.string(), + city: z.string(), + state: z.string().length(2), + zipCode: z.string().regex(/^\d{5}(-\d{4})?$/), + country: z.string().default("US"), +}); + +const companySchema = z.object({ + companyId: z.string().uuid(), + name: z.string(), + taxId: z.string().optional(), + addresses: z.object({ + billing: addressSchema, + shipping: addressSchema.optional(), + }), + contacts: z + .array( + z.object({ + name: z.string(), + email: z.string().email(), + phone: z.string().optional(), + role: z.enum(["primary", "billing", "technical"]), + }) + ) + .min(1), + settings: z.object({ + invoicePrefix: z.string().default("INV"), + paymentTerms: z.number().int().min(0).max(90).default(30), + currency: z.enum(["USD", "EUR", "GBP"]).default("USD"), + }), +}); + +export const processCompanyWithComplexSchema = schemaTask({ + id: "json-schema-complex-example", + schema: companySchema, + maxDuration: 300, // 5 minutes + retry: { + maxAttempts: 3, + factor: 2, + }, + run: async (payload, { ctx }) => { + logger.info("Processing company with complex schema", { + companyId: payload.companyId, + name: payload.name, + contactCount: payload.contacts.length, + }); + + // Process each contact + for (const contact of payload.contacts) { + logger.info("Processing contact", { + name: contact.name, + role: contact.role, + }); + } + + return { + processed: true, + companyId: payload.companyId, + name: payload.name, + primaryContact: payload.contacts.find((c) => c.role === "primary"), + }; + }, +}); + +// =========================================== +// Example 8: Demonstrating schema benefits +// =========================================== +export const triggerExamples = task({ + id: "json-schema-trigger-examples", + run: async (_, { ctx }) => { + logger.info("Triggering various schema examples"); + + // Trigger Zod example - TypeScript will enforce correct payload + await processUserWithZod.trigger({ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "John Doe", + email: "john@example.com", + age: 30, + preferences: { + newsletter: true, + theme: "dark", + }, + }); + + // Trigger Yup example + await processProductWithYup.trigger({ + sku: "ABC-12345", + name: "Premium Widget", + description: "A high-quality widget for all your needs", + price: 99.99, + categories: ["electronics", "gadgets"], + inStock: true, + }); + + // Trigger manual schema example (no compile-time validation) + await processOrderManualSchema.trigger({ + orderId: "ORD-12345", + customerId: "550e8400-e29b-41d4-a716-446655440001", + items: [ + { + productId: "PROD-001", + quantity: 2, + price: 29.99, + }, + { + productId: "PROD-002", + quantity: 1, + price: 49.99, + }, + ], + totalAmount: 109.97, + status: "pending", + }); + + return { + message: "All examples triggered successfully", + timestamp: new Date().toISOString(), + }; + }, +}); \ No newline at end of file diff --git a/references/hello-world/src/trigger/jsonSchemaApi.ts b/references/hello-world/src/trigger/jsonSchemaApi.ts new file mode 100644 index 00000000000..9ac121b3698 --- /dev/null +++ b/references/hello-world/src/trigger/jsonSchemaApi.ts @@ -0,0 +1,343 @@ +import { task, schemaTask, logger, type JSONSchema } from "@trigger.dev/sdk/v3"; +import { z } from "zod"; + +// =========================================== +// Example: Webhook Handler with Schema Validation +// =========================================== + +// Define schemas for different webhook event types +const baseWebhookSchema = z.object({ + id: z.string(), + timestamp: z.string().datetime(), + type: z.string(), + version: z.literal("1.0"), +}); + +// Payment webhook events +const paymentEventSchema = baseWebhookSchema.extend({ + type: z.literal("payment"), + data: z.object({ + paymentId: z.string(), + amount: z.number().positive(), + currency: z.string().length(3), + status: z.enum(["pending", "processing", "completed", "failed"]), + customerId: z.string(), + paymentMethod: z.object({ + type: z.enum(["card", "bank_transfer", "paypal"]), + last4: z.string().optional(), + }), + metadata: z.record(z.string()).optional(), + }), +}); + +// Customer webhook events +const customerEventSchema = baseWebhookSchema.extend({ + type: z.literal("customer"), + data: z.object({ + customerId: z.string(), + action: z.enum(["created", "updated", "deleted"]), + email: z.string().email(), + name: z.string(), + subscription: z.object({ + status: z.enum(["active", "cancelled", "past_due"]), + plan: z.string(), + }).optional(), + }), +}); + +// Union of all webhook types +const webhookSchema = z.discriminatedUnion("type", [ + paymentEventSchema, + customerEventSchema, +]); + +export const handleWebhook = schemaTask({ + id: "handle-webhook", + schema: webhookSchema, + run: async (payload, { ctx }) => { + logger.info("Processing webhook", { + id: payload.id, + type: payload.type, + timestamp: payload.timestamp, + }); + + // TypeScript knows the exact shape based on the discriminated union + switch (payload.type) { + case "payment": + logger.info("Payment event received", { + paymentId: payload.data.paymentId, + amount: payload.data.amount, + status: payload.data.status, + }); + + if (payload.data.status === "completed") { + // Trigger order fulfillment + await fulfillOrder.trigger({ + customerId: payload.data.customerId, + paymentId: payload.data.paymentId, + amount: payload.data.amount, + }); + } + break; + + case "customer": + logger.info("Customer event received", { + customerId: payload.data.customerId, + action: payload.data.action, + }); + + if (payload.data.action === "created") { + // Send welcome email + await sendWelcomeEmail.trigger({ + email: payload.data.email, + name: payload.data.name, + }); + } + break; + } + + return { + processed: true, + eventId: payload.id, + eventType: payload.type, + }; + }, +}); + +// =========================================== +// Example: External API Integration +// =========================================== + +// Schema for making API requests to a third-party service +const apiRequestSchema = z.object({ + endpoint: z.enum(["/users", "/products", "/orders"]), + method: z.enum(["GET", "POST", "PUT", "DELETE"]), + params: z.record(z.string()).optional(), + body: z.unknown().optional(), + headers: z.record(z.string()).optional(), + retryOnError: z.boolean().default(true), +}); + +// Response schemas for different endpoints +const userResponseSchema = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + createdAt: z.string().datetime(), +}); + +const productResponseSchema = z.object({ + id: z.string(), + name: z.string(), + price: z.number(), + inStock: z.boolean(), +}); + +export const callExternalApi = schemaTask({ + id: "call-external-api", + schema: apiRequestSchema, + retry: { + maxAttempts: 3, + factor: 2, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + }, + run: async (payload, { ctx }) => { + logger.info("Making API request", { + endpoint: payload.endpoint, + method: payload.method, + }); + + // Simulate API call + const response = await makeApiCall(payload); + + // Validate response based on endpoint + let validatedResponse; + switch (payload.endpoint) { + case "/users": + validatedResponse = userResponseSchema.parse(response); + break; + case "/products": + validatedResponse = productResponseSchema.parse(response); + break; + default: + validatedResponse = response; + } + + return { + success: true, + endpoint: payload.endpoint, + response: validatedResponse, + }; + }, +}); + +// Helper function to simulate API calls +async function makeApiCall(request: z.infer) { + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 100)); + + // Return mock data based on endpoint + switch (request.endpoint) { + case "/users": + return { + id: "user_123", + email: "user@example.com", + name: "John Doe", + createdAt: new Date().toISOString(), + }; + case "/products": + return { + id: "prod_456", + name: "Premium Widget", + price: 99.99, + inStock: true, + }; + default: + return { message: "Success" }; + } +} + +// =========================================== +// Example: Batch Processing with Validation +// =========================================== + +const batchItemSchema = z.object({ + id: z.string(), + operation: z.enum(["create", "update", "delete"]), + resourceType: z.enum(["user", "product", "order"]), + data: z.record(z.unknown()), +}); + +const batchRequestSchema = z.object({ + batchId: z.string(), + items: z.array(batchItemSchema).min(1).max(100), + options: z.object({ + stopOnError: z.boolean().default(false), + parallel: z.boolean().default(true), + maxConcurrency: z.number().int().min(1).max(10).default(5), + }).default({}), +}); + +export const processBatch = schemaTask({ + id: "process-batch", + schema: batchRequestSchema, + maxDuration: 300, // 5 minutes for large batches + run: async (payload, { ctx }) => { + logger.info("Processing batch", { + batchId: payload.batchId, + itemCount: payload.items.length, + parallel: payload.options.parallel, + }); + + const results = []; + const errors = []; + + if (payload.options.parallel) { + // Process items in parallel with concurrency limit + const chunks = chunkArray(payload.items, payload.options.maxConcurrency); + + for (const chunk of chunks) { + const chunkResults = await Promise.allSettled( + chunk.map(item => processItem(item)) + ); + + chunkResults.forEach((result, index) => { + if (result.status === "fulfilled") { + results.push(result.value); + } else { + errors.push({ + item: chunk[index], + error: result.reason, + }); + + if (payload.options.stopOnError) { + throw new Error(`Batch processing stopped due to error in item ${chunk[index].id}`); + } + } + }); + } + } else { + // Process items sequentially + for (const item of payload.items) { + try { + const result = await processItem(item); + results.push(result); + } catch (error) { + errors.push({ item, error }); + + if (payload.options.stopOnError) { + throw new Error(`Batch processing stopped due to error in item ${item.id}`); + } + } + } + } + + return { + batchId: payload.batchId, + processed: results.length, + failed: errors.length, + results, + errors, + }; + }, +}); + +async function processItem(item: z.infer) { + logger.info("Processing batch item", { + id: item.id, + operation: item.operation, + resourceType: item.resourceType, + }); + + // Simulate processing + await new Promise(resolve => setTimeout(resolve, 50)); + + return { + id: item.id, + success: true, + operation: item.operation, + resourceType: item.resourceType, + }; +} + +function chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +// =========================================== +// Helper Tasks +// =========================================== + +const orderSchema = z.object({ + customerId: z.string(), + paymentId: z.string(), + amount: z.number(), +}); + +export const fulfillOrder = schemaTask({ + id: "fulfill-order", + schema: orderSchema, + run: async (payload, { ctx }) => { + logger.info("Fulfilling order", payload); + return { fulfilled: true }; + }, +}); + +const welcomeEmailSchema = z.object({ + email: z.string().email(), + name: z.string(), +}); + +export const sendWelcomeEmail = schemaTask({ + id: "send-welcome-email", + schema: welcomeEmailSchema, + run: async (payload, { ctx }) => { + logger.info("Sending welcome email", payload); + return { sent: true }; + }, +}); \ No newline at end of file diff --git a/references/hello-world/src/trigger/jsonSchemaSimple.ts b/references/hello-world/src/trigger/jsonSchemaSimple.ts new file mode 100644 index 00000000000..a844b14034f --- /dev/null +++ b/references/hello-world/src/trigger/jsonSchemaSimple.ts @@ -0,0 +1,235 @@ +import { task, schemaTask, logger, type JSONSchema } from "@trigger.dev/sdk/v3"; +import { z } from "zod"; + +// =========================================== +// The Two Main Approaches +// =========================================== + +// Approach 1: Using schemaTask (Recommended) +// - Automatic JSON Schema conversion +// - Full TypeScript type safety +// - Runtime validation built-in +const emailSchema = z.object({ + to: z.string().email(), + subject: z.string(), + body: z.string(), + attachments: z + .array( + z.object({ + filename: z.string(), + url: z.string().url(), + }) + ) + .optional(), +}); + +export const sendEmailSchemaTask = schemaTask({ + id: "send-email-schema-task", + schema: emailSchema, + run: async (payload, { ctx }) => { + // payload is fully typed as: + // { + // to: string; + // subject: string; + // body: string; + // attachments?: Array<{ filename: string; url: string; }>; + // } + + logger.info("Sending email", { + to: payload.to, + subject: payload.subject, + hasAttachments: !!payload.attachments?.length, + }); + + // Your email sending logic here... + + return { + sent: true, + messageId: `msg_${ctx.run.id}`, + sentAt: new Date().toISOString(), + }; + }, +}); + +// Approach 2: Using plain task with payloadSchema +// - Manual JSON Schema definition +// - No automatic type inference (payload is 'any') +// - Good for when you already have JSON Schema definitions +export const sendEmailPlainTask = task({ + id: "send-email-plain-task", + jsonSchema: { + type: "object", + properties: { + to: { + type: "string", + format: "email", + description: "Recipient email address", + }, + subject: { + type: "string", + maxLength: 200, + }, + body: { + type: "string", + }, + attachments: { + type: "array", + items: { + type: "object", + properties: { + filename: { type: "string" }, + url: { type: "string", format: "uri" }, + }, + required: ["filename", "url"], + }, + }, + }, + required: ["to", "subject", "body"], + } satisfies JSONSchema, // Use 'satisfies' for type checking + run: async (payload, { ctx }) => { + // payload is typed as 'any' - you need to validate/cast it yourself + logger.info("Sending email", { + to: payload.to, + subject: payload.subject, + }); + + // Your email sending logic here... + + return { + sent: true, + messageId: `msg_${ctx.run.id}`, + sentAt: new Date().toISOString(), + }; + }, +}); + +// =========================================== +// Benefits of JSON Schema +// =========================================== + +// 1. Documentation - The schema is visible in the Trigger.dev dashboard +// 2. Validation - Invalid payloads are rejected before execution +// 3. Type Safety - With schemaTask, you get full TypeScript support +// 4. OpenAPI Generation - Can be used to generate API documentation +// 5. Client SDKs - Can generate typed clients for other languages + +export const demonstrateBenefits = task({ + id: "json-schema-benefits-demo", + run: async (_, { ctx }) => { + logger.info("Demonstrating JSON Schema benefits"); + + // With schemaTask, TypeScript prevents invalid payloads at compile time + try { + await sendEmailSchemaTask.trigger({ + to: "user@example.com", + subject: "Welcome!", + body: "Thanks for signing up!", + // TypeScript error if you try to add invalid fields + // invalidField: "This would cause a TypeScript error", + }); + } catch (error) { + logger.error("Failed to send email", { error }); + } + + // With plain task, validation happens at runtime + try { + await sendEmailPlainTask.trigger({ + to: "not-an-email", // This will fail validation at runtime + subject: "Test", + body: "Test email", + }); + } catch (error) { + logger.error("Failed validation", { error }); + } + + return { demonstrated: true }; + }, +}); + +// =========================================== +// Real-World Example: User Registration Flow +// =========================================== +const userRegistrationSchema = z.object({ + email: z.string().email(), + username: z + .string() + .min(3) + .max(20) + .regex(/^[a-zA-Z0-9_]+$/), + password: z.string().min(8), + profile: z.object({ + firstName: z.string(), + lastName: z.string(), + dateOfBirth: z.string().optional(), // ISO date string + preferences: z + .object({ + newsletter: z.boolean().default(false), + notifications: z.boolean().default(true), + }) + .default({}), + }), + referralCode: z.string().optional(), +}); + +export const registerUser = schemaTask({ + id: "register-user", + schema: userRegistrationSchema, + retry: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + }, + run: async (payload, { ctx }) => { + logger.info("Registering new user", { + email: payload.email, + username: payload.username, + }); + + // Step 1: Validate uniqueness + logger.info("Checking if user exists"); + // ... database check logic ... + + // Step 2: Create user account + logger.info("Creating user account"); + const userId = `user_${Date.now()}`; + // ... user creation logic ... + + // Step 3: Send welcome email + await sendEmailSchemaTask.trigger({ + to: payload.email, + subject: `Welcome to our platform, ${payload.profile.firstName}!`, + body: `Hi ${payload.profile.firstName},\n\nThanks for joining us...`, + }); + + // Step 4: Apply referral code if provided + if (payload.referralCode) { + logger.info("Processing referral code", { code: payload.referralCode }); + // ... referral logic ... + } + + return { + success: true, + userId, + username: payload.username, + welcomeEmailSent: true, + }; + }, +}); + +// =========================================== +// When to Use Each Approach +// =========================================== + +/* +Use schemaTask when: +- You're already using Zod, Yup, ArkType, etc. in your codebase +- You want TypeScript type inference +- You want runtime validation handled automatically +- You're building new tasks from scratch + +Use plain task with payloadSchema when: +- You have existing JSON Schema definitions +- You're migrating from another system that uses JSON Schema +- You need fine-grained control over the schema format +- You're working with generated schemas from OpenAPI/Swagger +*/ \ No newline at end of file From 8eecc7e54851baae38cd4039ee81aa244efe3a82 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 6 Aug 2025 13:56:57 +0100 Subject: [PATCH 051/641] chore: json schema missing changeset (#2353) --- .changeset/fluffy-mirrors-live.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fluffy-mirrors-live.md diff --git a/.changeset/fluffy-mirrors-live.md b/.changeset/fluffy-mirrors-live.md new file mode 100644 index 00000000000..97e741ecab5 --- /dev/null +++ b/.changeset/fluffy-mirrors-live.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add jsonSchema support when indexing tasks From d950a969bd57697dc0a639be11b9bc24cda08b49 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 6 Aug 2025 14:48:43 +0100 Subject: [PATCH 052/641] Update zod package to version 3.25.76 across all modules (#2352) * Update zod package to version 3.25.76 across all modules Update the zod library from version 3.23.8 to 3.25.76 in multiple package files to ensure compatibility and take advantage of new features or bug fixes introduced in recent releases. Keeping all modules synchronized with the latest version of zod helps maintain consistency across the project and reduces potential compatibility issues. - Modified zod version in apps/supervisor, webapp, and various internal packages. - Updated zod references in pnpm-lock.yaml to reflect the new version. - Ensure dependencies that rely on zod are using the updated version to avoid mismatches. * Add changeset --- .changeset/swift-vans-dress.md | 7 + apps/supervisor/package.json | 2 +- apps/webapp/package.json | 2 +- internal-packages/clickhouse/package.json | 2 +- internal-packages/emails/package.json | 2 +- internal-packages/run-engine/package.json | 2 +- .../schedule-engine/package.json | 2 +- internal-packages/zod-worker/package.json | 2 +- packages/cli-v3/package.json | 2 +- packages/core/package.json | 2 +- packages/redis-worker/package.json | 2 +- packages/trigger-sdk/package.json | 2 +- pnpm-lock.yaml | 445 +++++++++--------- references/d3-chat/package.json | 2 +- references/d3-openai-agents/package.json | 2 +- references/hello-world/package.json | 2 +- references/nextjs-realtime/package.json | 2 +- references/python-catalog/package.json | 2 +- references/test-tasks/package.json | 2 +- references/v3-catalog/package.json | 2 +- 20 files changed, 244 insertions(+), 244 deletions(-) create mode 100644 .changeset/swift-vans-dress.md diff --git a/.changeset/swift-vans-dress.md b/.changeset/swift-vans-dress.md new file mode 100644 index 00000000000..1ccbf2027ac --- /dev/null +++ b/.changeset/swift-vans-dress.md @@ -0,0 +1,7 @@ +--- +"@trigger.dev/sdk": patch +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Upgrade to zod 3.25.76 diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json index ae365492721..9cce9d5feb6 100644 --- a/apps/supervisor/package.json +++ b/apps/supervisor/package.json @@ -19,7 +19,7 @@ "prom-client": "^15.1.0", "socket.io": "4.7.4", "std-env": "^3.8.0", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@types/dockerode": "^3.3.33" diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 80b7c9614ba..69473b78b9b 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -203,7 +203,7 @@ "ulidx": "^2.2.1", "uuid": "^9.0.0", "ws": "^8.11.0", - "zod": "3.23.8", + "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" }, diff --git a/internal-packages/clickhouse/package.json b/internal-packages/clickhouse/package.json index d85051b5065..efa7cffd12c 100644 --- a/internal-packages/clickhouse/package.json +++ b/internal-packages/clickhouse/package.json @@ -9,7 +9,7 @@ "@clickhouse/client": "^1.11.1", "@internal/tracing": "workspace:*", "@trigger.dev/core": "workspace:*", - "zod": "3.23.8", + "zod": "3.25.76", "zod-error": "1.5.0" }, "devDependencies": { diff --git a/internal-packages/emails/package.json b/internal-packages/emails/package.json index 85cb7abe014..e5d35cb2ee8 100644 --- a/internal-packages/emails/package.json +++ b/internal-packages/emails/package.json @@ -17,7 +17,7 @@ "react-email": "^2.1.1", "resend": "^3.2.0", "tiny-invariant": "^1.2.0", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@types/nodemailer": "^6.4.17", diff --git a/internal-packages/run-engine/package.json b/internal-packages/run-engine/package.json index 62f08378972..a12abc73e28 100644 --- a/internal-packages/run-engine/package.json +++ b/internal-packages/run-engine/package.json @@ -30,7 +30,7 @@ "nanoid": "3.3.8", "redlock": "5.0.0-beta.2", "seedrandom": "^3.0.5", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@internal/testcontainers": "workspace:*", diff --git a/internal-packages/schedule-engine/package.json b/internal-packages/schedule-engine/package.json index ecdf151247e..86929a39341 100644 --- a/internal-packages/schedule-engine/package.json +++ b/internal-packages/schedule-engine/package.json @@ -22,7 +22,7 @@ "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", "nanoid": "3.3.8", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@internal/testcontainers": "workspace:*", diff --git a/internal-packages/zod-worker/package.json b/internal-packages/zod-worker/package.json index 1d9cbad93cd..352fa945293 100644 --- a/internal-packages/zod-worker/package.json +++ b/internal-packages/zod-worker/package.json @@ -10,7 +10,7 @@ "@trigger.dev/database": "workspace:*", "graphile-worker": "0.16.6", "lodash.omit": "^4.5.0", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@types/lodash.omit": "^4.5.7", diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 74e935590e1..b6a20b86d18 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -135,7 +135,7 @@ "tinyglobby": "^0.2.10", "ws": "^8.18.0", "xdg-app-paths": "^8.3.0", - "zod": "3.23.8", + "zod": "3.25.76", "zod-validation-error": "^1.5.0" }, "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index bf14dfdbd40..a6afde03e56 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -196,7 +196,7 @@ "superjson": "^2.2.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", - "zod": "3.23.8", + "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" }, diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 3625e0f1302..3ba44752e21 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -27,7 +27,7 @@ "lodash.omit": "^4.5.0", "nanoid": "^5.0.7", "p-limit": "^6.2.0", - "zod": "3.23.8", + "zod": "3.25.76", "cron-parser": "^4.9.0" }, "devDependencies": { diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index fe4c7d5023d..6dc5977d546 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -74,7 +74,7 @@ "tshy": "^3.0.2", "tsx": "4.17.0", "typed-emitter": "^2.1.0", - "zod": "3.23.8" + "zod": "3.25.76" }, "peerDependencies": { "zod": "^3.0.0 || ^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2d7021ef0b..6b3af7126db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,8 +182,8 @@ importers: specifier: ^3.8.0 version: 3.8.1 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@types/dockerode': specifier: ^3.3.33 @@ -193,7 +193,7 @@ importers: dependencies: '@ai-sdk/openai': specifier: ^1.3.23 - version: 1.3.23(zod@3.23.8) + version: 1.3.23(zod@3.25.76) '@ariakit/react': specifier: ^0.4.6 version: 0.4.6(react-dom@18.2.0)(react@18.2.0) @@ -244,7 +244,7 @@ importers: version: 0.9.2(react@18.2.0) '@conform-to/zod': specifier: 0.9.2 - version: 0.9.2(@conform-to/dom@0.9.2)(zod@3.23.8) + version: 0.9.2(@conform-to/dom@0.9.2)(zod@3.25.76) '@depot/cli': specifier: 0.0.1-cli.2.80.0 version: 0.0.1-cli.2.80.0 @@ -469,7 +469,7 @@ importers: version: 0.9.14 ai: specifier: ^4.3.19 - version: 4.3.19(react@18.2.0)(zod@3.23.8) + version: 4.3.19(react@18.2.0)(zod@3.25.76) assert-never: specifier: ^1.2.1 version: 1.2.1 @@ -652,7 +652,7 @@ importers: version: 0.3.1(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0)(react@18.2.0) remix-utils: specifier: ^7.7.0 - version: 7.7.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/router@1.15.3)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.23.8) + version: 7.7.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/router@1.15.3)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76) seedrandom: specifier: ^3.0.5 version: 3.0.5 @@ -711,14 +711,14 @@ importers: specifier: ^8.11.0 version: 8.12.0 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 zod-error: specifier: 1.5.0 version: 1.5.0 zod-validation-error: specifier: ^1.5.0 - version: 1.5.0(zod@3.23.8) + version: 1.5.0(zod@3.25.76) devDependencies: '@internal/clickhouse': specifier: workspace:* @@ -951,8 +951,8 @@ importers: specifier: workspace:* version: link:../../packages/core zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 zod-error: specifier: 1.5.0 version: 1.5.0 @@ -1004,8 +1004,8 @@ importers: specifier: ^1.2.0 version: 1.3.1 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@types/nodemailer': specifier: ^6.4.17 @@ -1103,8 +1103,8 @@ importers: specifier: ^3.0.5 version: 3.0.5 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@internal/testcontainers': specifier: workspace:* @@ -1143,8 +1143,8 @@ importers: specifier: 3.3.8 version: 3.3.8 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@internal/testcontainers': specifier: workspace:* @@ -1220,8 +1220,8 @@ importers: specifier: ^4.5.0 version: 4.5.0 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@types/lodash.omit': specifier: ^4.5.7 @@ -1435,11 +1435,11 @@ importers: specifier: ^8.3.0 version: 8.3.0 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 zod-validation-error: specifier: ^1.5.0 - version: 1.5.0(zod@3.23.8) + version: 1.5.0(zod@3.25.76) devDependencies: '@epic-web/test-server': specifier: ^0.1.0 @@ -1592,18 +1592,18 @@ importers: specifier: ^0.1.3 version: 0.1.3 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 zod-error: specifier: 1.5.0 version: 1.5.0 zod-validation-error: specifier: ^1.5.0 - version: 1.5.0(zod@3.23.8) + version: 1.5.0(zod@3.25.76) devDependencies: '@ai-sdk/provider-utils': specifier: ^1.0.22 - version: 1.0.22(zod@3.23.8) + version: 1.0.22(zod@3.25.76) '@arethetypeswrong/cli': specifier: ^0.15.4 version: 0.15.4 @@ -1624,7 +1624,7 @@ importers: version: 4.0.14 ai: specifier: ^3.4.33 - version: 3.4.33(react@18.3.1)(svelte@5.33.14)(vue@3.5.16)(zod@3.23.8) + version: 3.4.33(react@18.3.1)(svelte@5.33.14)(vue@3.5.16)(zod@3.25.76) defu: specifier: ^6.1.4 version: 6.1.4 @@ -1733,8 +1733,8 @@ importers: specifier: ^6.2.0 version: 6.2.0 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@internal/redis': specifier: workspace:* @@ -1900,7 +1900,7 @@ importers: version: 8.5.4 ai: specifier: ^4.2.0 - version: 4.2.5(react@18.3.1)(zod@3.23.8) + version: 4.2.5(react@18.3.1)(zod@3.25.76) encoding: specifier: ^0.1.13 version: 0.1.13 @@ -1917,8 +1917,8 @@ importers: specifier: ^2.1.0 version: 2.1.0 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 references/bun-catalog: dependencies: @@ -1937,10 +1937,10 @@ importers: dependencies: '@ai-sdk/anthropic': specifier: ^1.2.4 - version: 1.2.4(zod@3.23.8) + version: 1.2.4(zod@3.25.76) '@ai-sdk/openai': specifier: 1.3.3 - version: 1.3.3(zod@3.23.8) + version: 1.3.3(zod@3.25.76) '@e2b/code-interpreter': specifier: ^1.1.0 version: 1.1.0 @@ -1985,7 +1985,7 @@ importers: version: 0.10.0 ai: specifier: 4.2.5 - version: 4.2.5(react@19.0.0)(zod@3.23.8) + version: 4.2.5(react@19.0.0)(zod@3.25.76) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2020,8 +2020,8 @@ importers: specifier: ^1.2.4 version: 1.2.4 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2058,7 +2058,7 @@ importers: dependencies: '@ai-sdk/openai': specifier: 1.3.3 - version: 1.3.3(zod@3.23.8) + version: 1.3.3(zod@3.25.76) '@slack/web-api': specifier: 7.9.1 version: 7.9.1 @@ -2076,7 +2076,7 @@ importers: version: 0.10.0 ai: specifier: 4.2.5 - version: 4.2.5(react@19.0.0)(zod@3.23.8) + version: 4.2.5(react@19.0.0)(zod@3.25.76) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2105,11 +2105,11 @@ importers: specifier: ^1.2.4 version: 1.2.4 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 zod-to-json-schema: specifier: ^3.24.5 - version: 3.24.5(zod@3.23.8) + version: 3.24.5(zod@3.25.76) devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2174,7 +2174,7 @@ importers: version: 2.1.20 openai: specifier: ^4.97.0 - version: 4.97.0(ws@8.12.0)(zod@3.23.8) + version: 4.97.0(ws@8.12.0)(zod@3.25.76) puppeteer-core: specifier: ^24.15.0 version: 24.15.0 @@ -2185,8 +2185,8 @@ importers: specifier: ^1.6.1 version: 1.6.1 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: trigger.dev: specifier: workspace:* @@ -2208,7 +2208,7 @@ importers: dependencies: '@ai-sdk/openai': specifier: ^1.0.1 - version: 1.0.1(zod@3.23.8) + version: 1.0.1(zod@3.25.76) '@fal-ai/serverless-client': specifier: ^0.15.0 version: 0.15.0 @@ -2247,7 +2247,7 @@ importers: version: 7.0.3(next@14.2.21)(react@18.3.1)(uploadthing@7.1.0) ai: specifier: ^4.0.0 - version: 4.0.0(react@18.3.1)(zod@3.23.8) + version: 4.0.0(react@18.3.1)(zod@3.25.76) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -2268,7 +2268,7 @@ importers: version: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0)(react@18.3.1) openai: specifier: ^4.68.4 - version: 4.68.4(zod@3.23.8) + version: 4.68.4(zod@3.25.76) react: specifier: ^18 version: 18.3.1 @@ -2285,8 +2285,8 @@ importers: specifier: ^7.1.0 version: 7.1.0(next@14.2.21)(tailwindcss@3.4.1) zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@next/bundle-analyzer': specifier: ^15.0.2 @@ -2319,8 +2319,8 @@ importers: specifier: workspace:* version: link:../../packages/trigger-sdk zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@trigger.dev/build': specifier: workspace:* @@ -2338,8 +2338,8 @@ importers: specifier: workspace:* version: link:../../packages/trigger-sdk zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@trigger.dev/build': specifier: workspace:* @@ -2385,10 +2385,10 @@ importers: version: 2.2.1 '@t3-oss/env-core': specifier: ^0.11.0 - version: 0.11.0(typescript@5.5.4)(zod@3.23.8) + version: 0.11.0(typescript@5.5.4)(zod@3.25.76) '@t3-oss/env-nextjs': specifier: ^0.10.1 - version: 0.10.1(typescript@5.5.4)(zod@3.23.8) + version: 0.10.1(typescript@5.5.4)(zod@3.25.76) '@traceloop/instrumentation-openai': specifier: ^0.10.0 version: 0.10.0(@opentelemetry/api@1.4.1) @@ -2400,7 +2400,7 @@ importers: version: 0.14.0(@sinclair/typebox@0.33.17) ai: specifier: ^3.3.24 - version: 3.3.24(openai@4.56.0)(react@19.0.0-rc.0)(svelte@5.33.14)(vue@3.5.16)(zod@3.23.8) + version: 3.3.24(openai@4.56.0)(react@19.0.0-rc.0)(svelte@5.33.14)(vue@3.5.16)(zod@3.25.76) arktype: specifier: 2.0.0-rc.17 version: 2.0.0-rc.17 @@ -2436,7 +2436,7 @@ importers: version: 1.3.6 openai: specifier: ^4.47.0 - version: 4.56.0(zod@3.23.8) + version: 4.56.0(zod@3.25.76) pg: specifier: ^8.11.5 version: 8.11.5 @@ -2492,8 +2492,8 @@ importers: specifier: ^0.0.11 version: 0.0.11 zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@opentelemetry/core': specifier: ^1.22.0 @@ -2590,51 +2590,51 @@ packages: resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} dev: false - /@ai-sdk/anthropic@1.2.4(zod@3.23.8): + /@ai-sdk/anthropic@1.2.4(zod@3.25.76): resolution: {integrity: sha512-dAN6MXvLffeFVAr2gz3RGvOTgX1KL/Yn5q1l4/Dt0TUeDjQgCt4AbbYxZZB2qIAYzQvoyAFPhlw0sB3nNizG/g==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.3(zod@3.23.8) - zod: 3.23.8 + '@ai-sdk/provider-utils': 2.2.3(zod@3.25.76) + zod: 3.25.76 dev: false - /@ai-sdk/openai@1.0.1(zod@3.23.8): + /@ai-sdk/openai@1.0.1(zod@3.25.76): resolution: {integrity: sha512-snZge8457afWlosVNUn+BG60MrxAPOOm3zmIMxJZih8tneNSiRbTVCbSzAtq/9vsnOHDe5RR83PRl85juOYEnA==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 dependencies: '@ai-sdk/provider': 1.0.0 - '@ai-sdk/provider-utils': 2.0.0(zod@3.23.8) - zod: 3.23.8 + '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) + zod: 3.25.76 dev: false - /@ai-sdk/openai@1.3.23(zod@3.23.8): + /@ai-sdk/openai@1.3.23(zod@3.25.76): resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.23.8) - zod: 3.23.8 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + zod: 3.25.76 dev: false - /@ai-sdk/openai@1.3.3(zod@3.23.8): + /@ai-sdk/openai@1.3.3(zod@3.25.76): resolution: {integrity: sha512-CH57tonLB4DwkwqwnMmTCoIOR7cNW3bP5ciyloI7rBGJS/Bolemsoo+vn5YnwkyT9O1diWJyvYeTh7A4UfiYOw==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.1(zod@3.23.8) - zod: 3.23.8 + '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) + zod: 3.25.76 dev: false - /@ai-sdk/provider-utils@1.0.17(zod@3.23.8): + /@ai-sdk/provider-utils@1.0.17(zod@3.25.76): resolution: {integrity: sha512-2VyeTH5DQ6AxqvwdyytKIeiZyYTyJffpufWjE67zM2sXMIHgYl7fivo8m5wVl6Cbf1dFPSGKq//C9s+lz+NHrQ==} engines: {node: '>=18'} peerDependencies: @@ -2647,10 +2647,10 @@ packages: eventsource-parser: 1.1.2 nanoid: 3.3.6 secure-json-parse: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/provider-utils@1.0.22(zod@3.23.8): + /@ai-sdk/provider-utils@1.0.22(zod@3.25.76): resolution: {integrity: sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==} engines: {node: '>=18'} peerDependencies: @@ -2663,10 +2663,10 @@ packages: eventsource-parser: 1.1.2 nanoid: 3.3.8 secure-json-parse: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 dev: true - /@ai-sdk/provider-utils@2.0.0(zod@3.23.8): + /@ai-sdk/provider-utils@2.0.0(zod@3.25.76): resolution: {integrity: sha512-uITgVJByhtzuQU2ZW+2CidWRmQqTUTp6KADevy+4aRnmILZxY2LCt+UZ/ZtjJqq0MffwkuQPPY21ExmFAQ6kKA==} engines: {node: '>=18'} peerDependencies: @@ -2679,10 +2679,10 @@ packages: eventsource-parser: 3.0.0 nanoid: 5.1.5 secure-json-parse: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/provider-utils@2.2.1(zod@3.23.8): + /@ai-sdk/provider-utils@2.2.1(zod@3.25.76): resolution: {integrity: sha512-BuExLp+NcpwsAVj1F4bgJuQkSqO/+roV9wM7RdIO+NVrcT8RBUTdXzf5arHt5T58VpK7bZyB2V9qigjaPHE+Dg==} engines: {node: '>=18'} peerDependencies: @@ -2691,9 +2691,9 @@ packages: '@ai-sdk/provider': 1.1.0 nanoid: 3.3.8 secure-json-parse: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 - /@ai-sdk/provider-utils@2.2.3(zod@3.23.8): + /@ai-sdk/provider-utils@2.2.3(zod@3.25.76): resolution: {integrity: sha512-o3fWTzkxzI5Af7U7y794MZkYNEsxbjLam2nxyoUZSScqkacb7vZ3EYHLh21+xCcSSzEC161C7pZAGHtC0hTUMw==} engines: {node: '>=18'} peerDependencies: @@ -2702,10 +2702,10 @@ packages: '@ai-sdk/provider': 1.1.0 nanoid: 3.3.8 secure-json-parse: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/provider-utils@2.2.8(zod@3.23.8): + /@ai-sdk/provider-utils@2.2.8(zod@3.25.76): resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} engines: {node: '>=18'} peerDependencies: @@ -2714,7 +2714,7 @@ packages: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.8 secure-json-parse: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 dev: false /@ai-sdk/provider@0.0.22: @@ -2751,7 +2751,7 @@ packages: json-schema: 0.4.0 dev: false - /@ai-sdk/react@0.0.53(react@19.0.0-rc.0)(zod@3.23.8): + /@ai-sdk/react@0.0.53(react@19.0.0-rc.0)(zod@3.25.76): resolution: {integrity: sha512-sIsmTFoR/QHvUUkltmHwP4bPjwy2vko6j/Nj8ayxLhEHs04Ug+dwXQyfA7MwgimEE3BcDQpWL8ikVj0m3ZILWQ==} engines: {node: '>=18'} peerDependencies: @@ -2763,14 +2763,14 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.40(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.17(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.40(zod@3.25.76) react: 19.0.0-rc.0 swr: 2.2.5(react@19.0.0-rc.0) - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/react@0.0.70(react@18.3.1)(zod@3.23.8): + /@ai-sdk/react@0.0.70(react@18.3.1)(zod@3.25.76): resolution: {integrity: sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==} engines: {node: '>=18'} peerDependencies: @@ -2782,15 +2782,15 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.22(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.50(zod@3.25.76) react: 18.3.1 swr: 2.2.5(react@18.3.1) throttleit: 2.1.0 - zod: 3.23.8 + zod: 3.25.76 dev: true - /@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.23.8): + /@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.25.76): resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==} engines: {node: '>=18'} peerDependencies: @@ -2802,15 +2802,15 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 2.0.0(zod@3.23.8) - '@ai-sdk/ui-utils': 1.0.0(zod@3.23.8) + '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) + '@ai-sdk/ui-utils': 1.0.0(zod@3.25.76) react: 18.3.1 swr: 2.2.5(react@18.3.1) throttleit: 2.1.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/react@1.2.12(react@18.2.0)(zod@3.23.8): + /@ai-sdk/react@1.2.12(react@18.2.0)(zod@3.25.76): resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} engines: {node: '>=18'} peerDependencies: @@ -2820,15 +2820,15 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.11(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) react: 18.2.0 swr: 2.2.5(react@18.2.0) throttleit: 2.1.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/react@1.2.2(react@18.3.1)(zod@3.23.8): + /@ai-sdk/react@1.2.2(react@18.3.1)(zod@3.25.76): resolution: {integrity: sha512-rxyNTFjUd3IilVOJFuUJV5ytZBYAIyRi50kFS2gNmSEiG4NHMBBm31ddrxI/i86VpY8gzZVp1/igtljnWBihUA==} engines: {node: '>=18'} peerDependencies: @@ -2838,15 +2838,15 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 2.2.1(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.1(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) + '@ai-sdk/ui-utils': 1.2.1(zod@3.25.76) react: 18.3.1 swr: 2.2.5(react@18.3.1) throttleit: 2.1.0 - zod: 3.23.8 + zod: 3.25.76 dev: true - /@ai-sdk/react@1.2.2(react@19.0.0)(zod@3.23.8): + /@ai-sdk/react@1.2.2(react@19.0.0)(zod@3.25.76): resolution: {integrity: sha512-rxyNTFjUd3IilVOJFuUJV5ytZBYAIyRi50kFS2gNmSEiG4NHMBBm31ddrxI/i86VpY8gzZVp1/igtljnWBihUA==} engines: {node: '>=18'} peerDependencies: @@ -2856,15 +2856,15 @@ packages: zod: optional: true dependencies: - '@ai-sdk/provider-utils': 2.2.1(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.1(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) + '@ai-sdk/ui-utils': 1.2.1(zod@3.25.76) react: 19.0.0 swr: 2.2.5(react@19.0.0) throttleit: 2.1.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@ai-sdk/solid@0.0.43(zod@3.23.8): + /@ai-sdk/solid@0.0.43(zod@3.25.76): resolution: {integrity: sha512-7PlPLaeMAu97oOY2gjywvKZMYHF+GDfUxYNcuJ4AZ3/MRBatzs/U2r4ClT1iH8uMOcMg02RX6UKzP5SgnUBjVw==} engines: {node: '>=18'} peerDependencies: @@ -2873,13 +2873,13 @@ packages: solid-js: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.40(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.17(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.40(zod@3.25.76) transitivePeerDependencies: - zod dev: false - /@ai-sdk/solid@0.0.54(zod@3.23.8): + /@ai-sdk/solid@0.0.54(zod@3.25.76): resolution: {integrity: sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==} engines: {node: '>=18'} peerDependencies: @@ -2888,13 +2888,13 @@ packages: solid-js: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.22(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.50(zod@3.25.76) transitivePeerDependencies: - zod dev: true - /@ai-sdk/svelte@0.0.45(svelte@5.33.14)(zod@3.23.8): + /@ai-sdk/svelte@0.0.45(svelte@5.33.14)(zod@3.25.76): resolution: {integrity: sha512-w5Sdl0ArFIM3Fp8BbH4TUvlrS84WP/jN/wC1+fghMOXd7ceVO3Yhs9r71wTqndhgkLC7LAEX9Ll7ZEPfW9WBDA==} engines: {node: '>=18'} peerDependencies: @@ -2903,15 +2903,15 @@ packages: svelte: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.40(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.17(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.40(zod@3.25.76) sswr: 2.1.0(svelte@5.33.14) svelte: 5.33.14 transitivePeerDependencies: - zod dev: false - /@ai-sdk/svelte@0.0.57(svelte@5.33.14)(zod@3.23.8): + /@ai-sdk/svelte@0.0.57(svelte@5.33.14)(zod@3.25.76): resolution: {integrity: sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==} engines: {node: '>=18'} peerDependencies: @@ -2920,15 +2920,15 @@ packages: svelte: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.22(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.50(zod@3.25.76) sswr: 2.1.0(svelte@5.33.14) svelte: 5.33.14 transitivePeerDependencies: - zod dev: true - /@ai-sdk/ui-utils@0.0.40(zod@3.23.8): + /@ai-sdk/ui-utils@0.0.40(zod@3.25.76): resolution: {integrity: sha512-f0eonPUBO13pIO8jA9IGux7IKMeqpvWK22GBr3tOoSRnO5Wg5GEpXZU1V0Po+unpeZHyEPahrWbj5JfXcyWCqw==} engines: {node: '>=18'} peerDependencies: @@ -2938,14 +2938,14 @@ packages: optional: true dependencies: '@ai-sdk/provider': 0.0.22 - '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.17(zod@3.25.76) json-schema: 0.4.0 secure-json-parse: 2.7.0 - zod: 3.23.8 - zod-to-json-schema: 3.23.2(zod@3.23.8) + zod: 3.25.76 + zod-to-json-schema: 3.23.2(zod@3.25.76) dev: false - /@ai-sdk/ui-utils@0.0.50(zod@3.23.8): + /@ai-sdk/ui-utils@0.0.50(zod@3.25.76): resolution: {integrity: sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==} engines: {node: '>=18'} peerDependencies: @@ -2955,14 +2955,14 @@ packages: optional: true dependencies: '@ai-sdk/provider': 0.0.26 - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.22(zod@3.25.76) json-schema: 0.4.0 secure-json-parse: 2.7.0 - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) dev: true - /@ai-sdk/ui-utils@1.0.0(zod@3.23.8): + /@ai-sdk/ui-utils@1.0.0(zod@3.25.76): resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==} engines: {node: '>=18'} peerDependencies: @@ -2972,35 +2972,35 @@ packages: optional: true dependencies: '@ai-sdk/provider': 1.0.0 - '@ai-sdk/provider-utils': 2.0.0(zod@3.23.8) - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) + '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) dev: false - /@ai-sdk/ui-utils@1.2.1(zod@3.23.8): + /@ai-sdk/ui-utils@1.2.1(zod@3.25.76): resolution: {integrity: sha512-BzvMbYm7LHBlbWuLlcG1jQh4eu14MGpz7L+wrGO1+F4oQ+O0fAjgUSNwPWGlZpKmg4NrcVq/QLmxiVJrx2R4Ew==} engines: {node: '>=18'} peerDependencies: zod: ^3.23.8 dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.1(zod@3.23.8) - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) - /@ai-sdk/ui-utils@1.2.11(zod@3.23.8): + /@ai-sdk/ui-utils@1.2.11(zod@3.25.76): resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} engines: {node: '>=18'} peerDependencies: zod: ^3.23.8 dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.23.8) - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) dev: false - /@ai-sdk/vue@0.0.45(vue@3.5.16)(zod@3.23.8): + /@ai-sdk/vue@0.0.45(vue@3.5.16)(zod@3.25.76): resolution: {integrity: sha512-bqeoWZqk88TQmfoPgnFUKkrvhOIcOcSH5LMPgzZ8XwDqz5tHHrMHzpPfHCj7XyYn4ROTFK/2kKdC/ta6Ko0fMw==} engines: {node: '>=18'} peerDependencies: @@ -3009,15 +3009,15 @@ packages: vue: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.40(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.17(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.40(zod@3.25.76) swrv: 1.0.4(vue@3.5.16) vue: 3.5.16(typescript@5.5.4) transitivePeerDependencies: - zod dev: false - /@ai-sdk/vue@0.0.59(vue@3.5.16)(zod@3.23.8): + /@ai-sdk/vue@0.0.59(vue@3.5.16)(zod@3.25.76): resolution: {integrity: sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==} engines: {node: '>=18'} peerDependencies: @@ -3026,8 +3026,8 @@ packages: vue: optional: true dependencies: - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.22(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.50(zod@3.25.76) swrv: 1.0.4(vue@3.5.16) vue: 3.5.16(typescript@5.5.4) transitivePeerDependencies: @@ -6073,14 +6073,14 @@ packages: react: 18.2.0 dev: false - /@conform-to/zod@0.9.2(@conform-to/dom@0.9.2)(zod@3.23.8): + /@conform-to/zod@0.9.2(@conform-to/dom@0.9.2)(zod@3.25.76): resolution: {integrity: sha512-treG9ZcuNuRERQ1uYvJSWT0zZuqHnYTzRwucg20+/WdjgKNSb60Br+Cy6BAHvVQ8dN6wJsGkHenkX2mSVw3xOA==} peerDependencies: '@conform-to/dom': 0.9.2 zod: ^3.21.0 dependencies: '@conform-to/dom': 0.9.2 - zod: 3.23.8 + zod: 3.25.76 dev: false /@connectrpc/connect-node@1.4.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.4.0): @@ -9295,8 +9295,8 @@ packages: express-rate-limit: 7.5.0(express@5.0.1) pkce-challenge: 4.1.0 raw-body: 3.0.0 - zod: 3.23.8 - zod-to-json-schema: 3.24.3(zod@3.23.8) + zod: 3.25.76 + zod-to-json-schema: 3.24.3(zod@3.25.76) transitivePeerDependencies: - supports-color dev: false @@ -19410,7 +19410,7 @@ packages: defer-to-connect: 1.1.3 dev: true - /@t3-oss/env-core@0.10.1(typescript@5.5.4)(zod@3.23.8): + /@t3-oss/env-core@0.10.1(typescript@5.5.4)(zod@3.25.76): resolution: {integrity: sha512-GcKZiCfWks5CTxhezn9k5zWX3sMDIYf6Kaxy2Gx9YEQftFcz8hDRN56hcbylyAO3t4jQnQ5ifLawINsNgCDpOg==} peerDependencies: typescript: '>=5.0.0' @@ -19420,10 +19420,10 @@ packages: optional: true dependencies: typescript: 5.5.4 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@t3-oss/env-core@0.11.0(typescript@5.5.4)(zod@3.23.8): + /@t3-oss/env-core@0.11.0(typescript@5.5.4)(zod@3.25.76): resolution: {integrity: sha512-PSalC5bG0a7XbyoLydiQdAnx3gICX6IQNctvh+TyLrdFxsxgocdj9Ui7sd061UlBzi+z4aIGjnem1kZx9QtUgQ==} peerDependencies: typescript: '>=5.0.0' @@ -19433,10 +19433,10 @@ packages: optional: true dependencies: typescript: 5.5.4 - zod: 3.23.8 + zod: 3.25.76 dev: false - /@t3-oss/env-nextjs@0.10.1(typescript@5.5.4)(zod@3.23.8): + /@t3-oss/env-nextjs@0.10.1(typescript@5.5.4)(zod@3.25.76): resolution: {integrity: sha512-iy2qqJLnFh1RjEWno2ZeyTu0ufomkXruUsOZludzDIroUabVvHsrSjtkHqwHp1/pgPUzN3yBRHMILW162X7x2Q==} peerDependencies: typescript: '>=5.0.0' @@ -19445,9 +19445,9 @@ packages: typescript: optional: true dependencies: - '@t3-oss/env-core': 0.10.1(typescript@5.5.4)(zod@3.23.8) + '@t3-oss/env-core': 0.10.1(typescript@5.5.4)(zod@3.25.76) typescript: 5.5.4 - zod: 3.23.8 + zod: 3.25.76 dev: false /@tabler/icons-react@2.47.0(react@18.2.0): @@ -19673,7 +19673,7 @@ packages: dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) graphql: 16.6.0 - zod: 3.23.8 + zod: 3.25.76 dev: false /@testcontainers/postgresql@10.28.0: @@ -20767,7 +20767,7 @@ packages: /@unkey/error@0.2.0: resolution: {integrity: sha512-DFGb4A7SrusZPP0FYuRIF0CO+Gi4etLUAEJ6EKc+TKYmscL0nEJ2Pr38FyX9MvjI4Wx5l35Wc9KsBjMm9Ybh7w==} dependencies: - zod: 3.23.8 + zod: 3.25.76 dev: false /@uploadthing/mime-types@0.3.0: @@ -21527,7 +21527,7 @@ packages: resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==} dev: true - /ai@3.3.24(openai@4.56.0)(react@19.0.0-rc.0)(svelte@5.33.14)(vue@3.5.16)(zod@3.23.8): + /ai@3.3.24(openai@4.56.0)(react@19.0.0-rc.0)(svelte@5.33.14)(vue@3.5.16)(zod@3.25.76): resolution: {integrity: sha512-hhyczvEdCQeeEMWBWP4Af8k1YIzsheC+dHv6lAsti8NBiOnySFhnjS1sTiIrLyuCgciHXoFYLhlA2+/3AtBLAQ==} engines: {node: '>=18'} peerDependencies: @@ -21549,29 +21549,29 @@ packages: optional: true dependencies: '@ai-sdk/provider': 0.0.22 - '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) - '@ai-sdk/react': 0.0.53(react@19.0.0-rc.0)(zod@3.23.8) - '@ai-sdk/solid': 0.0.43(zod@3.23.8) - '@ai-sdk/svelte': 0.0.45(svelte@5.33.14)(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.40(zod@3.23.8) - '@ai-sdk/vue': 0.0.45(vue@3.5.16)(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.17(zod@3.25.76) + '@ai-sdk/react': 0.0.53(react@19.0.0-rc.0)(zod@3.25.76) + '@ai-sdk/solid': 0.0.43(zod@3.25.76) + '@ai-sdk/svelte': 0.0.45(svelte@5.33.14)(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.40(zod@3.25.76) + '@ai-sdk/vue': 0.0.45(vue@3.5.16)(zod@3.25.76) '@opentelemetry/api': 1.9.0 eventsource-parser: 1.1.2 json-schema: 0.4.0 jsondiffpatch: 0.6.0 nanoid: 3.3.6 - openai: 4.56.0(zod@3.23.8) + openai: 4.56.0(zod@3.25.76) react: 19.0.0-rc.0 secure-json-parse: 2.7.0 svelte: 5.33.14 - zod: 3.23.8 - zod-to-json-schema: 3.23.2(zod@3.23.8) + zod: 3.25.76 + zod-to-json-schema: 3.23.2(zod@3.25.76) transitivePeerDependencies: - solid-js - vue dev: false - /ai@3.4.33(react@18.3.1)(svelte@5.33.14)(vue@3.5.16)(zod@3.23.8): + /ai@3.4.33(react@18.3.1)(svelte@5.33.14)(vue@3.5.16)(zod@3.25.76): resolution: {integrity: sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==} engines: {node: '>=18'} peerDependencies: @@ -21593,12 +21593,12 @@ packages: optional: true dependencies: '@ai-sdk/provider': 0.0.26 - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) - '@ai-sdk/react': 0.0.70(react@18.3.1)(zod@3.23.8) - '@ai-sdk/solid': 0.0.54(zod@3.23.8) - '@ai-sdk/svelte': 0.0.57(svelte@5.33.14)(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.50(zod@3.23.8) - '@ai-sdk/vue': 0.0.59(vue@3.5.16)(zod@3.23.8) + '@ai-sdk/provider-utils': 1.0.22(zod@3.25.76) + '@ai-sdk/react': 0.0.70(react@18.3.1)(zod@3.25.76) + '@ai-sdk/solid': 0.0.54(zod@3.25.76) + '@ai-sdk/svelte': 0.0.57(svelte@5.33.14)(zod@3.25.76) + '@ai-sdk/ui-utils': 0.0.50(zod@3.25.76) + '@ai-sdk/vue': 0.0.59(vue@3.5.16)(zod@3.25.76) '@opentelemetry/api': 1.9.0 eventsource-parser: 1.1.2 json-schema: 0.4.0 @@ -21606,14 +21606,14 @@ packages: react: 18.3.1 secure-json-parse: 2.7.0 svelte: 5.33.14 - zod: 3.23.8 - zod-to-json-schema: 3.24.3(zod@3.23.8) + zod: 3.25.76 + zod-to-json-schema: 3.24.3(zod@3.25.76) transitivePeerDependencies: - solid-js - vue dev: true - /ai@4.0.0(react@18.3.1)(zod@3.23.8): + /ai@4.0.0(react@18.3.1)(zod@3.25.76): resolution: {integrity: sha512-cqf2GCaXnOPhUU+Ccq6i+5I0jDjnFkzfq7t6mc0SUSibSa1wDPn5J4p8+Joh2fDGDYZOJ44rpTW9hSs40rXNAw==} engines: {node: '>=18'} peerDependencies: @@ -21626,17 +21626,17 @@ packages: optional: true dependencies: '@ai-sdk/provider': 1.0.0 - '@ai-sdk/provider-utils': 2.0.0(zod@3.23.8) - '@ai-sdk/react': 1.0.0(react@18.3.1)(zod@3.23.8) - '@ai-sdk/ui-utils': 1.0.0(zod@3.23.8) + '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) + '@ai-sdk/react': 1.0.0(react@18.3.1)(zod@3.25.76) + '@ai-sdk/ui-utils': 1.0.0(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 react: 18.3.1 - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) dev: false - /ai@4.2.5(react@18.3.1)(zod@3.23.8): + /ai@4.2.5(react@18.3.1)(zod@3.25.76): resolution: {integrity: sha512-URJEslI3cgF/atdTJHtz+Sj0W1JTmiGmD3znw9KensL3qV605odktDim+GTazNJFPR4QaIu1lUio5b8RymvOjA==} engines: {node: '>=18'} peerDependencies: @@ -21647,16 +21647,16 @@ packages: optional: true dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.1(zod@3.23.8) - '@ai-sdk/react': 1.2.2(react@18.3.1)(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.1(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) + '@ai-sdk/react': 1.2.2(react@18.3.1)(zod@3.25.76) + '@ai-sdk/ui-utils': 1.2.1(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 react: 18.3.1 - zod: 3.23.8 + zod: 3.25.76 dev: true - /ai@4.2.5(react@19.0.0)(zod@3.23.8): + /ai@4.2.5(react@19.0.0)(zod@3.25.76): resolution: {integrity: sha512-URJEslI3cgF/atdTJHtz+Sj0W1JTmiGmD3znw9KensL3qV605odktDim+GTazNJFPR4QaIu1lUio5b8RymvOjA==} engines: {node: '>=18'} peerDependencies: @@ -21667,16 +21667,16 @@ packages: optional: true dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.1(zod@3.23.8) - '@ai-sdk/react': 1.2.2(react@19.0.0)(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.1(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) + '@ai-sdk/react': 1.2.2(react@19.0.0)(zod@3.25.76) + '@ai-sdk/ui-utils': 1.2.1(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 react: 19.0.0 - zod: 3.23.8 + zod: 3.25.76 dev: false - /ai@4.3.19(react@18.2.0)(zod@3.23.8): + /ai@4.3.19(react@18.2.0)(zod@3.25.76): resolution: {integrity: sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==} engines: {node: '>=18'} peerDependencies: @@ -21687,13 +21687,13 @@ packages: optional: true dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.23.8) - '@ai-sdk/react': 1.2.12(react@18.2.0)(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.11(zod@3.23.8) + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/react': 1.2.12(react@18.2.0)(zod@3.25.76) + '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 react: 18.2.0 - zod: 3.23.8 + zod: 3.25.76 dev: false /ajv-formats@2.1.1(ajv@8.17.1): @@ -22154,9 +22154,9 @@ packages: js-yaml: 4.1.0 linear-sum-assignment: 1.0.7 mustache: 4.2.0 - openai: 4.97.0(ws@8.12.0)(zod@3.23.8) - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) + openai: 4.97.0(ws@8.12.0)(zod@3.25.76) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - encoding - ws @@ -28306,7 +28306,7 @@ packages: dependencies: '@types/uuid': 10.0.0 commander: 10.0.1 - openai: 4.68.4(zod@3.23.8) + openai: 4.68.4(zod@3.25.76) p-queue: 6.6.2 p-retry: 4.6.2 semver: 7.6.3 @@ -31029,7 +31029,7 @@ packages: - encoding dev: false - /openai@4.56.0(zod@3.23.8): + /openai@4.56.0(zod@3.25.76): resolution: {integrity: sha512-zcag97+3bG890MNNa0DQD9dGmmTWL8unJdNkulZzWRXrl+QeD+YkBI4H58rJcwErxqGK6a0jVPZ4ReJjhDGcmw==} hasBin: true peerDependencies: @@ -31045,12 +31045,12 @@ packages: form-data-encoder: 1.7.2 formdata-node: 4.4.1 node-fetch: 2.6.12 - zod: 3.23.8 + zod: 3.25.76 transitivePeerDependencies: - encoding dev: false - /openai@4.68.4(zod@3.23.8): + /openai@4.68.4(zod@3.25.76): resolution: {integrity: sha512-LRinV8iU9VQplkr25oZlyrsYGPGasIwYN8KFMAAFTHHLHjHhejtJ5BALuLFrkGzY4wfbKhOhuT+7lcHZ+F3iEA==} hasBin: true peerDependencies: @@ -31066,12 +31066,12 @@ packages: form-data-encoder: 1.7.2 formdata-node: 4.4.1 node-fetch: 2.6.12 - zod: 3.23.8 + zod: 3.25.76 transitivePeerDependencies: - encoding dev: false - /openai@4.97.0(ws@8.12.0)(zod@3.23.8): + /openai@4.97.0(ws@8.12.0)(zod@3.25.76): resolution: {integrity: sha512-LRoiy0zvEf819ZUEJhgfV8PfsE8G5WpQi4AwA1uCV8SKvvtXQkoWUFkepD6plqyJQRghy2+AEPQ07FrJFKHZ9Q==} hasBin: true peerDependencies: @@ -31091,7 +31091,7 @@ packages: formdata-node: 4.4.1 node-fetch: 2.6.12 ws: 8.12.0 - zod: 3.23.8 + zod: 3.25.76 transitivePeerDependencies: - encoding @@ -33782,7 +33782,7 @@ packages: react: 18.2.0 dev: false - /remix-utils@7.7.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/router@1.15.3)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.23.8): + /remix-utils@7.7.0(@remix-run/node@2.1.0)(@remix-run/react@2.1.0)(@remix-run/router@1.15.3)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76): resolution: {integrity: sha512-J8NhP044nrNIam/xOT1L9a4RQ9FSaA2wyrUwmN8ZT+c/+CdAAf70yfaLnvMyKcV5U+8BcURQ/aVbth77sT6jGA==} engines: {node: '>=18.0.0'} peerDependencies: @@ -33821,7 +33821,7 @@ packages: intl-parse-accept-language: 1.0.0 react: 18.2.0 type-fest: 4.33.0 - zod: 3.23.8 + zod: 3.25.76 dev: false /remove-accents@0.5.0: @@ -38394,30 +38394,23 @@ packages: /zod-error@1.5.0: resolution: {integrity: sha512-zzopKZ/skI9iXpqCEPj+iLCKl9b88E43ehcU+sbRoHuwGd9F1IDVGQ70TyO6kmfiRL1g4IXkjsXK+g1gLYl4WQ==} dependencies: - zod: 3.23.8 + zod: 3.25.76 dev: false - /zod-to-json-schema@3.23.2(zod@3.23.8): + /zod-to-json-schema@3.23.2(zod@3.25.76): resolution: {integrity: sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==} peerDependencies: zod: ^3.23.3 dependencies: - zod: 3.23.8 + zod: 3.25.76 dev: false - /zod-to-json-schema@3.24.3(zod@3.23.8): + /zod-to-json-schema@3.24.3(zod@3.25.76): resolution: {integrity: sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==} peerDependencies: zod: ^3.24.1 dependencies: - zod: 3.23.8 - - /zod-to-json-schema@3.24.5(zod@3.23.8): - resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} - peerDependencies: - zod: ^3.24.1 - dependencies: - zod: 3.23.8 + zod: 3.25.76 /zod-to-json-schema@3.24.5(zod@3.25.76): resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} @@ -38425,19 +38418,19 @@ packages: zod: ^3.24.1 dependencies: zod: 3.25.76 - dev: false - /zod-validation-error@1.5.0(zod@3.23.8): + /zod-validation-error@1.5.0(zod@3.25.76): resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==} engines: {node: '>=16.0.0'} peerDependencies: zod: ^3.18.0 dependencies: - zod: 3.23.8 + zod: 3.25.76 dev: false /zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + dev: false /zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} diff --git a/references/d3-chat/package.json b/references/d3-chat/package.json index 2d14a1d22ec..22e5a9dae9d 100644 --- a/references/d3-chat/package.json +++ b/references/d3-chat/package.json @@ -47,7 +47,7 @@ "react-markdown": "^10.1.0", "tailwind-merge": "^3.1.0", "tw-animate-css": "^1.2.4", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/references/d3-openai-agents/package.json b/references/d3-openai-agents/package.json index e94dc26a21f..91309ea5a30 100644 --- a/references/d3-openai-agents/package.json +++ b/references/d3-openai-agents/package.json @@ -33,7 +33,7 @@ "react-dom": "^19.0.0", "tailwind-merge": "^3.0.2", "tw-animate-css": "^1.2.4", - "zod": "3.23.8", + "zod": "3.25.76", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { diff --git a/references/hello-world/package.json b/references/hello-world/package.json index 35299405299..dea86c6b7af 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -13,7 +13,7 @@ "puppeteer-core": "^24.15.0", "replicate": "^1.0.1", "yup": "^1.6.1", - "zod": "3.23.8", + "zod": "3.25.76", "@sinclair/typebox": "^0.34.3" }, "scripts": { diff --git a/references/nextjs-realtime/package.json b/references/nextjs-realtime/package.json index 9e9edd34a16..4c2736a020f 100644 --- a/references/nextjs-realtime/package.json +++ b/references/nextjs-realtime/package.json @@ -37,7 +37,7 @@ "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", "uploadthing": "^7.1.0", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@next/bundle-analyzer": "^15.0.2", diff --git a/references/python-catalog/package.json b/references/python-catalog/package.json index 171a1e3ea42..1c4f5859262 100644 --- a/references/python-catalog/package.json +++ b/references/python-catalog/package.json @@ -10,7 +10,7 @@ "dependencies": { "@trigger.dev/sdk": "workspace:*", "@trigger.dev/python": "workspace:*", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@trigger.dev/build": "workspace:*", diff --git a/references/test-tasks/package.json b/references/test-tasks/package.json index 6739b44b301..9ed2635da1a 100644 --- a/references/test-tasks/package.json +++ b/references/test-tasks/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "@trigger.dev/sdk": "workspace:*", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@trigger.dev/build": "workspace:*", diff --git a/references/v3-catalog/package.json b/references/v3-catalog/package.json index 5b2315fe67b..b6375201975 100644 --- a/references/v3-catalog/package.json +++ b/references/v3-catalog/package.json @@ -62,7 +62,7 @@ "yt-dlp-wrap": "^2.3.12", "yup": "^1.4.0", "zip-node-addon": "^0.0.11", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@opentelemetry/api": "^1.8.0", From a782813c6c840e9340e8403d18bb4b492150c1f2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 15:56:01 +0100 Subject: [PATCH 053/641] =?UTF-8?q?Regions=20=E2=80=93=20dashboard=20page,?= =?UTF-8?q?=20switching=20default,=20allowedMasterQueues=20(#2354)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial Regions page * Fix for bad attribute name * Switching regions is working. Some style improvements * Use a dialog for confirmation, not great yet * Added allowedMasterQueues * Improves flag icons * Improves the region switch modal with more info * Adds “suggest a region” table row * New icons for the buttons * Improved the tooltip information * New “small” badge style * Make the default badge live in its column * Better DO icon size * Remove unused export of regions options * Show upgrade message for free users to get static IPs * Admins can view all regions and switch at will --------- Co-authored-by: James Ritchie --- .../app/assets/icons/CloudProviderIcon.tsx | 76 +++ apps/webapp/app/assets/icons/RegionIcons.tsx | 106 +++++ apps/webapp/app/components/CloudProvider.tsx | 10 + .../app/components/navigation/SideMenu.tsx | 13 +- .../app/components/primitives/Badge.tsx | 2 + .../presenters/v3/RegionsPresenter.server.ts | 159 +++++++ .../route.tsx | 435 ++++++++++++++++++ apps/webapp/app/routes/resources.feedback.ts | 1 + apps/webapp/app/utils/pathBuilder.ts | 8 + .../v3/services/setDefaultRegion.server.ts | 58 +++ .../migration.sql | 5 + .../migration.sql | 2 + .../database/prisma/schema.prisma | 9 + 13 files changed, 882 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/assets/icons/CloudProviderIcon.tsx create mode 100644 apps/webapp/app/assets/icons/RegionIcons.tsx create mode 100644 apps/webapp/app/components/CloudProvider.tsx create mode 100644 apps/webapp/app/presenters/v3/RegionsPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx create mode 100644 apps/webapp/app/v3/services/setDefaultRegion.server.ts create mode 100644 internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql diff --git a/apps/webapp/app/assets/icons/CloudProviderIcon.tsx b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx new file mode 100644 index 00000000000..6c162528247 --- /dev/null +++ b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx @@ -0,0 +1,76 @@ +export function CloudProviderIcon({ + provider, + className, +}: { + provider: "aws" | "digitalocean" | (string & {}); + className?: string; +}) { + switch (provider) { + case "aws": + return ; + case "digitalocean": + return ; + default: + return null; + } +} + +export function AWS({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function DigitalOcean({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RegionIcons.tsx b/apps/webapp/app/assets/icons/RegionIcons.tsx new file mode 100644 index 00000000000..098d5bc98ce --- /dev/null +++ b/apps/webapp/app/assets/icons/RegionIcons.tsx @@ -0,0 +1,106 @@ +export function FlagIcon({ + region, + className, +}: { + region: "usa" | "europe" | (string & {}); + className?: string; +}) { + switch (region) { + case "usa": + return ; + case "europe": + return ; + default: + return null; + } +} + +export function FlagUSA({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function FlagEurope({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/CloudProvider.tsx b/apps/webapp/app/components/CloudProvider.tsx new file mode 100644 index 00000000000..acf8cff5506 --- /dev/null +++ b/apps/webapp/app/components/CloudProvider.tsx @@ -0,0 +1,10 @@ +export function cloudProviderTitle(provider: "aws" | "digitalocean" | (string & {})) { + switch (provider) { + case "aws": + return "Amazon Web Services"; + case "digitalocean": + return "Digital Ocean"; + default: + return provider; + } +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 20d2a821db8..4e0adf97947 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -10,6 +10,7 @@ import { CogIcon, FolderIcon, FolderOpenIcon, + GlobeAmericasIcon, IdentificationIcon, KeyIcon, PlusIcon, @@ -22,6 +23,7 @@ import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; @@ -46,6 +48,7 @@ import { organizationPath, organizationSettingsPath, organizationTeamPath, + regionsPath, v3ApiKeysPath, v3BatchesPath, v3BillingPath, @@ -87,8 +90,6 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { ListChecks } from "lucide-react"; -import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -311,6 +312,14 @@ export function SideMenu({ data-action="preview-branches" badge={} /> + } + />

+ + + Region + Cloud Provider + Location + Static IPs + {isAdmin && Admin} + + + When you trigger a run it will execute in your default region, unless + you override the region when triggering. + + + Read docs + + + } + > + Default region + + + + + {regions.length === 0 ? ( + + There are no regions for this project + + ) : ( + regions.map((region) => { + return ( + + + + + + {region.cloudProvider ? ( + + + {cloudProviderTitle(region.cloudProvider)} + + ) : ( + "–" + )} + + + + {region.location ? ( + + ) : null} + {region.description ?? "–"} + + + + {region.staticIPs === null ? ( + + Unlock static IPs + + ) : region.staticIPs !== undefined ? ( + + ) : ( + "Not available" + )} + + {isAdmin && ( + {region.isHidden ? "Hidden" : "Visible"} + )} + {region.isDefault ? ( + + + Default + + + ) : ( + + } + /> + )} + + ); + }) + )} + + + + Suggest a new region + + + Suggest a region… + + } + defaultValue="region" + /> + } + /> + + +
+ + + )} + + +
+ ); +} + +function SetDefaultDialog({ + regions, + newDefaultRegion, +}: { + regions: Region[]; + newDefaultRegion: Region; +}) { + const [isOpen, setIsOpen] = useState(false); + const currentDefaultRegion = regions.find((r) => r.isDefault); + + return ( + + + + + + + Set as default region + + + + Are you sure you want to set {newDefaultRegion.name} as your new default region? + + +
+
+
+ Current default +
+
+ {currentDefaultRegion?.name ?? "–"} +
+
+ + {currentDefaultRegion?.cloudProvider ? ( + <> + + {cloudProviderTitle(currentDefaultRegion.cloudProvider)} + + ) : ( + "–" + )} + +
+
+ + {currentDefaultRegion?.location ? ( + + ) : null} + {currentDefaultRegion?.description ?? "–"} + +
+
+ + {/* Middle column with arrow */} +
+
+ +
+
+ + {/* Right column */} +
+
+ New default +
+
+ {newDefaultRegion.name} +
+
+ + {newDefaultRegion.cloudProvider ? ( + <> + + {cloudProviderTitle(newDefaultRegion.cloudProvider)} + + ) : ( + "–" + )} + +
+
+ + {newDefaultRegion.location ? ( + + ) : null} + {newDefaultRegion.description ?? "–"} + +
+
+
+ + + Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "} + override when triggering. + +
+ + + + + + +
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.feedback.ts b/apps/webapp/app/routes/resources.feedback.ts index 6ea572a91c3..a6271c9d5ad 100644 --- a/apps/webapp/app/routes/resources.feedback.ts +++ b/apps/webapp/app/routes/resources.feedback.ts @@ -15,6 +15,7 @@ export const feedbackTypeLabel = { enterprise: "Enterprise enquiry", feedback: "General feedback", concurrency: "Increase my concurrency", + region: "Suggest a new region", }; export type FeedbackType = keyof typeof feedbackTypeLabel; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index cfb77f3437d..9ff7ff9c1e7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -453,6 +453,14 @@ export function branchesPath( return `${v3EnvironmentPath(organization, project, environment)}/branches`; } +export function regionsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/regions`; +} + export function v3BillingPath(organization: OrgForPath, message?: string) { return `${organizationPath(organization)}/settings/billing${ message ? `?message=${encodeURIComponent(message)}` : "" diff --git a/apps/webapp/app/v3/services/setDefaultRegion.server.ts b/apps/webapp/app/v3/services/setDefaultRegion.server.ts new file mode 100644 index 00000000000..8e4eb5b2587 --- /dev/null +++ b/apps/webapp/app/v3/services/setDefaultRegion.server.ts @@ -0,0 +1,58 @@ +import { BaseService, ServiceValidationError } from "./baseService.server"; + +export class SetDefaultRegionService extends BaseService { + public async call({ + projectId, + regionId, + isAdmin = false, + }: { + projectId: string; + regionId: string; + isAdmin?: boolean; + }) { + const workerGroup = await this._prisma.workerInstanceGroup.findFirst({ + where: { + id: regionId, + }, + }); + + if (!workerGroup) { + throw new ServiceValidationError("Region not found"); + } + + const project = await this._prisma.project.findFirst({ + where: { + id: projectId, + }, + }); + + if (!project) { + throw new ServiceValidationError("Project not found"); + } + + // If their project is restricted, only allow them to set default regions that are allowed + if (!isAdmin) { + if (project.allowedMasterQueues.length > 0) { + if (!project.allowedMasterQueues.includes(workerGroup.masterQueue)) { + throw new ServiceValidationError("You're not allowed to set this region as default"); + } + } else if (workerGroup.hidden) { + throw new ServiceValidationError("This region is not available to you"); + } + } + + await this._prisma.project.update({ + where: { + id: projectId, + }, + data: { + defaultWorkerGroupId: regionId, + }, + }); + + return { + id: workerGroup.id, + name: workerGroup.name, + }; + } +} diff --git a/internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql b/internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql new file mode 100644 index 00000000000..d1509fea9a1 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "WorkerInstanceGroup" +ADD COLUMN "cloudProvider" TEXT, +ADD COLUMN "location" TEXT, +ADD COLUMN "staticIPs" TEXT; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql b/internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql new file mode 100644 index 00000000000..4a1d3b9403a --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "allowedMasterQueues" TEXT[] DEFAULT ARRAY[]::TEXT[]; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index ce12dcf7c78..11018e20a42 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -335,6 +335,9 @@ model Project { defaultWorkerGroup WorkerInstanceGroup? @relation("ProjectDefaultWorkerGroup", fields: [defaultWorkerGroupId], references: [id]) defaultWorkerGroupId String? + /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) + allowedMasterQueues String[] @default([]) + environments RuntimeEnvironment[] backgroundWorkers BackgroundWorker[] backgroundWorkerTasks BackgroundWorkerTask[] @@ -1142,6 +1145,7 @@ model WorkerInstanceGroup { /// If unmanged, it will be prefixed with the project ID e.g. "project_1-us-east-1" masterQueue String @unique + /// "N. Virginia, USA", "Frankfurt, Germany", etc. Used for display purposes description String? hidden Boolean @default(false) @@ -1159,6 +1163,11 @@ model WorkerInstanceGroup { project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String? + cloudProvider String? + /// "usa", "europe", etc. Used like a pseudo enum for things like flags + location String? + staticIPs String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } From 3cd7dd86d422f2ffcef3ec1f36052cb08b9422d0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 17:18:59 +0100 Subject: [PATCH 054/641] Fixed code example in reduce spend docs page (#2350) --- docs/how-to-reduce-your-spend.mdx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/how-to-reduce-your-spend.mdx b/docs/how-to-reduce-your-spend.mdx index 53bac280491..a65af5ce470 100644 --- a/docs/how-to-reduce-your-spend.mdx +++ b/docs/how-to-reduce-your-spend.mdx @@ -104,12 +104,10 @@ Sometimes it's more efficient to do more work in a single task than split across export const processItems = task({ id: "process-items", run: async (payload: { items: string[] }) => { - // Process all items in one run - for (const item of payload.items) { - // Do async work in parallel - // This works very well for API calls - await processItem(item); - } + // Process all items in parallel + const promises = payload.items.map(item => processItem(item)); + // This works very well for API calls + await Promise.all(promises); }, }); ``` From e46e9bd30fda7757b993a2d4ccf3a4067b4791e0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 7 Aug 2025 10:22:37 +0100 Subject: [PATCH 055/641] docs: Manual setup guide (#2358) * Add manual monorepo setup guide for Trigger.dev This commit introduces a new "Manual setup" guide for setting up Trigger.dev in projects, specifically focused on monorepo configurations. The guide outlines two primary approaches for monorepos: creating a dedicated tasks package and integrating tasks directly within apps. This detailed documentation aims to help developers manually configure their projects, bypassing automated steps, and understanding the setup better. - Provides step-by-step instructions for both 'Tasks as a package' and 'Tasks in apps' approaches. - Includes example configurations for various package managers, environment setups, and runtime options. - Enhances user understanding of Trigger.dev's configuration requirements in complex monorepo environments. The purpose of adding this guide is to enable developers to seamlessly integrate Trigger.dev into their monorepos, whether they choose to abstract tasks into packages or embed them within individual applications. This flexibility supports diverse project structures while maintaining consistency with Trigger.dev's operational prerequisites. * Correct tasks usage in documentation The 'tasks as package' section in the documentation had an incorrect example under 'Use tasks in your apps' which needed correction to align with the actual package usage. - Fixed incorrect import of tasks by updating to the correct import from '@repo/tasks/trigger'. - Updated the syntax to use 'tasks.trigger' with type parameters, following the new pattern established for triggering tasks with TypeScript. - Added error handling to catch and log errors during task execution, returning a meaningful error message instead of just failing silently. This update ensures developers have an accurate reference when implementing tasks in their applications, especially given the breaking changes in TypeScript compatibility due to recent package updates. * Update package configuration for Zod 3-4 compatibility The recent Zod package updates from version 3 to 4 introduce breaking changes in TypeScript compatibility that require adjustments in our project configuration files. The primary updates involve adding type annotations and modifying import statements to support the shift without breaking existing functionality. - Updated `package.json` and initialization files to align with new compatibility requirements. - Modified server task examples to explicitly use type imports for improved error handling and consistency. - Adjusted workspace and project settings to maintain compatibility and improve configuration clarity. - Provided references to current examples for better implementation guidance. * More manual setup guide steps * Fix bun docs link --- docs/docs.json | 82 ++++-- docs/manual-setup.mdx | 631 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 696 insertions(+), 17 deletions(-) create mode 100644 docs/manual-setup.mdx diff --git a/docs/docs.json b/docs/docs.json index 2d99921a960..9f12c13a3e8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -10,7 +10,11 @@ }, "favicon": "/images/favicon.png", "contextual": { - "options": ["copy", "view", "claude"] + "options": [ + "copy", + "view", + "claude" + ] }, "navigation": { "dropdowns": [ @@ -24,6 +28,7 @@ "pages": [ "introduction", "quick-start", + "manual-setup", "video-walkthrough", "how-it-works", "limits", @@ -35,7 +40,11 @@ "pages": [ { "group": "Tasks", - "pages": ["tasks/overview", "tasks/schemaTask", "tasks/scheduled"] + "pages": [ + "tasks/overview", + "tasks/schemaTask", + "tasks/scheduled" + ] }, "triggering", "runs", @@ -50,7 +59,12 @@ "errors-retrying", { "group": "Wait", - "pages": ["wait", "wait-for", "wait-until", "wait-for-token"] + "pages": [ + "wait", + "wait-for", + "wait-until", + "wait-for-token" + ] }, "queue-concurrency", "versioning", @@ -96,7 +110,9 @@ }, { "group": "Development", - "pages": ["cli-dev"] + "pages": [ + "cli-dev" + ] }, { "group": "Deployment", @@ -108,7 +124,9 @@ "deployment/atomic-deployment", { "group": "Deployment integrations", - "pages": ["vercel-integration"] + "pages": [ + "vercel-integration" + ] } ] }, @@ -161,7 +179,12 @@ }, { "group": "Using the Dashboard", - "pages": ["run-tests", "troubleshooting-alerts", "replaying", "bulk-actions"] + "pages": [ + "run-tests", + "troubleshooting-alerts", + "replaying", + "bulk-actions" + ] }, { "group": "Troubleshooting", @@ -183,23 +206,39 @@ "self-hosting/kubernetes", { "group": "Environment variables", - "pages": ["self-hosting/env/webapp", "self-hosting/env/supervisor"] + "pages": [ + "self-hosting/env/webapp", + "self-hosting/env/supervisor" + ] } ], - "tags": ["v4"], + "tags": [ + "v4" + ], "tag": "v4" }, { "group": "Self-hosting", - "pages": ["open-source-self-hosting"] + "pages": [ + "open-source-self-hosting" + ] }, { "group": "Open source", - "pages": ["open-source-contributing", "github-repo", "changelog", "roadmap"] + "pages": [ + "open-source-contributing", + "github-repo", + "changelog", + "roadmap" + ] }, { "group": "Help", - "pages": ["community", "help-slack", "help-email"] + "pages": [ + "community", + "help-slack", + "help-email" + ] } ] }, @@ -220,7 +259,10 @@ }, { "group": "Tasks API", - "pages": ["management/tasks/trigger", "management/tasks/batch-trigger"] + "pages": [ + "management/tasks/trigger", + "management/tasks/batch-trigger" + ] }, { "group": "Runs API", @@ -266,7 +308,9 @@ "groups": [ { "group": "Introduction", - "pages": ["guides/introduction"] + "pages": [ + "guides/introduction" + ] }, { "group": "Frameworks", @@ -328,7 +372,6 @@ } ] }, - { "group": "Example projects", "pages": [ @@ -384,7 +427,9 @@ }, { "group": "Migration guides", - "pages": ["migration-mergent"] + "pages": [ + "migration-mergent" + ] }, { "group": "Community packages", @@ -405,7 +450,10 @@ "href": "https://trigger.dev" }, "api": { - "openapi": ["openapi.yml", "v3-openapi.yaml"], + "openapi": [ + "openapi.yml", + "v3-openapi.yaml" + ], "playground": { "display": "simple" } @@ -580,4 +628,4 @@ "destination": "/management/overview" } ] -} +} \ No newline at end of file diff --git a/docs/manual-setup.mdx b/docs/manual-setup.mdx new file mode 100644 index 00000000000..328877de1b5 --- /dev/null +++ b/docs/manual-setup.mdx @@ -0,0 +1,631 @@ +--- +title: "Manual setup" +description: "How to manually setup Trigger.dev in your project." +--- + +This guide covers how to manually set up Trigger.dev in your project, including configuration for different package managers, monorepos, and build extensions. This guide replicates all the steps performed by the `trigger.dev init` command. Follow our [Quickstart](/quick-start) for a more streamlined setup. + +## Prerequisites + +- Node.js 18.20+ (or Bun runtime) +- A Trigger.dev account (sign up at [trigger.dev](https://trigger.dev)) +- TypeScript 5.0.4 or later (for TypeScript projects) + +## CLI Authentication + +Before setting up your project, you need to authenticate the CLI with Trigger.dev: + +```bash +# Login to Trigger.dev +npx trigger.dev@v4-beta login + +# Or with a specific API URL (for self-hosted instances) +npx trigger.dev@v4-beta login --api-url https://your-trigger-instance.com +``` + +This will open your browser to authenticate. Once authenticated, you'll need to select or create a project in the Trigger.dev dashboard to get your project reference (e.g., `proj_abc123`). + +## Package installation + +Install the required packages based on your package manager: + + + +```bash npm +npm add @trigger.dev/sdk@v4-beta +npm add --save-dev @trigger.dev/build@v4-beta +``` + +```bash pnpm +pnpm add @trigger.dev/sdk@v4-beta +pnpm add -D @trigger.dev/build@v4-beta +``` + +```bash yarn +yarn add @trigger.dev/sdk@v4-beta +yarn add -D @trigger.dev/build@v4-beta +``` + +```bash bun +bun add @trigger.dev/sdk@v4-beta +bun add -D @trigger.dev/build@v4-beta +``` + + + +## Environment variables + +For local development, you need to set up the `TRIGGER_SECRET_KEY` environment variable. This key authenticates your application with Trigger.dev. + +1. Go to your project dashboard in Trigger.dev +2. Navigate to the "API Keys" page +3. Copy the **DEV** secret key +4. Add it to your local environment file: + +```bash +TRIGGER_SECRET_KEY=tr_dev_xxxxxxxxxx +``` + +### Self-hosted instances + +If you're using a self-hosted Trigger.dev instance, also set: + +```bash +TRIGGER_API_URL=https://your-trigger-instance.com +``` + +## CLI setup + +You can run the Trigger.dev CLI in two ways: + +### Option 1: Using npx/pnpm dlx/yarn dlx + +```bash +# npm +npx trigger.dev@v4-beta dev + +# pnpm +pnpm dlx trigger.dev@v4-beta dev + +# yarn +yarn dlx trigger.dev@v4-beta dev +``` + +### Option 2: Add as dev dependency + +Add the CLI to your `package.json`: + +```json +{ + "devDependencies": { + "trigger.dev": "4.0.0-v4-beta.26" + } +} +``` + +Then add scripts to your `package.json`: + +```json +{ + "scripts": { + "dev:trigger": "trigger dev", + "deploy:trigger": "trigger deploy" + } +} +``` + +### Version pinning + +Make sure to pin the version of the CLI to the same version as the SDK that you are using: + +```json +"devDependencies": { + "trigger.dev": "4.0.0-v4-beta.26", + "@trigger.dev/build": "4.0.0-v4-beta.26" +}, +"dependencies": { + "@trigger.dev/sdk": "4.0.0-v4-beta.26" +} +``` + +While running the CLI `dev` or `deploy` commands, the CLI will automatically detect mismatched versions and warn you. + +## Configuration file + +Create a `trigger.config.ts` file in your project root (or `trigger.config.mjs` for JavaScript projects): + +```typescript +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + // Your project ref from the Trigger.dev dashboard + project: "", // e.g., "proj_abc123" + + // Directories containing your tasks + dirs: ["./src/trigger"], // Customize based on your project structure + + // Retry configuration + retries: { + enabledInDev: false, // Enable retries in development + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + + // Build configuration (optional) + build: { + extensions: [], // Build extensions go here + }, + + // Max duration of a task in seconds + maxDuration: 3600, +}); +``` + +### Using the Bun runtime + +By default, Trigger.dev will use the Node.js runtime. If you're using Bun, you can specify the runtime: + +```typescript +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", + runtime: "bun", + dirs: ["./src/trigger"], +}); +``` + +See our [Bun runtime documentation](/guides/frameworks/bun) for more information. + +## Add your first task + +Create a `trigger` directory (matching the `dirs` in your config) and add an example task: + +```typescript src/trigger/example.ts +import { task } from "@trigger.dev/sdk"; + +export const helloWorld = task({ + id: "hello-world", + run: async (payload: { name: string }) => { + console.log(`Hello ${payload.name}!`); + + return { + message: `Hello ${payload.name}!`, + timestamp: new Date().toISOString(), + }; + }, +}); +``` + +See our [Tasks](/tasks/overview) docs for more information on how to create tasks. + +## TypeScript config + +If you're using TypeScript, add `trigger.config.ts` to your `tsconfig.json` include array: + +```json +{ + "compilerOptions": { + // ... your existing options + }, + "include": [ + // ... your existing includes + "trigger.config.ts" + ] +} +``` + +## Git config + +Add `.trigger` to your `.gitignore` file to exclude Trigger.dev's local development files: + +```bash +# Trigger.dev +.trigger +``` + +If you don't have a `.gitignore` file, create one with this content. + +## React hooks setup + +If you're building a React frontend application and want to display task status in real-time, install the React hooks package: + +### Installation + +```bash +# npm +npm install @trigger.dev/react-hooks@v4-beta + +# pnpm +pnpm add @trigger.dev/react-hooks@v4-beta + +# yarn +yarn add @trigger.dev/react-hooks@v4-beta + +# bun +bun add @trigger.dev/react-hooks@v4-beta +``` + +### Basic usage + +1. **Generate a Public Access Token** in your backend: + +```typescript +import { auth } from "@trigger.dev/sdk"; + +// In your backend API +export async function getPublicAccessToken() { + const publicAccessToken = await auth.createPublicToken({ + scopes: ["read:runs"], // Customize based on needs + }); + + return publicAccessToken; +} +``` + +2. **Use hooks to monitor tasks**: + +```tsx +import { useRealtimeRun } from "@trigger.dev/react-hooks"; + +export function TaskStatus({ + runId, + publicAccessToken, +}: { + runId: string; + publicAccessToken: string; +}) { + const { run, error } = useRealtimeRun(runId, { + accessToken: publicAccessToken, + }); + + if (error) return
Error: {error.message}
; + if (!run) return
Loading...
; + + return ( +
+

Status: {run.status}

+

Progress: {run.completedAt ? "Complete" : "Running..."}

+
+ ); +} +``` + +For more information, see the [React Hooks documentation](/frontend/react-hooks/overview). + +## Build extensions + +Build extensions allow you to customize the build process. Ensure you have the `@trigger.dev/build` package installed in your project (see [package installation](#package-installation)). + +Now you can use any of the built-in extensions: + +```typescript +import { defineConfig } from "@trigger.dev/sdk"; +import { prismaExtension } from "@trigger.dev/build/extensions/prisma"; + +export default defineConfig({ + project: "", + build: { + extensions: [ + prismaExtension({ + schema: "prisma/schema.prisma", + migrate: true, // Run migrations on deploy + }), + ], + }, +}); +``` + +See our [Build extensions](/config/extensions/overview) docs for more information on how to use build extensions and the available extensions. + +## Monorepo setup + +There are two main approaches for setting up Trigger.dev in a monorepo: + +1. **Tasks as a package**: Create a separate package for your Trigger.dev tasks that can be shared across apps +2. **Tasks in apps**: Install Trigger.dev directly in individual apps that need background tasks + +Both approaches work well depending on your needs. Use the tasks package approach if you want to share tasks across multiple applications, or the app-based approach if tasks are specific to individual apps. + +### Approach 1: Tasks as a package (Turborepo) + +This approach creates a dedicated tasks package that can be consumed by multiple apps in your monorepo. + +#### 1. Set up workspace configuration + +**Root `package.json`**: + +```json +{ + "name": "my-monorepo", + "private": true, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint" + }, + "devDependencies": { + "turbo": "^2.4.4", + "typescript": "5.8.2" + }, + "packageManager": "pnpm@9.0.0" +} +``` + +**`pnpm-workspace.yaml`**: + +```yaml +packages: + - "apps/*" + - "packages/*" +``` + +**`turbo.json`**: + +```json +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^lint"] + } + } +} +``` + +#### 2. Create the tasks package + +**`packages/tasks/package.json`**: + +```json +{ + "name": "@repo/tasks", + "version": "0.0.0", + "dependencies": { + "@trigger.dev/sdk": "4.0.0-v4-beta.26" + }, + "devDependencies": { + "@trigger.dev/build": "4.0.0-v4-beta.26" + }, + "exports": { + ".": "./src/trigger/index.ts", + "./trigger": "./src/index.ts" + } +} +``` + +**`packages/tasks/trigger.config.ts`**: + +```typescript +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", // Replace with your project reference + dirs: ["./src/trigger"], + retries: { + enabledInDev: true, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + maxDuration: 3600, +}); +``` + +**`packages/tasks/src/index.ts`**: + +```typescript +export * from "@trigger.dev/sdk"; // Export values and types from the Trigger.dev sdk +``` + +**`packages/tasks/src/trigger/index.ts`**: + +```typescript +// Export tasks +export * from "./example"; +``` + +**`packages/tasks/src/trigger/example.ts`**: + +```typescript +import { task } from "@trigger.dev/sdk"; + +export const helloWorld = task({ + id: "hello-world", + run: async (payload: { name: string }) => { + console.log(`Hello ${payload.name}!`); + + return { + message: `Hello ${payload.name}!`, + timestamp: new Date().toISOString(), + }; + }, +}); +``` + +See our [turborepo-prisma-tasks-package example](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package) for a more complete example. + +#### 3. Use tasks in your apps + +**`apps/web/package.json`**: + +```json +{ + "name": "web", + "dependencies": { + "@repo/tasks": "workspace:*", + "next": "^15.2.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +**`apps/web/app/api/actions.ts`**: + +```typescript +"use server"; + +import { tasks } from "@repo/tasks/trigger"; +import type { helloWorld } from "@repo/tasks"; +// 👆 type only import + +export async function triggerHelloWorld(name: string) { + try { + const handle = await tasks.trigger("hello-world", { + name: name, + }); + + return handle.id; + } catch (error) { + console.error(error); + return { error: "something went wrong" }; + } +} +``` + +#### 4. Development workflow + +Run the development server for the tasks package: + +```bash +# From the root of your monorepo +cd packages/tasks +npx trigger.dev@v4-beta dev + +# Or using turbo (if you add dev:trigger script to tasks package.json) +turbo run dev:trigger --filter=@repo/tasks +``` + +### Approach 2: Tasks in apps (Turborepo) + +This approach installs Trigger.dev directly in individual apps that need background tasks. + +#### 1. Install in your app + +**`apps/web/package.json`**: + +```json +{ + "name": "web", + "dependencies": { + "@trigger.dev/sdk": "4.0.0-v4-beta.26", + "next": "^15.2.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@trigger.dev/build": "4.0.0-v4-beta.26" + } +} +``` + +#### 2. Add configuration + +**`apps/web/trigger.config.ts`**: + +```typescript +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", // Replace with your project reference + dirs: ["./src/trigger"], + retries: { + enabledInDev: true, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + maxDuration: 3600, +}); +``` + +#### 3. Create tasks + +**`apps/web/src/trigger/example.ts`**: + +```typescript +import { task } from "@trigger.dev/sdk"; + +export const helloWorld = task({ + id: "hello-world", + run: async (payload: { name: string }) => { + console.log(`Hello ${payload.name}!`); + + return { + message: `Hello ${payload.name}!`, + timestamp: new Date().toISOString(), + }; + }, +}); +``` + +#### 4. Use tasks in your app + +**`apps/web/app/api/actions.ts`**: + +```typescript +"use server"; + +import { tasks } from "@trigger.dev/sdk"; +import type { helloWorld } from "../../src/trigger/example"; +// 👆 type only import + +export async function triggerHelloWorld(name: string) { + try { + const handle = await tasks.trigger("hello-world", { + name: name, + }); + + return handle.id; + } catch (error) { + console.error(error); + return { error: "something went wrong" }; + } +} +``` + +#### 5. Development workflow + +```bash +# From the app directory +cd apps/web +npx trigger.dev@v4-beta dev + +# Or from the root using turbo +turbo run dev:trigger --filter=web +``` + +## Example projects + +You can find a growing list of example projects in our [examples](/guides/introduction) section. + +## Troubleshooting + +If you run into any issues, please check our [Troubleshooting](/troubleshooting) page. + +## Feedback + +If you have any feedback, please let us know by [opening an issue](https://github.com/triggerdotdev/trigger.dev/issues). From 9787120cfbcb92822835cea70185218d0fbd1206 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 7 Aug 2025 11:40:14 +0100 Subject: [PATCH 056/641] We need to set TRIGGER_OTEL_EXPORTER_OTLP_ENDPOINT for deployed runs (#2365) --- packages/cli-v3/src/entryPoints/managed/env.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli-v3/src/entryPoints/managed/env.ts b/packages/cli-v3/src/entryPoints/managed/env.ts index 68a403d3da6..2f5fef436a0 100644 --- a/packages/cli-v3/src/entryPoints/managed/env.ts +++ b/packages/cli-v3/src/entryPoints/managed/env.ts @@ -221,6 +221,7 @@ export class RunnerEnv { NODE_ENV: this.NODE_ENV, NODE_EXTRA_CA_CERTS: this.NODE_EXTRA_CA_CERTS, OTEL_EXPORTER_OTLP_ENDPOINT: this.OTEL_EXPORTER_OTLP_ENDPOINT, + TRIGGER_OTEL_EXPORTER_OTLP_ENDPOINT: this.OTEL_EXPORTER_OTLP_ENDPOINT, UV_USE_IO_URING: this.UV_USE_IO_URING, }; From af1462168337d591887cefd334f586fef108a32b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 7 Aug 2025 12:41:39 +0100 Subject: [PATCH 057/641] Specify a region when triggering (#2366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Map new allowedMasterQueues → allowedWorkerQueues * ClickHouse worker_queue on task runs * Added the Region to the run inspector * Pass a region in when triggering * Added a changeset * Added triggering regions docs * Added region to the ctx * Fix for backfiller masterQueue/workerQueue --- .changeset/thick-poets-yawn.md | 5 +++ .../presenters/v3/RegionsPresenter.server.ts | 6 +-- .../app/presenters/v3/SpanPresenter.server.ts | 23 +++++++++-- .../admin.api.v1.runs-replication.backfill.ts | 7 +++- .../route.tsx | 14 +++++++ .../app/runEngine/concerns/queues.server.ts | 19 +++++++-- .../runEngine/services/triggerTask.server.ts | 2 +- apps/webapp/app/runEngine/types.ts | 5 ++- .../app/services/runsBackfiller.server.ts | 7 +++- .../services/runsReplicationService.server.ts | 17 +++++--- .../v3/services/setDefaultRegion.server.ts | 4 +- .../worker/workerGroupService.server.ts | 39 +++++++++++++++++-- docs/triggering.mdx | 12 ++++++ .../006_add_task_runs_v2_workerqueue.sql | 10 +++++ internal-packages/clickhouse/src/taskRuns.ts | 1 + .../database/prisma/schema.prisma | 2 +- .../src/engine/systems/runAttemptSystem.ts | 7 ++++ packages/core/src/v3/schemas/api.ts | 2 + packages/core/src/v3/schemas/common.ts | 2 + packages/core/src/v3/types/tasks.ts | 15 +++++++ packages/trigger-sdk/src/v3/shared.ts | 8 ++++ references/hello-world/src/trigger/regions.ts | 9 +++++ 22 files changed, 191 insertions(+), 25 deletions(-) create mode 100644 .changeset/thick-poets-yawn.md create mode 100644 internal-packages/clickhouse/schema/006_add_task_runs_v2_workerqueue.sql create mode 100644 references/hello-world/src/trigger/regions.ts diff --git a/.changeset/thick-poets-yawn.md b/.changeset/thick-poets-yawn.md new file mode 100644 index 00000000000..56f1151b542 --- /dev/null +++ b/.changeset/thick-poets-yawn.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Specify a region override when triggering a run diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 7f692987744..c304597bb1f 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -30,7 +30,7 @@ export class RegionsPresenter extends BasePresenter { id: true, organizationId: true, defaultWorkerGroupId: true, - allowedMasterQueues: true, + allowedWorkerQueues: true, }, where: { slug: projectSlug, @@ -70,9 +70,9 @@ export class RegionsPresenter extends BasePresenter { where: isAdmin ? undefined : // Hide hidden unless they're allowed to use them - project.allowedMasterQueues.length > 0 + project.allowedWorkerQueues.length > 0 ? { - masterQueue: { in: project.allowedMasterQueues }, + masterQueue: { in: project.allowedWorkerQueues }, } : { hidden: false, diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index a3a5747846c..a00ffa3f3cb 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -1,11 +1,11 @@ import { - MachinePreset, + type MachinePreset, prettyPrintPacket, SemanticInternalAttributes, - TaskRunContext, + type TaskRunContext, TaskRunError, TriggerTraceContext, - V3TaskRunContext, + type V3TaskRunContext, } from "@trigger.dev/core/v3"; import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; @@ -176,6 +176,22 @@ export class SpanPresenter extends BasePresenter { const externalTraceId = this.#getExternalTraceId(run.traceContext); + let region: { name: string; location: string | null } | null = null; + + if (run.runtimeEnvironment.type !== "DEVELOPMENT" && run.engine !== "V1") { + const workerGroup = await this._replica.workerInstanceGroup.findFirst({ + select: { + name: true, + location: true, + }, + where: { + masterQueue: run.workerQueue, + }, + }); + + region = workerGroup ?? null; + } + return { id: run.id, friendlyId: run.friendlyId, @@ -233,6 +249,7 @@ export class SpanPresenter extends BasePresenter { maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds), batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined, engine: run.engine, + region, workerQueue: run.workerQueue, spanId: run.spanId, isCached: !!span.originalRun, diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts index 1584d1acccd..0897c30c21d 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts @@ -59,7 +59,12 @@ export async function action({ request }: ActionFunctionArgs) { throw new Error("Runs replication instance not found"); } - await runsReplicationInstance.backfill(runs); + await runsReplicationInstance.backfill( + runs.map((run) => ({ + ...run, + masterQueue: run.workerQueue, + })) + ); logger.info("Backfilled runs", { runs }); 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 4e232b888f2..904f5c508bc 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 @@ -76,6 +76,7 @@ import { } from "~/utils/pathBuilder"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; +import { FlagIcon } from "~/assets/icons/RegionIcons"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug, envParam, runParam, spanParam } = @@ -701,6 +702,19 @@ function RunBody({ + {run.region && ( + + Region + + + {run.region.location ? ( + + ) : null} + {run.region.name} + + + + )} Run invocation cost diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index c8bda40d22c..60cf20b14f7 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -14,6 +14,7 @@ import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.serv import type { RunEngine } from "~/v3/runEngine.server"; import { env } from "~/env.server"; import { EngineServiceValidationError } from "./errors"; +import { tryCatch } from "@trigger.dev/core/v3"; export class DefaultQueueManager implements QueueManager { constructor( @@ -196,7 +197,10 @@ export class DefaultQueueManager implements QueueManager { }; } - async getWorkerQueue(environment: AuthenticatedEnvironment): Promise { + async getWorkerQueue( + environment: AuthenticatedEnvironment, + regionOverride?: string + ): Promise { if (environment.type === "DEVELOPMENT") { return environment.id; } @@ -206,9 +210,16 @@ export class DefaultQueueManager implements QueueManager { engine: this.engine, }); - const workerGroup = await workerGroupService.getDefaultWorkerGroupForProject({ - projectId: environment.projectId, - }); + const [error, workerGroup] = await tryCatch( + workerGroupService.getDefaultWorkerGroupForProject({ + projectId: environment.projectId, + regionOverride, + }) + ); + + if (error) { + throw new EngineServiceValidationError(error.message); + } if (!workerGroup) { throw new EngineServiceValidationError("No worker group found"); diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index ea41c12c793..5ec3f29dd85 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -234,7 +234,7 @@ export class RunEngineTriggerTaskService { const depth = parentRun ? parentRun.depth + 1 : 0; - const workerQueue = await this.queueConcern.getWorkerQueue(environment); + const workerQueue = await this.queueConcern.getWorkerQueue(environment, body.options?.region); try { return await this.traceEventConcern.traceRun(triggerRequest, async (event) => { diff --git a/apps/webapp/app/runEngine/types.ts b/apps/webapp/app/runEngine/types.ts index 3564a5d717c..40a70678e0a 100644 --- a/apps/webapp/app/runEngine/types.ts +++ b/apps/webapp/app/runEngine/types.ts @@ -67,7 +67,10 @@ export interface QueueManager { ): Promise; getQueueName(request: TriggerTaskRequest): Promise; validateQueueLimits(env: AuthenticatedEnvironment): Promise; - getWorkerQueue(env: AuthenticatedEnvironment): Promise; + getWorkerQueue( + env: AuthenticatedEnvironment, + regionOverride?: string + ): Promise; } export interface PayloadProcessor { diff --git a/apps/webapp/app/services/runsBackfiller.server.ts b/apps/webapp/app/services/runsBackfiller.server.ts index a566b44bb3d..7fc824f3d39 100644 --- a/apps/webapp/app/services/runsBackfiller.server.ts +++ b/apps/webapp/app/services/runsBackfiller.server.ts @@ -73,7 +73,12 @@ export class RunsBackfillerService { lastCreatedAt: runs[runs.length - 1].createdAt, }); - await this.runsReplicationInstance.backfill(runs); + await this.runsReplicationInstance.backfill( + runs.map((run) => ({ + ...run, + masterQueue: run.workerQueue, + })) + ); const lastRun = runs[runs.length - 1]; diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 60badb2ebc5..b9eeeab2562 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -59,7 +59,13 @@ export type RunsReplicationServiceOptions = { insertMaxDelayMs?: number; }; -type TaskRunInsert = { _version: bigint; run: TaskRun; event: "insert" | "update" | "delete" }; +type PostgresTaskRun = TaskRun & { masterQueue: string }; + +type TaskRunInsert = { + _version: bigint; + run: PostgresTaskRun; + event: "insert" | "update" | "delete"; +}; export type RunsReplicationServiceEvents = { message: [{ lsn: string; message: PgoutputMessage; service: RunsReplicationService }]; @@ -243,7 +249,7 @@ export class RunsReplicationService { } } - async backfill(runs: TaskRun[]) { + async backfill(runs: PostgresTaskRun[]) { // divide into batches of 50 to get data from Postgres const flushId = nanoid(); // Use current timestamp as LSN (high enough to be above existing data) @@ -352,7 +358,7 @@ export class RunsReplicationService { const replicationLagMs = Date.now() - Number(message.commitTime / 1000n); this._currentTransaction.commitEndLsn = message.commitEndLsn; this._currentTransaction.replicationLagMs = replicationLagMs; - const transaction = this._currentTransaction as Transaction; + const transaction = this._currentTransaction as Transaction; this._currentTransaction = null; if (transaction.commitEndLsn) { @@ -370,7 +376,7 @@ export class RunsReplicationService { } } - #handleTransaction(transaction: Transaction) { + #handleTransaction(transaction: Transaction) { if (this._isShutDownComplete) return; if (this._isShuttingDown) { @@ -764,7 +770,7 @@ export class RunsReplicationService { } async #prepareTaskRunInsert( - run: TaskRun, + run: PostgresTaskRun, organizationId: string, environmentType: string, event: "insert" | "update" | "delete", @@ -814,6 +820,7 @@ export class RunsReplicationService { output, concurrency_key: run.concurrencyKey ?? "", bulk_action_group_ids: run.bulkActionGroupIds ?? [], + worker_queue: run.masterQueue, _version: _version.toString(), _is_deleted: event === "delete" ? 1 : 0, }; diff --git a/apps/webapp/app/v3/services/setDefaultRegion.server.ts b/apps/webapp/app/v3/services/setDefaultRegion.server.ts index 8e4eb5b2587..cada8194527 100644 --- a/apps/webapp/app/v3/services/setDefaultRegion.server.ts +++ b/apps/webapp/app/v3/services/setDefaultRegion.server.ts @@ -32,8 +32,8 @@ export class SetDefaultRegionService extends BaseService { // If their project is restricted, only allow them to set default regions that are allowed if (!isAdmin) { - if (project.allowedMasterQueues.length > 0) { - if (!project.allowedMasterQueues.includes(workerGroup.masterQueue)) { + if (project.allowedWorkerQueues.length > 0) { + if (!project.allowedWorkerQueues.includes(workerGroup.masterQueue)) { throw new ServiceValidationError("You're not allowed to set this region as default"); } } else if (workerGroup.hidden) { diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index e33c3056fef..f05c8783ecd 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -195,10 +195,12 @@ export class WorkerGroupService extends WithRunEngine { async getDefaultWorkerGroupForProject({ projectId, + regionOverride, }: { projectId: string; + regionOverride?: string; }): Promise { - const project = await this._prisma.project.findUnique({ + const project = await this._prisma.project.findFirst({ where: { id: projectId, }, @@ -208,8 +210,39 @@ export class WorkerGroupService extends WithRunEngine { }); if (!project) { - logger.error("[WorkerGroupService] Project not found", { projectId }); - return; + throw new Error("Project not found."); + } + + // If they've specified a region, we need to check they have access to it + if (regionOverride) { + const workerGroup = await this._prisma.workerInstanceGroup.findFirst({ + where: { + masterQueue: regionOverride, + }, + }); + + if (!workerGroup) { + throw new Error(`The region you specified doesn't exist ("${regionOverride}").`); + } + + // If they're restricted, check they have access + if (project.allowedWorkerQueues.length > 0) { + if (project.allowedWorkerQueues.includes(workerGroup.masterQueue)) { + return workerGroup; + } + + throw new Error( + `You don't have access to this region ("${regionOverride}"). You can use the following regions: ${project.allowedWorkerQueues.join( + ", " + )}.` + ); + } + + if (workerGroup.hidden) { + throw new Error(`The region you specified isn't available to you ("${regionOverride}").`); + } + + return workerGroup; } if (project.defaultWorkerGroup) { diff --git a/docs/triggering.mdx b/docs/triggering.mdx index 235383796f5..cd263eac97a 100644 --- a/docs/triggering.mdx +++ b/docs/triggering.mdx @@ -980,6 +980,18 @@ View our [metadata doc](/runs/metadata) for more information. View our [maxDuration doc](/runs/max-duration) for more information. +### `region` + +You can override the default region when you trigger a run: + +```ts +await yourTask.trigger(payload, { region: "eu-central-1" }); +``` + +If you don't specify a region it will use the default for your project. Go to the "Regions" page in the dashboard to see available regions or switch your default. + +The region is where your runs are executed, it does not change where the run payload, output, tags, logs, or are any other data is stored. + ## Large Payloads We recommend keeping your task payloads as small as possible. We currently have a hard limit on task payloads above 10MB. diff --git a/internal-packages/clickhouse/schema/006_add_task_runs_v2_workerqueue.sql b/internal-packages/clickhouse/schema/006_add_task_runs_v2_workerqueue.sql new file mode 100644 index 00000000000..840ab78575f --- /dev/null +++ b/internal-packages/clickhouse/schema/006_add_task_runs_v2_workerqueue.sql @@ -0,0 +1,10 @@ +-- +goose Up +/* +Add worker_queue column. + */ +ALTER TABLE trigger_dev.task_runs_v2 +ADD COLUMN worker_queue String DEFAULT ''; + +-- +goose Down +ALTER TABLE trigger_dev.task_runs_v2 +DROP COLUMN worker_queue; \ No newline at end of file diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index 1d114772087..2363c691fd3 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -44,6 +44,7 @@ export const TaskRunV2 = z.object({ is_test: z.boolean().default(false), concurrency_key: z.string().default(""), bulk_action_group_ids: z.array(z.string()).default([]), + worker_queue: z.string().default(""), _version: z.string(), _is_deleted: z.number().int().default(0), }); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 11018e20a42..ba69f0f04fd 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -336,7 +336,7 @@ model Project { defaultWorkerGroupId String? /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) - allowedMasterQueues String[] @default([]) + allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues") environments RuntimeEnvironment[] backgroundWorkers BackgroundWorker[] diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index ce0f8abe4d5..08bc64b8f02 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -210,6 +210,7 @@ export class RunAttemptSystem { parentTaskRunId: true, rootTaskRunId: true, batchId: true, + workerQueue: true, }, }); @@ -261,6 +262,7 @@ export class RunAttemptSystem { priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, parentTaskRunId: run.parentTaskRunId ? RunId.toFriendlyId(run.parentTaskRunId) : undefined, rootTaskRunId: run.rootTaskRunId ? RunId.toFriendlyId(run.rootTaskRunId) : undefined, + region: run.runtimeEnvironment.type !== "DEVELOPMENT" ? run.workerQueue : undefined, }, attempt: { number: run.attemptNumber ?? 1, @@ -428,6 +430,7 @@ export class RunAttemptSystem { }, parentTaskRunId: true, rootTaskRunId: true, + workerQueue: true, }, }); @@ -574,6 +577,10 @@ export class RunAttemptSystem { rootTaskRunId: updatedRun.rootTaskRunId ? RunId.toFriendlyId(updatedRun.rootTaskRunId) : undefined, + region: + updatedRun.runtimeEnvironment.type !== "DEVELOPMENT" + ? updatedRun.workerQueue + : undefined, }, task, queue, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index cdf455cbfea..7fde77c41c4 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -134,6 +134,7 @@ export const TriggerTaskRequestBody = z.object({ ttl: z.string().or(z.number().nonnegative().int()).optional(), priority: z.number().optional(), bulkActionId: z.string().optional(), + region: z.string().optional(), }) .optional(), }); @@ -181,6 +182,7 @@ export const BatchTriggerTaskItem = z.object({ test: z.boolean().optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(), priority: z.number().optional(), + region: z.string().optional(), }) .optional(), }); diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 2928995606b..41a095648fb 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -229,6 +229,8 @@ export const TaskRun = z.object({ // These are only used during execution, not in run.ctx durationMs: z.number().optional(), costInCents: z.number().optional(), + + region: z.string().optional(), }); export type TaskRun = z.infer; diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 66c9d98d5f3..5a527b9471f 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -855,6 +855,21 @@ export type TriggerOptions = { * to the same version as the parent task that is triggering the child tasks. */ version?: string; + + /** + * Specify the region to run the task in. This overrides the default region set for your project in the dashboard. + * + * Check the Regions page in the dashboard for regions that are available to you. + * + * In DEV this won't do anything, so it's fine to set it in your code. + * + * @example + * + * ```ts + * await myTask.trigger({ foo: "bar" }, { region: "us-east-1" }); + * ``` + */ + region?: string; }; export type TriggerAndWaitOptions = Omit; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 05300dc4f97..1a05d766cb6 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -627,6 +627,7 @@ export async function batchTriggerById( idempotencyKeyTTL: item.options?.idempotencyKeyTTL ?? options?.idempotencyKeyTTL, machine: item.options?.machine, priority: item.options?.priority, + region: item.options?.region, lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; @@ -796,6 +797,7 @@ export async function batchTriggerByIdAndWait( idempotencyKeyTTL: item.options?.idempotencyKeyTTL ?? options?.idempotencyKeyTTL, machine: item.options?.machine, priority: item.options?.priority, + region: item.options?.region, }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) @@ -955,6 +957,7 @@ export async function batchTriggerTasks( idempotencyKeyTTL: item.options?.idempotencyKeyTTL ?? options?.idempotencyKeyTTL, machine: item.options?.machine, priority: item.options?.priority, + region: item.options?.region, lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; @@ -1126,6 +1129,7 @@ export async function batchTriggerAndWaitTasks( parentRunId: taskContext.ctx?.run.id, machine: options?.machine, priority: options?.priority, + region: options?.region, lockToVersion: options?.version ?? getEnvVar("TRIGGER_VERSION"), }, }, @@ -1270,6 +1275,7 @@ async function batchTrigger_internal( idempotencyKeyTTL: item.options?.idempotencyKeyTTL ?? options?.idempotencyKeyTTL, machine: item.options?.machine, priority: item.options?.priority, + region: item.options?.region, lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; @@ -1354,6 +1360,7 @@ async function triggerAndWait_internal { + await fixedLengthTask.triggerAndWait({ waitSeconds: 1 }, { region }); + }, +}); From da7dc74fa45f4888dcf915192425de5b93f39d77 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 7 Aug 2025 14:43:28 +0100 Subject: [PATCH 058/641] Replaying keeps the original run region (#2368) --- apps/webapp/app/v3/services/replayTaskRun.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index e29b5d423ab..d9893468dab 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -92,6 +92,7 @@ export class ReplayTaskRunService extends BaseService { lockToVersion: overrideOptions.version === "latest" ? undefined : overrideOptions.version, bulkActionId: overrideOptions?.bulkActionId, + region: existingTaskRun.workerQueue, }, }, { From 9e41e83818809679ec1a4cf0729bccef35115557 Mon Sep 17 00:00:00 2001 From: Dan <8297864+D-K-P@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:02:52 +0100 Subject: [PATCH 059/641] Restructured the realtime docs (#2317) * Reordered react hooks + frontend sections * Updated the overview and nav * Separated out SWR hooks * Restructured metadata sections * Improved backend docs * Fixed broken link * Fixed broken links * Updated the structure * Restructured overview * Updated examples cards * Improved overview * Updated how it works * Updated auth * Added type safety to the run object page * made the subscribe description clearer * Fixed links in triggering * Fixed link * Removed examples footers * Copy tweak * Consolidated metadata and subscribe pages * moved metadata task examples to metadata * Fixed links * Fixing links like zelda * Removed dead import * Clearer titles * Cap R for Realtime * Fixes broken link --------- Co-authored-by: James Ritchie --- docs/docs.json | 78 ++- docs/frontend/overview.mdx | 171 ----- docs/frontend/react-hooks/overview.mdx | 337 ---------- docs/frontend/react-hooks/realtime.mdx | 420 ------------ .../example-projects/batch-llm-evaluator.mdx | 4 +- .../human-in-the-loop-workflow.mdx | 5 +- .../vercel-ai-sdk-deep-research.mdx | 2 +- .../vercel-ai-sdk-image-generator.mdx | 4 +- docs/introduction.mdx | 20 +- docs/management/authentication.mdx | 4 +- docs/manual-setup.mdx | 2 +- docs/realtime/auth.mdx | 204 ++++++ docs/realtime/backend/overview.mdx | 42 ++ docs/realtime/{ => backend}/streams.mdx | 160 +---- docs/realtime/backend/subscribe.mdx | 225 +++++++ docs/realtime/how-it-works.mdx | 92 +++ docs/realtime/overview.mdx | 275 +------- docs/realtime/react-hooks.mdx | 7 - docs/realtime/react-hooks/overview.mdx | 73 ++ docs/realtime/react-hooks/streams.mdx | 221 ++++++ docs/realtime/react-hooks/subscribe.mdx | 630 ++++++++++++++++++ docs/realtime/react-hooks/swr.mdx | 87 +++ .../react-hooks/triggering.mdx | 86 +-- docs/realtime/run-object.mdx | 174 +++++ docs/realtime/subscribe-to-batch.mdx | 49 -- docs/realtime/subscribe-to-run.mdx | 49 -- docs/realtime/subscribe-to-runs-with-tag.mdx | 49 -- docs/runs/metadata.mdx | 100 ++- docs/snippets/realtime-examples-cards.mdx | 16 +- docs/snippets/realtime-learn-more.mdx | 4 +- docs/triggering.mdx | 2 +- docs/video-walkthrough.mdx | 13 +- docs/wait-for-token.mdx | 2 +- 33 files changed, 2017 insertions(+), 1590 deletions(-) delete mode 100644 docs/frontend/overview.mdx delete mode 100644 docs/frontend/react-hooks/overview.mdx delete mode 100644 docs/frontend/react-hooks/realtime.mdx create mode 100644 docs/realtime/auth.mdx create mode 100644 docs/realtime/backend/overview.mdx rename docs/realtime/{ => backend}/streams.mdx (60%) create mode 100644 docs/realtime/backend/subscribe.mdx create mode 100644 docs/realtime/how-it-works.mdx delete mode 100644 docs/realtime/react-hooks.mdx create mode 100644 docs/realtime/react-hooks/overview.mdx create mode 100644 docs/realtime/react-hooks/streams.mdx create mode 100644 docs/realtime/react-hooks/subscribe.mdx create mode 100644 docs/realtime/react-hooks/swr.mdx rename docs/{frontend => realtime}/react-hooks/triggering.mdx (67%) create mode 100644 docs/realtime/run-object.mdx delete mode 100644 docs/realtime/subscribe-to-batch.mdx delete mode 100644 docs/realtime/subscribe-to-run.mdx delete mode 100644 docs/realtime/subscribe-to-runs-with-tag.mdx diff --git a/docs/docs.json b/docs/docs.json index 9f12c13a3e8..e2988073684 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -131,30 +131,32 @@ ] }, { - "group": "Frontend usage", + "group": "Realtime", "pages": [ - "frontend/overview", + "realtime/overview", + "realtime/how-it-works", + "realtime/run-object", + "realtime/auth", + { + "group": "React hooks (frontend)", + "pages": [ + "realtime/react-hooks/overview", + "realtime/react-hooks/triggering", + "realtime/react-hooks/subscribe", + "realtime/react-hooks/streams", + "realtime/react-hooks/swr" + ] + }, { - "group": "React hooks", + "group": "Backend", "pages": [ - "frontend/react-hooks/overview", - "frontend/react-hooks/realtime", - "frontend/react-hooks/triggering" + "realtime/backend/overview", + "realtime/backend/subscribe", + "realtime/backend/streams" ] } ] }, - { - "group": "Realtime API", - "pages": [ - "realtime/overview", - "realtime/streams", - "realtime/react-hooks", - "realtime/subscribe-to-run", - "realtime/subscribe-to-runs-with-tag", - "realtime/subscribe-to-batch" - ] - }, { "group": "CLI", "pages": [ @@ -621,7 +623,47 @@ }, { "source": "/frontend/react-hooks", - "destination": "/frontend/react-hooks/overview" + "destination": "/realtime/react-hooks/overview" + }, + { + "source": "/frontend/overview", + "destination": "/realtime/auth" + }, + { + "source": "/frontend/react-hooks/overview", + "destination": "/realtime/react-hooks/overview" + }, + { + "source": "/frontend/react-hooks/realtime", + "destination": "/realtime/react-hooks/realtime" + }, + { + "source": "/frontend/react-hooks/triggering", + "destination": "/realtime/react-hooks/triggering" + }, + { + "source": "/realtime/backend", + "destination": "/realtime/backend/overview" + }, + { + "source": "/realtime/streams", + "destination": "/realtime/backend/streams" + }, + { + "source": "/realtime/react-hooks", + "destination": "/realtime/react-hooks/overview" + }, + { + "source": "/realtime/subscribe-to-run", + "destination": "/realtime/backend/subscribe" + }, + { + "source": "/realtime/subscribe-to-runs-with-tag", + "destination": "/realtime/backend/subscribe" + }, + { + "source": "/realtime/subscribe-to-batch", + "destination": "/realtime/backend/subscribe" }, { "source": "/management/projects/runs", diff --git a/docs/frontend/overview.mdx b/docs/frontend/overview.mdx deleted file mode 100644 index 6df411bc3a5..00000000000 --- a/docs/frontend/overview.mdx +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: Overview & Authentication -sidebarTitle: Overview & Auth -description: Using the Trigger.dev SDK from your frontend application. ---- - -You can use our [React hooks](/frontend/react-hooks) in your frontend application to interact with the Trigger.dev API. This guide will show you how to generate Public Access Tokens that can be used to authenticate your requests. - -## Authentication - -To create a Public Access Token, you can use the `auth.createPublicToken` function in your **backend** code: - -```tsx -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken(); // 👈 this public access token has no permissions, so is pretty useless! -``` - -### Scopes - -By default a Public Access Token has no permissions. You must specify the scopes you need when creating a Public Access Token: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - scopes: { - read: { - runs: true, // ❌ this token can read all runs, possibly useful for debugging/testing - }, - }, -}); -``` - -This will allow the token to read all runs, which is probably not what you want. You can specify only certain runs by passing an array of run IDs: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - scopes: { - read: { - runs: ["run_1234", "run_5678"], // ✅ this token can read only these runs - }, - }, -}); -``` - -You can scope the token to only read certain tasks: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - scopes: { - read: { - tasks: ["my-task-1", "my-task-2"], // 👈 this token can read all runs of these tasks - }, - }, -}); -``` - -Or tags: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - scopes: { - read: { - tags: ["my-tag-1", "my-tag-2"], // 👈 this token can read all runs with these tags - }, - }, -}); -``` - -Or a specific batch of runs: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - scopes: { - read: { - batch: "batch_1234", // 👈 this token can read all runs in this batch - }, - }, -}); -``` - -You can also combine scopes. For example, to read runs with specific tags and for specific tasks: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - scopes: { - read: { - tasks: ["my-task-1", "my-task-2"], - tags: ["my-tag-1", "my-tag-2"], - }, - }, -}); -``` - -### Expiration - -By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token: - -```ts -import { auth } from "@trigger.dev/sdk/v3"; - -const publicToken = await auth.createPublicToken({ - expirationTime: "1hr", -}); -``` - -- If `expirationTime` is a string, it will be treated as a time span -- If `expirationTime` is a number, it will be treated as a Unix timestamp -- If `expirationTime` is a `Date`, it will be treated as a date - -The format used for a time span is the same as the [jose package](https://github.com/panva/jose), which is a number followed by a unit. Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins", "m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year", "years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an alias for a year. If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets subtracted from the current unix timestamp. A "from now" suffix can also be used for readability when adding to the current unix timestamp. - -## Auto-generated tokens - -When triggering a task from your backend, the `handle` received from the `trigger` function now includes a `publicAccessToken` field. This token can be used to authenticate requests in your frontend application: - -```ts -import { tasks } from "@trigger.dev/sdk/v3"; - -const handle = await tasks.trigger("my-task", { some: "data" }); - -console.log(handle.publicAccessToken); -``` - -By default, tokens returned from the `trigger` function expire after 15 minutes and have a read scope for that specific run. You can customize the expiration of the auto-generated tokens by passing a `publicTokenOptions` object to the `trigger` function: - -```ts -import { tasks } from "@trigger.dev/sdk/v3"; - -const handle = await tasks.trigger( - "my-task", - { some: "data" }, - { - tags: ["my-tag"], - }, - { - publicAccessToken: { - expirationTime: "1hr", - }, - } -); -``` - -You will also get back a Public Access Token when using the `batchTrigger` function: - -```ts -import { tasks } from "@trigger.dev/sdk/v3"; - -const handle = await tasks.batchTrigger("my-task", [ - { payload: { some: "data" } }, - { payload: { some: "data" } }, - { payload: { some: "data" } }, -]); - -console.log(handle.publicAccessToken); -``` - -## Usage - -To learn how to use these Public Access Tokens, see our [React hooks](/frontend/react-hooks) guide. diff --git a/docs/frontend/react-hooks/overview.mdx b/docs/frontend/react-hooks/overview.mdx deleted file mode 100644 index 3dedfcaa2b3..00000000000 --- a/docs/frontend/react-hooks/overview.mdx +++ /dev/null @@ -1,337 +0,0 @@ ---- -title: Overview -sidebarTitle: Overview -description: Using the Trigger.dev v3 API from your React application. ---- - -import RealtimeExamplesCards from "/snippets/realtime-examples-cards.mdx"; - -Our react hooks package provides a set of hooks that make it easy to interact with the Trigger.dev API from your React application, using our [frontend API](/frontend/overview). You can use these hooks to fetch runs, and subscribe to real-time updates, and trigger tasks from your frontend application. - -## Installation - -Install the `@trigger.dev/react-hooks` package in your project: - - - -```bash npm -npm add @trigger.dev/react-hooks -``` - -```bash pnpm -pnpm add @trigger.dev/react-hooks -``` - -```bash yarn -yarn install @trigger.dev/react-hooks -``` - - - -## Authentication - -All hooks accept an optional last argument `options` that accepts an `accessToken` param, which should be a valid Public Access Token. Learn more about [generating tokens in the frontend guide](/frontend/overview). - -```tsx -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, // This is required - baseURL: "https://your-trigger-dev-instance.com", // optional, only needed if you are self-hosting Trigger.dev - }); - - // ... -} -``` - -Alternatively, you can use our `TriggerAuthContext` provider - -```tsx -import { TriggerAuthContext } from "@trigger.dev/react-hooks"; - -export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) { - return ( - - - - ); -} -``` - -Now children components can use the hooks to interact with the Trigger.dev API. If you are self-hosting Trigger.dev, you can provide the `baseURL` to the `TriggerAuthContext` provider. - -```tsx -import { TriggerAuthContext } from "@trigger.dev/react-hooks"; - -export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) { - return ( - - - - ); -} -``` - -### Next.js and client components - -If you are using Next.js with the App Router, you have to make sure the component that uses the `TriggerAuthContext` is a client component. So for example, the following code will not work: - -```tsx app/page.tsx -import { TriggerAuthContext } from "@trigger.dev/react-hooks"; - -export default function Page() { - return ( - - - - ); -} -``` - -That's because `Page` is a server component and the `TriggerAuthContext.Provider` uses client-only react code. To fix this, wrap the `TriggerAuthContext.Provider` in a client component: - -```ts components/TriggerProvider.tsx -"use client"; - -import { TriggerAuthContext } from "@trigger.dev/react-hooks"; - -export function TriggerProvider({ - accessToken, - children, -}: { - accessToken: string; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} -``` - -### Passing the token to the frontend - -Techniques for passing the token to the frontend vary depending on your setup. Here are a few ways to do it for different setups: - -#### Next.js App Router - -If you are using Next.js with the App Router and you are triggering a task from a server action, you can use cookies to store and pass the token to the frontend. - -```tsx actions/trigger.ts -"use server"; - -import { tasks } from "@trigger.dev/sdk/v3"; -import type { exampleTask } from "@/trigger/example"; -import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; - -export async function startRun() { - const handle = await tasks.trigger("example", { foo: "bar" }); - - // Set the auto-generated publicAccessToken in a cookie - cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run - - redirect(`/runs/${handle.id}`); -} -``` - -Then in the `/runs/[id].tsx` page, you can read the token from the cookie and pass it to the `TriggerProvider`. - -```tsx pages/runs/[id].tsx -import { TriggerProvider } from "@/components/TriggerProvider"; - -export default function RunPage({ params }: { params: { id: string } }) { - const publicAccessToken = cookies().get("publicAccessToken"); - - return ( - - - - ); -} -``` - -Instead of a cookie, you could also use a query parameter to pass the token to the frontend: - -```tsx actions/trigger.ts -import { tasks } from "@trigger.dev/sdk/v3"; -import type { exampleTask } from "@/trigger/example"; -import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; - -export async function startRun() { - const handle = await tasks.trigger("example", { foo: "bar" }); - - redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`); -} -``` - -And then in the `/runs/[id].tsx` page: - -```tsx pages/runs/[id].tsx -import { TriggerProvider } from "@/components/TriggerProvider"; - -export default function RunPage({ - params, - searchParams, -}: { - params: { id: string }; - searchParams: { publicAccessToken: string }; -}) { - return ( - - - - ); -} -``` - -Another alternative would be to use a server-side rendered page to fetch the token and pass it to the frontend: - - - -```tsx pages/runs/[id].tsx -import { TriggerProvider } from "@/components/TriggerProvider"; -import { generatePublicAccessToken } from "@/trigger/auth"; - -export default async function RunPage({ params }: { params: { id: string } }) { - // This will be executed on the server only - const publicAccessToken = await generatePublicAccessToken(params.id); - - return ( - - - - ); -} -``` - -```tsx trigger/auth.ts -import { auth } from "@trigger.dev/sdk/v3"; - -export async function generatePublicAccessToken(runId: string) { - return auth.createPublicToken({ - scopes: { - read: { - runs: [runId], - }, - }, - expirationTime: "1h", - }); -} -``` - - - -## SWR vs Realtime hooks - -We offer two "styles" of hooks: SWR and Realtime. The SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. The Realtime hooks use [Trigger.dev realtime](/realtime) to subscribe to updates in real-time. - - - It can be a little confusing which one to use because [swr](https://swr.vercel.app/) can also be - configured to poll for updates. But because of rate-limits and the way the Trigger.dev API works, - we recommend using the Realtime hooks for most use-cases. - - -## SWR Hooks - -### useRun - -The `useRun` hook allows you to fetch a run by its ID. - -```tsx -"use client"; // This is needed for Next.js App Router or other RSC frameworks - -import { useRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ runId }: { runId: string }) { - const { run, error, isLoading } = useRun(runId); - - if (isLoading) return
Loading...
; - if (error) return
Error: {error.message}
; - - return
Run: {run.id}
; -} -``` - -The `run` object returned is the same as the [run object](/management/runs/retrieve) returned by the Trigger.dev API. To correctly type the run's payload and output, you can provide the type of your task to the `useRun` hook: - -```tsx -import { useRun } from "@trigger.dev/react-hooks"; -import type { myTask } from "@/trigger/myTask"; - -export function MyComponent({ runId }: { runId: string }) { - const { run, error, isLoading } = useRun(runId, { - refreshInterval: 0, // Disable polling - }); - - if (isLoading) return
Loading...
; - if (error) return
Error: {error.message}
; - - // Now run.payload and run.output are correctly typed - - return
Run: {run.id}
; -} -``` - -### Common options - -You can pass the following options to the all SWR hooks: - - - Revalidate the data when the window regains focus. - - - - Revalidate the data when the browser regains a network connection. - - - - Poll for updates at the specified interval (in milliseconds). Polling is not recommended for most - use-cases. Use the Realtime hooks instead. - - -### Common return values - - - An error object if an error occurred while fetching the data. - - - - A boolean indicating if the data is currently being fetched. - - - - A boolean indicating if the data is currently being revalidated. - - - - A boolean indicating if an error occurred while fetching the data. - - -## Realtime hooks - -See our [Realtime hooks documentation](/frontend/react-hooks/realtime) for more information. - -## Trigger Hooks - -See our [Trigger hooks documentation](/frontend/react-hooks/triggering) for more information. - - diff --git a/docs/frontend/react-hooks/realtime.mdx b/docs/frontend/react-hooks/realtime.mdx deleted file mode 100644 index d1cb260faad..00000000000 --- a/docs/frontend/react-hooks/realtime.mdx +++ /dev/null @@ -1,420 +0,0 @@ ---- -title: Realtime hooks -sidebarTitle: Realtime -description: Get live updates from the Trigger.dev API in your frontend application. ---- - -import RealtimeExamplesCards from "/snippets/realtime-examples-cards.mdx"; - -These hooks allow you to subscribe to runs, batches, and streams using [Trigger.dev realtime](/realtime). Before reading this guide: - -- Read our [Realtime documentation](/realtime) to understand how the Trigger.dev realtime API works. -- Read how to [setup and authenticate](/frontend/overview) using the `@trigger.dev/react-hooks` package. - -## Hooks - -### useRealtimeRun - -The `useRealtimeRun` hook allows you to subscribe to a run by its ID. - -```tsx -"use client"; // This is needed for Next.js App Router or other RSC frameworks - -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, - }); - - if (error) return
Error: {error.message}
; - - return
Run: {run.id}
; -} -``` - -To correctly type the run's payload and output, you can provide the type of your task to the `useRealtimeRun` hook: - -```tsx -import { useRealtimeRun } from "@trigger.dev/react-hooks"; -import type { myTask } from "@/trigger/myTask"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, - }); - - if (error) return
Error: {error.message}
; - - // Now run.payload and run.output are correctly typed - - return
Run: {run.id}
; -} -``` - -You can supply an `onComplete` callback to the `useRealtimeRun` hook to be called when the run is completed or errored. This is useful if you want to perform some action when the run is completed, like navigating to a different page or showing a notification. - -```tsx -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, - onComplete: (run, error) => { - console.log("Run completed", run); - }, - }); - - if (error) return
Error: {error.message}
; - - return
Run: {run.id}
; -} -``` - -See our [Realtime documentation](/realtime) for more information about the type of the run object and more. - -### useRealtimeRunsWithTag - -The `useRealtimeRunsWithTag` hook allows you to subscribe to multiple runs with a specific tag. - -```tsx -"use client"; // This is needed for Next.js App Router or other RSC frameworks - -import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks"; - -export function MyComponent({ tag }: { tag: string }) { - const { runs, error } = useRealtimeRunsWithTag(tag); - - if (error) return
Error: {error.message}
; - - return ( -
- {runs.map((run) => ( -
Run: {run.id}
- ))} -
- ); -} -``` - -To correctly type the runs payload and output, you can provide the type of your task to the `useRealtimeRunsWithTag` hook: - -```tsx -import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks"; -import type { myTask } from "@/trigger/myTask"; - -export function MyComponent({ tag }: { tag: string }) { - const { runs, error } = useRealtimeRunsWithTag(tag); - - if (error) return
Error: {error.message}
; - - // Now runs[i].payload and runs[i].output are correctly typed - - return ( -
- {runs.map((run) => ( -
Run: {run.id}
- ))} -
- ); -} -``` - -If `useRealtimeRunsWithTag` could return multiple different types of tasks, you can pass a union of all the task types to the hook: - -```tsx -import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks"; -import type { myTask1, myTask2 } from "@/trigger/myTasks"; - -export function MyComponent({ tag }: { tag: string }) { - const { runs, error } = useRealtimeRunsWithTag(tag); - - if (error) return
Error: {error.message}
; - - // You can narrow down the type of the run based on the taskIdentifier - for (const run of runs) { - if (run.taskIdentifier === "my-task-1") { - // run is correctly typed as myTask1 - } else if (run.taskIdentifier === "my-task-2") { - // run is correctly typed as myTask2 - } - } - - return ( -
- {runs.map((run) => ( -
Run: {run.id}
- ))} -
- ); -} -``` - -### useRealtimeBatch - -The `useRealtimeBatch` hook allows you to subscribe to a batch of runs by its the batch ID. - -```tsx -"use client"; // This is needed for Next.js App Router or other RSC frameworks - -import { useRealtimeBatch } from "@trigger.dev/react-hooks"; - -export function MyComponent({ batchId }: { batchId: string }) { - const { runs, error } = useRealtimeBatch(batchId); - - if (error) return
Error: {error.message}
; - - return ( -
- {runs.map((run) => ( -
Run: {run.id}
- ))} -
- ); -} -``` - -See our [Realtime documentation](/realtime) for more information. - -### useRealtimeRunWithStreams - -The `useRealtimeRunWithStreams` hook allows you to subscribe to a run by its ID and also receive any streams that are emitted by the task. See our [Realtime documentation](/realtime#streams) for more information about emitting streams from a task. - -```tsx -"use client"; // This is needed for Next.js App Router or other RSC frameworks - -import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, streams, error } = useRealtimeRunWithStreams(runId, { - accessToken: publicAccessToken, - }); - - if (error) return
Error: {error.message}
; - - return ( -
-
Run: {run.id}
-
- {Object.keys(streams).map((stream) => ( -
Stream: {stream}
- ))} -
-
- ); -} -``` - -You can provide the type of the streams to the `useRealtimeRunWithStreams` hook: - -```tsx -import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks"; -import type { myTask } from "@/trigger/myTask"; - -type STREAMS = { - openai: string; // this is the type of each "part" of the stream -}; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, streams, error } = useRealtimeRunWithStreams(runId, { - accessToken: publicAccessToken, - }); - - if (error) return
Error: {error.message}
; - - const text = streams.openai?.map((part) => part).join(""); - - return ( -
-
Run: {run.id}
-
{text}
-
- ); -} -``` - -As you can see above, each stream is an array of the type you provided, keyed by the stream name. If instead of a pure text stream you have a stream of objects, you can provide the type of the object: - -```tsx -import type { TextStreamPart } from "ai"; -import type { myTask } from "@/trigger/myTask"; - -type STREAMS = { openai: TextStreamPart<{}> }; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, streams, error } = useRealtimeRunWithStreams(runId, { - accessToken: publicAccessToken, - }); - - if (error) return
Error: {error.message}
; - - const text = streams.openai - ?.filter((stream) => stream.type === "text-delta") - ?.map((part) => part.text) - .join(""); - - return ( -
-
Run: {run.id}
-
{text}
-
- ); -} -``` - -## Common options - -### accessToken & baseURL - -You can pass the `accessToken` option to the Realtime hooks to authenticate the subscription. - -```tsx -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, - baseURL: "https://my-self-hosted-trigger.com", // Optional if you are using a self-hosted Trigger.dev instance - }); - - if (error) return
Error: {error.message}
; - - return
Run: {run.id}
; -} -``` - -### enabled - -You can pass the `enabled` option to the Realtime hooks to enable or disable the subscription. - -```tsx -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, - enabled, -}: { - runId: string; - publicAccessToken: string; - enabled: boolean; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, - enabled, - }); - - if (error) return
Error: {error.message}
; - - return
Run: {run.id}
; -} -``` - -This allows you to conditionally disable using the hook based on some state. - -### id - -You can pass the `id` option to the Realtime hooks to change the ID of the subscription. - -```tsx -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - id, - runId, - publicAccessToken, - enabled, -}: { - id: string; - runId: string; - publicAccessToken: string; - enabled: boolean; -}) { - const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, - enabled, - id, - }); - - if (error) return
Error: {error.message}
; - - return
Run: {run.id}
; -} -``` - -This allows you to change the ID of the subscription based on some state. Passing in a different ID will unsubscribe from the current subscription and subscribe to the new one (and remove any cached data). - -### experimental_throttleInMs - -The `*withStreams` variants of the Realtime hooks accept an `experimental_throttleInMs` option to throttle the updates from the server. This can be useful if you are getting too many updates and want to reduce the number of updates. - -```tsx -import { useRealtimeRunsWithStreams } from "@trigger.dev/react-hooks"; - -export function MyComponent({ - runId, - publicAccessToken, -}: { - runId: string; - publicAccessToken: string; -}) { - const { runs, error } = useRealtimeRunsWithStreams(tag, { - accessToken: publicAccessToken, - experimental_throttleInMs: 1000, // Throttle updates to once per second - }); - - if (error) return
Error: {error.message}
; - - return ( -
- {runs.map((run) => ( -
Run: {run.id}
- ))} -
- ); -} -``` - - diff --git a/docs/guides/example-projects/batch-llm-evaluator.mdx b/docs/guides/example-projects/batch-llm-evaluator.mdx index c214e80722a..5a043313915 100644 --- a/docs/guides/example-projects/batch-llm-evaluator.mdx +++ b/docs/guides/example-projects/batch-llm-evaluator.mdx @@ -39,11 +39,11 @@ This demo is a full stack example that uses the following: - View the Trigger.dev task code in the [src/trigger/batch.ts](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/trigger/batch.ts) file. - The `evaluateModels` task uses the `batch.triggerByTaskAndWait` method to distribute the task to the different LLM models. - It then passes the results through to a `summarizeEvals` task that calculates some dummy "tags" for each LLM response. -- We use a [useRealtimeRunsWithTag](https://trigger.dev/docs/frontend/react-hooks/realtime#userealtimerunswithtag) hook to subscribe to the different evaluation tasks runs in the [src/components/llm-evaluator.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/llm-evaluator.tsx) file. +- We use a [useRealtimeRunsWithTag](/realtime/react-hooks/subscribe#userealtimerunswithtag) hook to subscribe to the different evaluation tasks runs in the [src/components/llm-evaluator.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/llm-evaluator.tsx) file. - We then pass the relevant run down into three different components for the different models: - The `AnthropicEval` component: [src/components/evals/Anthropic.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/evals/Anthropic.tsx) - The `XAIEval` component: [src/components/evals/XAI.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/evals/XAI.tsx) - The `OpenAIEval` component: [src/components/evals/OpenAI.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/evals/OpenAI.tsx) -- Each of these components then uses [useRealtimeRunWithStreams](https://trigger.dev/docs/frontend/react-hooks/realtime#userealtimerunwithstreams) to subscribe to the different LLM responses. +- Each of these components then uses [useRealtimeRunWithStreams](/realtime/react-hooks/streams) to subscribe to the different LLM responses. diff --git a/docs/guides/example-projects/human-in-the-loop-workflow.mdx b/docs/guides/example-projects/human-in-the-loop-workflow.mdx index 36d5bcc66fa..dbc0f6f7cdc 100644 --- a/docs/guides/example-projects/human-in-the-loop-workflow.mdx +++ b/docs/guides/example-projects/human-in-the-loop-workflow.mdx @@ -79,7 +79,6 @@ await wait.completeToken( - 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 @@ -87,6 +86,6 @@ While the workflow in this example is static and does not allow changing the con 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 +- [Realtime streaming](/realtime/react-hooks/streams) - learn more about streaming data from your tasks +- [React hooks](/realtime/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/example-projects/vercel-ai-sdk-deep-research.mdx b/docs/guides/example-projects/vercel-ai-sdk-deep-research.mdx index aa0d54687c4..33c58925ed7 100644 --- a/docs/guides/example-projects/vercel-ai-sdk-deep-research.mdx +++ b/docs/guides/example-projects/vercel-ai-sdk-deep-research.mdx @@ -110,7 +110,7 @@ Level 0 (Initial Query): "AI safety in autonomous vehicles" ### Using Trigger.dev Realtime to trigger and subscribe to the deep research task -We use the [`useRealtimeTaskTrigger`](/frontend/react-hooks/triggering#userealtimetasktrigger) React hook to trigger the `deep-research` task and subscribe to it's updates. +We use the [`useRealtimeTaskTrigger`](/realtime/react-hooks/triggering#userealtimetasktrigger) React hook to trigger the `deep-research` task and subscribe to it's updates. **Frontend (React Hook)**: diff --git a/docs/guides/example-projects/vercel-ai-sdk-image-generator.mdx b/docs/guides/example-projects/vercel-ai-sdk-image-generator.mdx index 15eda0270ab..ea13ac92b08 100644 --- a/docs/guides/example-projects/vercel-ai-sdk-image-generator.mdx +++ b/docs/guides/example-projects/vercel-ai-sdk-image-generator.mdx @@ -11,7 +11,7 @@ import RealtimeLearnMore from "/snippets/realtime-learn-more.mdx"; This demo is a full stack example that uses the following: - A [Next.js](https://nextjs.org/) app using [shadcn](https://ui.shadcn.com/) for the UI -- Our 'useRealtimeRun' [React hook](https://trigger.dev/docs/frontend/react-hooks/realtime) to subscribe to the run and show updates on the frontend +- Our 'useRealtimeRun' [React hook](/realtime/react-hooks/subscribe#userealtimerun) to subscribe to the run and show updates on the frontend - The [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction) to [generate images](https://sdk.vercel.ai/docs/ai-sdk-core/image-generation) using OpenAI's DALL-E models ## GitHub repo @@ -36,6 +36,6 @@ This demo is a full stack example that uses the following: ## Relevant code - View the Trigger.dev task code which generates the image using the Vercel AI SDK in [src/trigger/realtime-generate-image.ts](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator/src/trigger/realtime-generate-image.ts). -- We use a [useRealtimeRun](https://trigger.dev/docs/frontend/react-hooks/realtime#userealtimerun) hook to subscribe to the run in [src/app/processing/[id]/ProcessingContent.tsx](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator/src/app/processing/[id]/ProcessingContent.tsx). +- We use a [useRealtimeRun](/realtime/react-hooks/subscribe#userealtimerun) hook to subscribe to the run in [src/app/processing/[id]/ProcessingContent.tsx](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator/src/app/processing/[id]/ProcessingContent.tsx). diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 1ab5baaf7aa..0180bf1ef54 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -12,7 +12,11 @@ mode: "center" Explore dozens of examples tasks to use in your own projects - + Learn how to use Trigger.dev with your favorite frameworks @@ -24,7 +28,7 @@ mode: "center" Trigger.dev is an open source background jobs framework that lets you write reliable workflows in plain async code. Run long-running AI tasks, handle complex background jobs, and build AI agents with built-in queuing, automatic retries, and real-time monitoring. No timeouts, elastic scaling, and zero infrastructure management required. -We provide everything you need to build and manage background tasks: a CLI and SDK for writing tasks in your existing codebase, support for both [regular](/tasks/overview) and [scheduled](/tasks/scheduled) tasks, full observability through our dashboard, and a [Realtime API](/realtime) with [React hooks](/frontend/react-hooks#realtime-hooks) for showing task status in your frontend. You can use [Trigger.dev Cloud](https://cloud.trigger.dev) or [self-host](/open-source-self-hosting) on your own infrastructure. +We provide everything you need to build and manage background tasks: a CLI and SDK for writing tasks in your existing codebase, support for both [regular](/tasks/overview) and [scheduled](/tasks/scheduled) tasks, full observability through our dashboard, and a [Realtime API](/realtime) with [React hooks](/realtime/react-hooks#realtime-hooks) for showing task status in your frontend. You can use [Trigger.dev Cloud](https://cloud.trigger.dev) or [self-host](/open-source-self-hosting) on your own infrastructure. ## Learn the concepts @@ -39,7 +43,8 @@ We provide everything you need to build and manage background tasks: a CLI and S Runs are the instances of tasks that are executed. Learn how they work. - API keys are used to authenticate requests to the Trigger.dev API. Learn how to create and use them. + API keys are used to authenticate requests to the Trigger.dev API. Learn how to create and use + them. @@ -52,13 +57,18 @@ We provide everything you need to build and manage background tasks: a CLI and S The Realtime API allows you to trigger tasks and get the status of runs. - + React hooks are a way to show task status in your frontend. Waits are a way to wait for a task to finish before continuing. - + Learn how to handle errors and retries. diff --git a/docs/management/authentication.mdx b/docs/management/authentication.mdx index 2d24c1f3541..701a017fcb4 100644 --- a/docs/management/authentication.mdx +++ b/docs/management/authentication.mdx @@ -8,7 +8,7 @@ There are two methods of authenticating with the management API: using a secret There is a separate authentication strategy when making requests from your frontend application. - See the [Frontend guide](/frontend/overview) for more information. This guide is for backend usage + See the [Realtime guide](/realtime/overview) for more information. This guide is for backend usage only. @@ -94,4 +94,4 @@ await envvars.upload("proj_1234", "dev", { }, override: true, }); -``` \ No newline at end of file +``` diff --git a/docs/manual-setup.mdx b/docs/manual-setup.mdx index 328877de1b5..28bf46c2e37 100644 --- a/docs/manual-setup.mdx +++ b/docs/manual-setup.mdx @@ -296,7 +296,7 @@ export function TaskStatus({ } ``` -For more information, see the [React Hooks documentation](/frontend/react-hooks/overview). +For more information, see the [React Hooks documentation](/realtime/react-hooks/overview). ## Build extensions diff --git a/docs/realtime/auth.mdx b/docs/realtime/auth.mdx new file mode 100644 index 00000000000..e574d07e6bd --- /dev/null +++ b/docs/realtime/auth.mdx @@ -0,0 +1,204 @@ +--- +title: Realtime authentication +sidebarTitle: Realtime auth +description: Authenticating real-time API requests with Public Access Tokens or Trigger Tokens +--- + +To use the Realtime API, you need to authenticate your requests with Public Access Tokens or Trigger Tokens. These tokens provide secure, scoped access to your runs and can be used in both frontend and backend applications. + +## Token Types + +There are two types of tokens you can use with the Realtime API: + +- **[Public Access Tokens](#public-access-tokens-for-subscribing-to-runs)** - Used to read and subscribe to run data. Can be used in both the frontend and backend. +- **[Trigger Tokens](#trigger-tokens-for-frontend-triggering-only)** - Used to trigger tasks from your frontend. These are more secure, single-use tokens that can only be used in the frontend. + +## Public Access Tokens (for subscribing to runs) + +Use Public Access Tokens to subscribe to runs and receive real-time updates in your frontend or backend. + +### Creating Public Access Tokens + +You can create a Public Access Token using the `auth.createPublicToken` function in your **backend** code: + +```tsx +// Somewhere in your backend code +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken(); // 👈 this public access token has no permissions, so is pretty useless! +``` + +### Scopes + +By default a Public Access Token has no permissions. You must specify the scopes you need when creating a Public Access Token: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + runs: true, // ❌ this token can read all runs, possibly useful for debugging/testing + }, + }, +}); +``` + +This will allow the token to read all runs, which is probably not what you want. You can specify only certain runs by passing an array of run IDs: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + runs: ["run_1234", "run_5678"], // ✅ this token can read only these runs + }, + }, +}); +``` + +You can scope the token to only read certain tasks: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + tasks: ["my-task-1", "my-task-2"], // 👈 this token can read all runs of these tasks + }, + }, +}); +``` + +Or tags: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + tags: ["my-tag-1", "my-tag-2"], // 👈 this token can read all runs with these tags + }, + }, +}); +``` + +Or a specific batch of runs: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + batch: "batch_1234", // 👈 this token can read all runs in this batch + }, + }, +}); +``` + +You can also combine scopes. For example, to read runs with specific tags and for specific tasks: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + tasks: ["my-task-1", "my-task-2"], + tags: ["my-tag-1", "my-tag-2"], + }, + }, +}); +``` + +### Expiration + +By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +const publicToken = await auth.createPublicToken({ + expirationTime: "1hr", +}); +``` + +- If `expirationTime` is a string, it will be treated as a time span +- If `expirationTime` is a number, it will be treated as a Unix timestamp +- If `expirationTime` is a `Date`, it will be treated as a date + +The format used for a time span is the same as the [jose package](https://github.com/panva/jose), which is a number followed by a unit. Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins", "m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year", "years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an alias for a year. If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets subtracted from the current unix timestamp. A "from now" suffix can also be used for readability when adding to the current unix timestamp. + +### Auto-generated tokens + +When you [trigger tasks](/triggering) from your backend, the `handle` received includes a `publicAccessToken` field. This token can be used to authenticate real-time requests in your frontend application. + +By default, auto-generated tokens expire after 15 minutes and have a read scope for the specific run(s) that were triggered. You can customize the expiration by passing a `publicTokenOptions` object to the trigger function. + +See our [triggering documentation](/triggering) for detailed examples of how to trigger tasks and get auto-generated tokens. + +### Subscribing to runs with Public Access Tokens + +Once you have a Public Access Token, you can use it to authenticate requests to the Realtime API in both backend and frontend applications. + +**Backend usage:** See our [backend documentation](/realtime/backend) for examples of what you can do with Realtime in your backend once you have authenticated with a token. + +**Frontend usage:** See our [React hooks documentation](/realtime/react-hooks) for examples of using tokens with frontend components. + +## Trigger Tokens (for frontend triggering only) + +For triggering tasks from your frontend, you need special "trigger" tokens. These tokens can only be used once to trigger a task and are more secure than regular Public Access Tokens. + +### Creating Trigger Tokens + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +const triggerToken = await auth.createTriggerPublicToken("my-task"); +``` + +### Multiple tasks + +You can pass multiple tasks to create a token that can trigger multiple tasks: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +const triggerToken = await auth.createTriggerPublicToken(["my-task-1", "my-task-2"]); +``` + +### Multiple use + +You can also create tokens that can be used multiple times: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +const triggerToken = await auth.createTriggerPublicToken("my-task", { + multipleUse: true, // ❌ Use this with caution! +}); +``` + +### Expiration + +These tokens also expire, with the default expiration time being 15 minutes. You can specify a custom expiration time: + +```ts +import { auth } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +const triggerToken = await auth.createTriggerPublicToken("my-task", { + expirationTime: "24hr", +}); +``` + +### Triggering tasks from the frontend with Trigger Tokens + +Check out our [React hooks documentation](/realtime/react-hooks) for examples of how to use Trigger Tokens in your frontend applications. diff --git a/docs/realtime/backend/overview.mdx b/docs/realtime/backend/overview.mdx new file mode 100644 index 00000000000..079004e9231 --- /dev/null +++ b/docs/realtime/backend/overview.mdx @@ -0,0 +1,42 @@ +--- +title: Backend overview +sidebarTitle: Overview +description: Using the Trigger.dev realtime API from your backend code +--- + +import RealtimeExamplesCards from "/snippets/realtime-examples-cards.mdx"; + +Use these backend functions to subscribe to runs and streams from your server-side code or other tasks. + +## Overview + +There are three main categories of functionality: + +- **[Subscribe functions](/realtime/backend/subscribe)** - Subscribe to run updates using async iterators +- **[Metadata](/realtime/backend/subscribe#subscribe-to-metadata-updates-from-your-tasks)** - Update and subscribe to run metadata in real-time +- **[Streams](/realtime/backend/streams)** - Emit and consume real-time streaming data from your tasks + +## Authentication + +All backend functions support both server-side and client-side authentication: + +- **Server-side**: Use your API key (automatically handled in tasks) +- **Client-side**: Generate a Public Access Token with appropriate scopes + +See our [authentication guide](/realtime/auth) for detailed information on creating and using tokens. + +## Quick example + +Subscribe to a run: + +```ts +import { runs, tasks } from "@trigger.dev/sdk/v3"; + +// Trigger a task +const handle = await tasks.trigger("my-task", { some: "data" }); + +// Subscribe to real-time updates +for await (const run of runs.subscribeToRun(handle.id)) { + console.log(`Run ${run.id} status: ${run.status}`); +} +``` diff --git a/docs/realtime/streams.mdx b/docs/realtime/backend/streams.mdx similarity index 60% rename from docs/realtime/streams.mdx rename to docs/realtime/backend/streams.mdx index aa74a68e096..7a5ae28d511 100644 --- a/docs/realtime/streams.mdx +++ b/docs/realtime/backend/streams.mdx @@ -1,20 +1,23 @@ --- -title: Realtime streams +title: Streams sidebarTitle: Streams -description: Stream data in realtime from inside your tasks +description: Emit and consume real-time streaming data from your tasks --- -import RealtimeExamplesCards from "/snippets/realtime-examples-cards.mdx"; +The Streams API allows you to stream data from your tasks to the outside world in realtime using the [metadata](/runs/metadata) system. This is particularly useful for streaming LLM outputs or any other real-time data. -The world is going realtime, and so should your tasks. With the Streams API, you can stream data from your tasks to the outside world in realtime. This is useful for a variety of use cases, including AI. + + For frontend applications using React, see our [React hooks streams + documentation](/realtime/react-hooks/streams) for consuming streams in your UI. + -## How it works +## How streams work -The Streams API is a simple API that allows you to send data from your tasks to the outside world in realtime using the [metadata](/runs/metadata) system. You can send any kind of data that is streamed in realtime, but the most common use case is to send streaming output from streaming LLM providers, like OpenAI. +Streams use the metadata system to send data chunks in real-time. You register a stream with a specific key using `metadata.stream`, and then consumers can subscribe to receive those chunks as they're emitted. -## Usage +## Basic streaming example -To use the Streams API, you need to register a stream with a specific key using `metadata.stream`. The following example uses the OpenAI SDK with `stream: true` to stream the output of the LLM model in realtime: +Here's how to stream data from OpenAI in your task: ```ts import { task, metadata } from "@trigger.dev/sdk/v3"; @@ -56,12 +59,9 @@ export const myTask = task({ }); ``` -You can then subscribe to the stream using the `runs.subscribeToRun` method: +## Subscribing to streams from backend - - `runs.subscribeToRun` should be used from your backend or another task. To subscribe to a run from - your frontend, you can use our [React hooks](/frontend/react-hooks). - +You can subscribe to the stream using the `runs.subscribeToRun` method with `.withStreams()`: ```ts import { runs } from "@trigger.dev/sdk/v3"; @@ -86,7 +86,9 @@ async function subscribeToStream(runId: string) { } ``` -You can register and subscribe to multiple streams in the same task. Let's add a stream from the response body of a fetch request: +## Multiple streams + +You can register and subscribe to multiple streams in the same task: ```ts import { task, metadata } from "@trigger.dev/sdk/v3"; @@ -97,7 +99,7 @@ const openai = new OpenAI({ }); export type STREAMS = { - openai: OpenAI.ChatCompletionChunk; // The type of the chunk is determined by the provider + openai: OpenAI.ChatCompletionChunk; fetch: string; // The response body will be an array of strings }; @@ -110,7 +112,7 @@ export const myTask = task({ stream: true, }); - // Register the stream with the key "openai" + // Register the OpenAI stream await metadata.stream("openai", completion); const response = await fetch("https://jsonplaceholder.typicode.com/posts"); @@ -119,7 +121,7 @@ export const myTask = task({ return; } - // Register the stream with the key "fetch" + // Register a fetch response stream // Pipe the response.body through a TextDecoderStream to convert it to a string await metadata.stream("fetch", response.body.pipeThrough(new TextDecoderStream())); }, @@ -133,7 +135,7 @@ export const myTask = task({ task. -And then subscribing to the streams: +Then subscribe to both streams: ```ts import { runs } from "@trigger.dev/sdk/v3"; @@ -141,7 +143,6 @@ import type { myTask, STREAMS } from "./trigger/my-task"; // Somewhere in your backend async function subscribeToStream(runId: string) { - // Use a for-await loop to subscribe to the stream for await (const part of runs.subscribeToRun(runId).withStreams()) { switch (part.type) { case "run": { @@ -163,45 +164,9 @@ async function subscribeToStream(runId: string) { } ``` -## React hooks - -If you're building a frontend application, you can use our React hooks to subscribe to streams. Here's an example of how you can use the `useRealtimeRunWithStreams` hook to subscribe to a stream: - -```tsx -import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks"; -import type { myTask, STREAMS } from "./trigger/my-task"; - -// Somewhere in your React component -function MyComponent({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { - const { run, streams } = useRealtimeRunWithStreams(runId, { - accessToken: publicAccessToken, - }); - - if (!run) { - return
Loading...
; - } - - return ( -
-

Run ID: {run.id}

-

Streams:

-
    - {Object.entries(streams).map(([key, value]) => ( -
  • - {key}: {JSON.stringify(value)} -
  • - ))} -
-
- ); -} -``` - -Read more about using the React hooks in the [React hooks](/frontend/react-hooks) documentation. - -## Usage with the `ai` SDK +## Using with the AI SDK -The [ai SDK](https://sdk.vercel.ai/docs/introduction) provides a higher-level API for working with AI models. You can use the `ai` SDK with the Streams API by using the `streamText` method: +The [AI SDK](https://sdk.vercel.ai/docs/introduction) provides a higher-level API for working with AI models. You can use it with the Streams API: ```ts import { openai } from "@ai-sdk/openai"; @@ -244,35 +209,9 @@ export const aiStreaming = schemaTask({ }); ``` -And then render the stream in your frontend: +## Using AI SDK with tools -```tsx -import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks"; -import type { aiStreaming, STREAMS } from "./trigger/ai-streaming"; - -function MyComponent({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { - const { streams } = useRealtimeRunWithStreams(runId, { - accessToken: publicAccessToken, - }); - - if (!streams.openai) { - return
Loading...
; - } - - const text = streams.openai.join(""); // `streams.openai` is an array of strings - - return ( -
-

OpenAI response:

-

{text}

-
- ); -} -``` - -### Using tools and `fullStream` - -When calling `streamText`, you can provide a `tools` object that allows the LLM to use additional tools. You can then access the tool call and results using the `fullStream` method: +When using tools with the AI SDK, you can access tool calls and results using the `fullStream`: ```ts import { openai } from "@ai-sdk/openai"; @@ -306,7 +245,7 @@ export const aiStreamingWithTools = schemaTask({ prompt: z .string() .default( - "Based on the temperature, will I need to wear extra clothes today in San Fransico? Please be detailed." + "Based on the temperature, will I need to wear extra clothes today in San Francisco? Please be detailed." ), }), run: async ({ model, prompt }) => { @@ -338,50 +277,9 @@ export const aiStreamingWithTools = schemaTask({ }); ``` -Now you can get access to the tool call and results in your frontend: +## Using toolTask -```tsx -import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks"; -import type { aiStreamingWithTools, STREAMS } from "./trigger/ai-streaming"; - -function MyComponent({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { - const { streams } = useRealtimeRunWithStreams(runId, { - accessToken: publicAccessToken, - }); - - if (!streams.openai) { - return
Loading...
; - } - - // streams.openai is an array of TextStreamPart - const toolCall = streams.openai.find( - (stream) => stream.type === "tool-call" && stream.toolName === "getWeather" - ); - const toolResult = streams.openai.find((stream) => stream.type === "tool-result"); - const textDeltas = streams.openai.filter((stream) => stream.type === "text-delta"); - - const text = textDeltas.map((delta) => delta.textDelta).join(""); - const weatherLocation = toolCall ? toolCall.args.location : undefined; - const weather = toolResult ? toolResult.result.temperature : undefined; - - return ( -
-

OpenAI response:

-

{text}

-

Weather:

-

- {weatherLocation - ? `The weather in ${weatherLocation} is ${weather} degrees.` - : "No weather data"} -

-
- ); -} -``` - -### Using `toolTask` - -As you can see above, we defined a tool which will be used in the `aiStreamingWithTools` task. You can also define a Trigger.dev task that can be used as a tool, and will automatically be invoked with `triggerAndWait` when the tool is called. This is done using the `toolTask` function: +You can define a Trigger.dev task that can be used as a tool, and will automatically be invoked with `triggerAndWait` when the tool is called: ```ts import { openai } from "@ai-sdk/openai"; @@ -418,7 +316,7 @@ export const aiStreamingWithTools = schemaTask({ prompt: z .string() .default( - "Based on the temperature, will I need to wear extra clothes today in San Fransico? Please be detailed." + "Based on the temperature, will I need to wear extra clothes today in San Francisco? Please be detailed." ), }), run: async ({ model, prompt }) => { @@ -451,5 +349,3 @@ export const aiStreamingWithTools = schemaTask({ }, }); ``` - - diff --git a/docs/realtime/backend/subscribe.mdx b/docs/realtime/backend/subscribe.mdx new file mode 100644 index 00000000000..973ce1414c8 --- /dev/null +++ b/docs/realtime/backend/subscribe.mdx @@ -0,0 +1,225 @@ +--- +title: Subscribe functions +sidebarTitle: Subscribe +description: Subscribe to run updates using async iterators +--- + +These functions allow you to subscribe to run updates from your backend code. Each function returns an async iterator that yields run objects as they change. + +## runs.subscribeToRun + +Subscribes to all changes to a specific run. + +```ts Example +import { runs } from "@trigger.dev/sdk/v3"; + +for await (const run of runs.subscribeToRun("run_1234")) { + console.log(run); +} +``` + +This function subscribes to all changes to a run. It returns an async iterator that yields the run object whenever the run is updated. The iterator will complete when the run is finished. + +**Authentication**: This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with read access to the specific run. See our [authentication guide](/realtime/auth) for details. + +**Response**: The AsyncIterator yields the [run object](/realtime/run-object). + +## runs.subscribeToRunsWithTag + +Subscribes to all changes to runs with a specific tag. + +```ts Example +import { runs } from "@trigger.dev/sdk/v3"; + +for await (const run of runs.subscribeToRunsWithTag("user:1234")) { + console.log(run); +} +``` + +This function subscribes to all changes to runs with a specific tag. It returns an async iterator that yields the run object whenever a run with the specified tag is updated. This iterator will never complete, so you must manually break out of the loop when you no longer want to receive updates. + +**Authentication**: This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with read access to the specific tag. See our [authentication guide](/realtime/auth) for details. + +**Response**: The AsyncIterator yields the [run object](/realtime/run-object). + +## runs.subscribeToBatch + +Subscribes to all changes for runs in a batch. + +```ts Example +import { runs } from "@trigger.dev/sdk/v3"; + +for await (const run of runs.subscribeToBatch("batch_1234")) { + console.log(run); +} +``` + +This function subscribes to all changes for runs in a batch. It returns an async iterator that yields a run object whenever a run in the batch is updated. The iterator does not complete on its own, you must manually `break` the loop when you want to stop listening for updates. + +**Authentication**: This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with read access to the specific batch. See our [authentication guide](/realtime/auth) for details. + +**Response**: The AsyncIterator yields the [run object](/realtime/run-object). + +## Type safety + +You can infer the types of the run's payload and output by passing the type of the task to the subscribe functions: + +```ts +import { runs, tasks } from "@trigger.dev/sdk/v3"; +import type { myTask } from "./trigger/my-task"; + +async function myBackend() { + const handle = await tasks.trigger("my-task", { some: "data" }); + + for await (const run of runs.subscribeToRun(handle.id)) { + // run.payload and run.output are now typed + console.log(run.payload.some); + + if (run.output) { + console.log(run.output.some); + } + } +} +``` + +When using `subscribeToRunsWithTag`, you can pass a union of task types: + +```ts +import { runs } from "@trigger.dev/sdk/v3"; +import type { myTask, myOtherTask } from "./trigger/my-task"; + +for await (const run of runs.subscribeToRunsWithTag("my-tag")) { + // Narrow down the type based on the taskIdentifier + switch (run.taskIdentifier) { + case "my-task": { + console.log("Run output:", run.output.foo); // Type-safe + break; + } + case "my-other-task": { + console.log("Run output:", run.output.bar); // Type-safe + break; + } + } +} +``` + +## Subscribe to metadata updates from your tasks + +The metadata API allows you to update custom metadata on runs and receive real-time updates when metadata changes. This is useful for tracking progress, storing intermediate results, or adding custom status information that can be monitored in real-time. + + + For frontend applications using React, see our [React hooks metadata + documentation](/realtime/react-hooks/subscribe#using-metadata-to-show-progress-in-your-ui) for + consuming metadata updates in your UI. + + +When you update metadata from within a task using `metadata.set()`, `metadata.append()`, or other metadata methods, all subscribers to that run will automatically receive the updated run object containing the new metadata. + +This makes metadata perfect for: + +- Progress tracking +- Status updates +- Intermediate results +- Custom notifications + +Use the metadata API within your task to update metadata in real-time. In this basic example task, we're updating the progress of a task as it processes items. + +### How to subscribe to metadata updates + +This example task updates the progress of a task as it processes items. + +```ts +// Your task code +import { task, metadata } from "@trigger.dev/sdk/v3"; + +export const progressTask = task({ + id: "progress-task", + run: async (payload: { items: string[] }) => { + const total = payload.items.length; + + for (let i = 0; i < payload.items.length; i++) { + // Update progress metadata + metadata.set("progress", { + current: i + 1, + total: total, + percentage: Math.round(((i + 1) / total) * 100), + currentItem: payload.items[i], + }); + + // Process the item + await processItem(payload.items[i]); + } + + metadata.set("status", "completed"); + return { processed: total }; + }, +}); + +async function processItem(item: string) { + // Simulate work + await new Promise((resolve) => setTimeout(resolve, 1000)); +} +``` + +We can now subscribe to the runs and receive real-time metadata updates. + +```ts +// Somewhere in your backend code +import { runs } from "@trigger.dev/sdk/v3"; +import type { progressTask } from "./trigger/progress-task"; + +async function monitorProgress(runId: string) { + for await (const run of runs.subscribeToRun(runId)) { + console.log(`Run ${run.id} status: ${run.status}`); + + if (run.metadata?.progress) { + const progress = run.metadata.progress as { + current: number; + total: number; + percentage: number; + currentItem: string; + }; + + console.log(`Progress: ${progress.current}/${progress.total} (${progress.percentage}%)`); + console.log(`Processing: ${progress.currentItem}`); + } + + if (run.metadata?.status === "completed") { + console.log("Task completed!"); + break; + } + } +} +``` + +For more information on how to write tasks that use the metadata API, as well as more examples, see our [run metadata docs](/runs/metadata#more-metadata-task-examples). + +### Type safety + +You can get type safety for your metadata by defining types: + +```ts +import { runs } from "@trigger.dev/sdk/v3"; +import type { progressTask } from "./trigger/progress-task"; + +interface ProgressMetadata { + progress?: { + current: number; + total: number; + percentage: number; + currentItem: string; + }; + status?: "running" | "completed" | "failed"; +} + +async function monitorTypedProgress(runId: string) { + for await (const run of runs.subscribeToRun(runId)) { + const metadata = run.metadata as ProgressMetadata; + + if (metadata?.progress) { + // Now you have full type safety + console.log(`Progress: ${metadata.progress.percentage}%`); + } + } +} +``` diff --git a/docs/realtime/how-it-works.mdx b/docs/realtime/how-it-works.mdx new file mode 100644 index 00000000000..594b9da926e --- /dev/null +++ b/docs/realtime/how-it-works.mdx @@ -0,0 +1,92 @@ +--- +title: How it works +sidebarTitle: How it works +description: Technical details about how the Trigger.dev Realtime API works +--- + +## Architecture + +The Realtime API is built on top of [Electric SQL](https://electric-sql.com/), an open-source PostgreSQL syncing engine. The Trigger.dev API wraps Electric SQL and provides a simple API to subscribe to [runs](/runs) and get real-time updates. + +## Run changes + +You will receive updates whenever a run changes for the following reasons: + +- The run moves to a new state. See our [run lifecycle docs](/runs#the-run-lifecycle) for more information. +- [Run tags](/tags) are added or removed. +- [Run metadata](/runs/metadata) is updated. + +## Run object + +The run object returned by Realtime subscriptions is optimized for streaming updates and differs from the management API's run object. See [the run object](/realtime/run-object) page for the complete schema and field descriptions. + +## Basic usage + +After you trigger a task, you can subscribe to the run using the `runs.subscribeToRun` function. This function returns an async iterator that you can use to get updates on the run status. + +```ts +import { runs, tasks } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +async function myBackend() { + const handle = await tasks.trigger("my-task", { some: "data" }); + + for await (const run of runs.subscribeToRun(handle.id)) { + // This will log the run every time it changes + console.log(run); + } +} +``` + +Every time the run changes, the async iterator will yield the updated run. You can use this to update your UI, log the run status, or take any other action. + +Alternatively, you can subscribe to changes to any run that includes a specific tag (or tags) using the `runs.subscribeToRunsWithTag` function. + +```ts +import { runs } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +for await (const run of runs.subscribeToRunsWithTag("user:1234")) { + // This will log the run every time it changes, for all runs with the tag "user:1234" + console.log(run); +} +``` + +If you've used `batchTrigger` to trigger multiple runs, you can also subscribe to changes to all the runs triggered in the batch using the `runs.subscribeToBatch` function. + +```ts +import { runs } from "@trigger.dev/sdk/v3"; + +// Somewhere in your backend code +for await (const run of runs.subscribeToBatch("batch-id")) { + // This will log the run every time it changes, for all runs in the batch with the ID "batch-id" + console.log(run); +} +``` + +## Run metadata + +The run metadata API gives you the ability to add or update custom metadata on a run, which will cause the run to be updated. This allows you to extend the Realtime API with custom data attached to a run that can be used for various purposes. Some common use cases include: + +- Adding a link to a related resource +- Adding a reference to a user or organization +- Adding a custom status with progress information + +See our [run metadata docs](/runs/metadata) for more on how to write tasks that use the metadata API. + +### Using metadata with Realtime & React hooks + +You can combine run metadata with the Realtime API to bridge the gap between your trigger.dev tasks and your applications in two ways: + +1. Using our [React hooks](/realtime/react-hooks/subscribe#using-metadata) to subscribe to metadata updates and update your UI in real-time. +2. Using our [backend functions](/realtime/backend) to subscribe to metadata updates in your backend. + +## Limits + +The Realtime API in the Trigger.dev Cloud limits the number of concurrent subscriptions, depending on your plan. If you exceed the limit, you will receive an error when trying to subscribe to a run. For more information, see our [pricing page](https://trigger.dev/pricing). + +## Learn more + +- Read our Realtime blog post ["How we built a real-time service that handles 20,000 updates per second](https://trigger.dev/blog/how-we-built-realtime) +- Using Realtime: [React Hooks (frontend)](/realtime/react-hooks) +- Using [Backend (server-side)](/realtime/backend) diff --git a/docs/realtime/overview.mdx b/docs/realtime/overview.mdx index 718181dc165..998a2e62957 100644 --- a/docs/realtime/overview.mdx +++ b/docs/realtime/overview.mdx @@ -1,272 +1,39 @@ --- title: Realtime overview sidebarTitle: Overview -description: Using the Trigger.dev v3 realtime API +description: Using the Trigger.dev Realtime API to trigger and/or subscribe to runs in real-time. --- -import RealtimeExamplesCards from "/snippets/realtime-examples-cards.mdx"; +Trigger.dev Realtime allows you to trigger, subscribe to, and get real-time updates for runs. This is useful for monitoring runs, updating UIs, and building real-time dashboards. -Trigger.dev Realtime is a set of APIs that allow you to subscribe to runs and get real-time updates on the run status. This is useful for monitoring runs, updating UIs, and building realtime dashboards. +You can subscribe to real-time updates for different scopes of runs: -## How it works +- **Specific runs** - Monitor individual run progress by run ID +- **Runs with specific tags** - Track all runs that have certain [tags](/tags) (e.g., all runs tagged with `user:123`) +- **Batch runs** - All runs within a specific batch +- **Trigger + subscribe combos** - Trigger a task and immediately subscribe to its run (frontend only) -The Realtime API is built on top of [Electric SQL](https://electric-sql.com/), an open-source PostgreSQL syncing engine. The Trigger.dev API wraps Electric SQL and provides a simple API to subscribe to [runs](/runs) and get real-time updates. +Once subscribed, you'll receive the complete [run object](/realtime/run-object) with automatic real-time updates whenever the [run changes](/realtime/how-it-works#run-changes), including: -## Walkthrough +- **Status changes** - queued → executing → completed, etc. +- **Metadata updates** - Custom progress tracking, status updates, user data, etc. ([React hooks](/realtime/react-hooks/subscribe#using-metadata-to-show-progress-in-your-ui) | [backend](/realtime/backend/subscribe#subscribe-to-metadata-updates-from-your-tasks)) +- **Tag changes** - When [tags](/tags) are added or removed from the run +- **Stream data** - Real-time data chunks from your tasks, perfect for AI/LLM streaming ([React hooks](/realtime/react-hooks/streams) | [backend](/realtime/backend/streams)) -