From 74335ab57e336f2e297e7d5b499d9645b2f0e7c9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 25 Sep 2024 13:41:17 +0100 Subject: [PATCH 01/10] Run metadata --- .changeset/tasty-rats-rhyme.md | 6 ++ apps/webapp/app/env.server.ts | 1 + .../v3/ApiRetrieveRunPresenter.server.ts | 24 +++-- .../app/presenters/v3/SpanPresenter.server.ts | 15 ++- .../app/routes/api.v1.runs.$runId.metadata.ts | 95 ++++++++++++++++ .../app/routes/resources.runs.$runParam.ts | 15 ++- apps/webapp/app/utils/packets.ts | 36 +++++++ .../v3/marqs/sharedQueueConsumer.server.ts | 7 ++ .../services/createTaskRunAttempt.server.ts | 8 +- .../app/v3/services/triggerTask.server.ts | 10 ++ packages/core/src/v3/apiClient/index.ts | 19 ++++ .../core/src/v3/apiClientManager/index.ts | 24 +++++ packages/core/src/v3/index.ts | 1 + packages/core/src/v3/run-metadata-api.ts | 5 + packages/core/src/v3/runMetadata/index.ts | 101 +++++++++++++++++ packages/core/src/v3/schemas/api.ts | 17 +++ packages/core/src/v3/schemas/common.ts | 2 + packages/core/src/v3/workers/taskExecutor.ts | 7 +- .../migration.sql | 5 + packages/database/prisma/schema.prisma | 8 ++ packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/metadata.ts | 102 ++++++++++++++++++ packages/trigger-sdk/src/v3/shared.ts | 10 ++ .../v3-catalog/src/trigger/runMetadata.ts | 49 +++++++++ 24 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 .changeset/tasty-rats-rhyme.md create mode 100644 apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts create mode 100644 apps/webapp/app/utils/packets.ts create mode 100644 packages/core/src/v3/run-metadata-api.ts create mode 100644 packages/core/src/v3/runMetadata/index.ts create mode 100644 packages/database/prisma/migrations/20240925092304_add_metadata_and_output_to_task_run/migration.sql create mode 100644 packages/trigger-sdk/src/v3/metadata.ts create mode 100644 references/v3-catalog/src/trigger/runMetadata.ts diff --git a/.changeset/tasty-rats-rhyme.md b/.changeset/tasty-rats-rhyme.md new file mode 100644 index 0000000000..47a6382f22 --- /dev/null +++ b/.changeset/tasty-rats-rhyme.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +Add Run metadata to allow for storing up to 8KB of data on a run and update it during the run diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index e4571f7361..6bbc0ce83f 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -212,6 +212,7 @@ const EnvironmentSchema = z.object({ MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000), TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB + TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(8_000), // 8KB }); export type Environment = z.infer; diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 24ea519b1f..90977f3442 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -29,6 +29,8 @@ const commonRunSelect = { completedAt: true, expiredAt: true, delayUntil: true, + metadata: true, + metadataType: true, ttl: true, tags: true, costInCents: true, @@ -157,10 +159,8 @@ export class ApiRetrieveRunPresenter extends BasePresenter { } } - const apiStatus = ApiRetrieveRunPresenter.apiStatusFromRunStatus(taskRun.status); - return { - ...createCommonRunStructure(taskRun), + ...(await createCommonRunStructure(taskRun)), payload: $payload, payloadPresignedUrl: $payloadPresignedUrl, output: $output, @@ -191,11 +191,15 @@ export class ApiRetrieveRunPresenter extends BasePresenter { error: ApiRetrieveRunPresenter.apiErrorFromError(a.error), })), relatedRuns: { - root: taskRun.rootTaskRun ? createCommonRunStructure(taskRun.rootTaskRun) : undefined, + root: taskRun.rootTaskRun + ? await createCommonRunStructure(taskRun.rootTaskRun) + : undefined, parent: taskRun.parentTaskRun - ? createCommonRunStructure(taskRun.parentTaskRun) + ? await createCommonRunStructure(taskRun.parentTaskRun) : undefined, - children: taskRun.childRuns.map((r) => createCommonRunStructure(r)), + children: await Promise.all( + taskRun.childRuns.map(async (r) => await createCommonRunStructure(r)) + ), }, }; }); @@ -329,7 +333,12 @@ export class ApiRetrieveRunPresenter extends BasePresenter { } } -function createCommonRunStructure(run: CommonRelatedRun) { +async function createCommonRunStructure(run: CommonRelatedRun) { + const metadata = await parsePacket({ + data: run.metadata ?? undefined, + dataType: run.metadataType, + }); + return { id: run.friendlyId, taskIdentifier: run.taskIdentifier, @@ -354,6 +363,7 @@ function createCommonRunStructure(run: CommonRelatedRun) { ...ApiRetrieveRunPresenter.apiBooleanHelpersFromTaskRunStatus(run.status), triggerFunction: resolveTriggerFunction(run), batchId: run.batch?.friendlyId, + metadata, }; } diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 7fbcd7a7a0..011eed0c1a 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -1,4 +1,9 @@ -import { MachinePresetName, prettyPrintPacket, TaskRunError } from "@trigger.dev/core/v3"; +import { + MachinePresetName, + parsePacket, + prettyPrintPacket, + TaskRunError, +} from "@trigger.dev/core/v3"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { eventRepository } from "~/v3/eventRepository.server"; import { machinePresetFromName } from "~/v3/machinePresets.server"; @@ -113,6 +118,8 @@ export class SpanPresenter extends BasePresenter { }, payload: true, payloadType: true, + metadata: true, + metadataType: true, maxAttempts: true, project: { include: { @@ -185,6 +192,11 @@ export class SpanPresenter extends BasePresenter { const span = await eventRepository.getSpan(spanId, run.traceId); + const metadata = await parsePacket({ + data: run.metadata ?? undefined, + dataType: run.metadataType, + }); + const context = { task: { id: run.taskIdentifier, @@ -203,6 +215,7 @@ export class SpanPresenter extends BasePresenter { baseCostInCents: run.baseCostInCents, maxAttempts: run.maxAttempts ?? undefined, version: run.lockedToVersion?.version, + metadata, }, queue: { name: run.queue, diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts new file mode 100644 index 0000000000..b133d369e0 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts @@ -0,0 +1,95 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { AddTagsRequestBody, parsePacket, UpdateMetadataRequestBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { createTag, getTagsForRunId, MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { handleMetadataPacket } from "~/utils/packets"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { FINAL_RUN_STATUSES } from "~/v3/taskStatus"; + +const ParamsSchema = z.object({ + runId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + // Ensure this is a POST request + if (request.method.toUpperCase() !== "PUT") { + return { status: 405, body: "Method Not Allowed" }; + } + + // Authenticate the request + const authenticationResult = await authenticateApiRequest(request); + if (!authenticationResult) { + return json({ error: "Invalid or Missing API Key" }, { status: 401 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + if (!parsedParams.success) { + return json( + { error: "Invalid request parameters", issues: parsedParams.error.issues }, + { status: 400 } + ); + } + + try { + const anyBody = await request.json(); + + const body = UpdateMetadataRequestBody.safeParse(anyBody); + + if (!body.success) { + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); + } + + const metadataPacket = handleMetadataPacket( + body.data.metadata, + body.data.metadataType ?? "application/json" + ); + + if (!metadataPacket) { + return json({ error: "Invalid metadata" }, { status: 400 }); + } + + const taskRun = await prisma.taskRun.findFirst({ + where: { + friendlyId: parsedParams.data.runId, + runtimeEnvironmentId: authenticationResult.environment.id, + }, + select: { + status: true, + }, + }); + + if (!taskRun) { + return json({ error: "Task Run not found" }, { status: 404 }); + } + + if (FINAL_RUN_STATUSES.includes(taskRun.status)) { + return json({ error: "Cannot update metadata for a completed run" }, { status: 400 }); + } + + await prisma.taskRun.update({ + where: { + friendlyId: parsedParams.data.runId, + runtimeEnvironmentId: authenticationResult.environment.id, + }, + data: { + metadata: metadataPacket?.data, + metadataType: metadataPacket?.dataType, + }, + }); + + const parsedPacket = await parsePacket(metadataPacket); + + return json({ metadata: parsedPacket }, { status: 200 }); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: error.status ?? 422 }); + } else { + return json( + { error: error instanceof Error ? error.message : "Internal Server Error" }, + { status: 500 } + ); + } + } +} diff --git a/apps/webapp/app/routes/resources.runs.$runParam.ts b/apps/webapp/app/routes/resources.runs.$runParam.ts index 744dff7964..0cb942f3cb 100644 --- a/apps/webapp/app/routes/resources.runs.$runParam.ts +++ b/apps/webapp/app/routes/resources.runs.$runParam.ts @@ -1,5 +1,10 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { MachinePresetName, prettyPrintPacket, TaskRunError } from "@trigger.dev/core/v3"; +import { + MachinePresetName, + parsePacket, + prettyPrintPacket, + TaskRunError, +} from "@trigger.dev/core/v3"; import { typedjson, UseDataFunctionReturn } from "remix-typedjson"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { $replica } from "~/db.server"; @@ -72,6 +77,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, payload: true, payloadType: true, + metadata: true, + metadataType: true, maxAttempts: true, project: { include: { @@ -151,6 +158,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } } + const metadata = await parsePacket({ + data: run.metadata ?? undefined, + dataType: run.metadataType, + }); + const context = { task: { id: run.taskIdentifier, @@ -169,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { baseCostInCents: run.baseCostInCents, maxAttempts: run.maxAttempts ?? undefined, version: run.lockedToVersion?.version, + metadata, }, queue: { name: run.queue, diff --git a/apps/webapp/app/utils/packets.ts b/apps/webapp/app/utils/packets.ts new file mode 100644 index 0000000000..42116c26e7 --- /dev/null +++ b/apps/webapp/app/utils/packets.ts @@ -0,0 +1,36 @@ +import { IOPacket } from "@trigger.dev/core/v3/utils/ioSerialization"; +import { env } from "~/env.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; + +export class MetadataTooLargeError extends ServiceValidationError { + constructor(message: string) { + super(message, 413); + this.name = "MetadataTooLargeError"; + } +} + +export function handleMetadataPacket(metadata: any, metadataType: string): IOPacket | undefined { + let metadataPacket: IOPacket | undefined = undefined; + + if (typeof metadata === "string") { + metadataPacket = { data: metadata, dataType: metadataType }; + } + + if (metadataType === "application/json") { + metadataPacket = { data: JSON.stringify(metadata), dataType: "application/json" }; + } + + if (!metadataPacket || !metadataPacket.data) { + return; + } + + const byteLength = Buffer.byteLength(metadataPacket.data, "utf8"); + + if (byteLength > env.TASK_RUN_METADATA_MAXIMUM_SIZE) { + throw new MetadataTooLargeError( + `Metadata exceeds maximum size of ${env.TASK_RUN_METADATA_MAXIMUM_SIZE} bytes` + ); + } + + return metadataPacket; +} diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 6f2eca0b56..c4d5282f30 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -9,6 +9,7 @@ import { TaskRunExecutionResult, TaskRunFailedExecutionResult, TaskRunSuccessfulExecutionResult, + parsePacket, serverWebsocketMessages, } from "@trigger.dev/core/v3"; import { ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler"; @@ -1033,6 +1034,11 @@ class SharedQueueTasks { const machinePreset = machinePresetFromConfig(backgroundWorkerTask.machineConfig ?? {}); + const metadata = await parsePacket({ + data: taskRun.metadata ?? undefined, + dataType: taskRun.metadataType, + }); + const execution: ProdTaskRunExecution = { task: { id: backgroundWorkerTask.slug, @@ -1060,6 +1066,7 @@ class SharedQueueTasks { durationMs: taskRun.usageDurationMs, costInCents: taskRun.costInCents, baseCostInCents: taskRun.baseCostInCents, + metadata, }, queue: { id: queue.friendlyId, diff --git a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts index e3f06d3bfe..0d7660e482 100644 --- a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts +++ b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts @@ -1,4 +1,4 @@ -import { TaskRunExecution } from "@trigger.dev/core/v3"; +import { parsePacket, TaskRunExecution } from "@trigger.dev/core/v3"; import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; @@ -157,6 +157,11 @@ export class CreateTaskRunAttemptService extends BaseService { const machinePreset = machinePresetFromConfig(taskRun.lockedBy.machineConfig ?? {}); + const metadata = await parsePacket({ + data: taskRun.metadata ?? undefined, + dataType: taskRun.metadataType, + }); + const execution: TaskRunExecution = { task: { id: taskRun.lockedBy.slug, @@ -186,6 +191,7 @@ export class CreateTaskRunAttemptService extends BaseService { baseCostInCents: taskRun.baseCostInCents, maxAttempts: taskRun.maxAttempts ?? undefined, version: taskRun.lockedBy.worker.version, + metadata, }, queue: { id: queue.friendlyId, diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 177e1279ac..4ac2a23d18 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -20,6 +20,7 @@ import { logger } from "~/services/logger.server"; import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; import { createTag, MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server"; import { findCurrentWorkerFromEnvironment } from "../models/workerDeployment.server"; +import { handleMetadataPacket } from "~/utils/packets"; export type TriggerTaskServiceOptions = { idempotencyKey?: string; @@ -99,6 +100,13 @@ export class TriggerTaskService extends BaseService { environment ); + const metadataPacket = body.options?.metadata + ? handleMetadataPacket( + body.options?.metadata, + body.options?.metadataType ?? "application/json" + ) + : undefined; + const dependentAttempt = body.options?.dependentAttempt ? await this._prisma.taskRunAttempt.findUnique({ where: { friendlyId: body.options.dependentAttempt }, @@ -341,6 +349,8 @@ export class TriggerTaskService extends BaseService { batchId: dependentBatchRun?.id ?? parentBatchRun?.id, resumeParentOnCompletion: !!(dependentAttempt ?? dependentBatchRun), depth, + metadata: metadataPacket?.data, + metadataType: metadataPacket?.dataType, }, }); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 7a91915684..7b117102ae 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -22,6 +22,8 @@ import { TriggerTaskRequestBody, TriggerTaskResponse, UpdateEnvironmentVariableRequestBody, + UpdateMetadataRequestBody, + UpdateMetadataResponseBody, UpdateScheduleOptions, } from "../schemas/index.js"; import { taskContext } from "../task-context-api.js"; @@ -498,6 +500,23 @@ export class ApiClient { ); } + updateRunMetadata( + runId: string, + body: UpdateMetadataRequestBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + UpdateMetadataResponseBody, + `${this.baseUrl}/api/v1/runs/${runId}/metadata`, + { + method: "PUT", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + #getHeaders(spanParentAsLink: boolean) { const headers: Record = { "Content-Type": "application/json", diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index 7fca5499ab..0a75577861 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -5,6 +5,8 @@ import { ApiClientConfiguration } from "./types.js"; const API_NAME = "api-client"; +export class ApiClientMissingError extends Error {} + export class APIClientManagerAPI { private static _instance?: APIClientManagerAPI; @@ -44,7 +46,29 @@ export class APIClientManagerAPI { return new ApiClient(this.baseURL, this.accessToken); } + clientOrThrow(): ApiClient { + if (!this.baseURL || !this.accessToken) { + throw new ApiClientMissingError(this.apiClientMissingError()); + } + + return new ApiClient(this.baseURL, this.accessToken); + } + #getConfig(): ApiClientConfiguration | undefined { return getGlobal(API_NAME); } + + apiClientMissingError() { + const hasBaseUrl = !!this.baseURL; + const hasAccessToken = !!this.accessToken; + if (!hasBaseUrl && !hasAccessToken) { + return `You need to set the TRIGGER_API_URL and TRIGGER_SECRET_KEY environment variables.`; + } else if (!hasBaseUrl) { + return `You need to set the TRIGGER_API_URL environment variable.`; + } else if (!hasAccessToken) { + return `You need to set the TRIGGER_SECRET_KEY environment variable.`; + } + + return `Unknown error`; + } } diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 1dbb4b47c1..61d7f3bf34 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -11,6 +11,7 @@ export * from "./runtime-api.js"; export * from "./task-context-api.js"; export * from "./apiClientManager-api.js"; export * from "./usage-api.js"; +export * from "./run-metadata-api.js"; export * from "./schemas/index.js"; export { SemanticInternalAttributes } from "./semanticInternalAttributes.js"; export * from "./task-catalog-api.js"; diff --git a/packages/core/src/v3/run-metadata-api.ts b/packages/core/src/v3/run-metadata-api.ts new file mode 100644 index 0000000000..12bc83ca39 --- /dev/null +++ b/packages/core/src/v3/run-metadata-api.ts @@ -0,0 +1,5 @@ +// Split module-level variable definition into separate files to allow +// tree-shaking on each api instance. +import { RunMetadataAPI } from "./runMetadata/index.js"; + +export const runMetadata = RunMetadataAPI.getInstance(); diff --git a/packages/core/src/v3/runMetadata/index.ts b/packages/core/src/v3/runMetadata/index.ts new file mode 100644 index 0000000000..85044bd9fa --- /dev/null +++ b/packages/core/src/v3/runMetadata/index.ts @@ -0,0 +1,101 @@ +import { DeserializedJson } from "../../schemas/json.js"; +import { apiClientManager } from "../apiClientManager-api.js"; +import { taskContext } from "../task-context-api.js"; +import { ApiRequestOptions } from "../zodfetch.js"; + +export class RunMetadataAPI { + private static _instance?: RunMetadataAPI; + private store: Record | undefined; + + private constructor() {} + + public static getInstance(): RunMetadataAPI { + if (!this._instance) { + this._instance = new RunMetadataAPI(); + } + + return this._instance; + } + + public enterWithMetadata(metadata: Record): void { + this.store = metadata; + } + + public current(): Record | undefined { + return this.store; + } + + public async setKey( + key: string, + value: DeserializedJson, + requestOptions?: ApiRequestOptions + ): Promise> { + const runId = taskContext.ctx?.run.id; + + if (!runId) { + throw new Error("Cannot set metadata outside of a task run"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + const nextStore = { + ...(this.store ?? {}), + [key]: value, + }; + + const response = await apiClient.updateRunMetadata( + runId, + { metadata: nextStore }, + requestOptions + ); + + this.store = response.metadata; + + return this.store; + } + + public async deleteKey( + key: string, + requestOptions?: ApiRequestOptions + ): Promise> { + const runId = taskContext.ctx?.run.id; + + if (!runId) { + throw new Error("Cannot delete metadata outside of a task run"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + const nextStore = { ...(this.store ?? {}) }; + delete nextStore[key]; + + const response = await apiClient.updateRunMetadata( + runId, + { metadata: nextStore }, + requestOptions + ); + + this.store = response.metadata; + + return this.store; + } + + public async update( + metadata: Record, + requestOptions?: ApiRequestOptions + ): Promise> { + const runId = taskContext.ctx?.run.id; + + if (!runId) { + throw new Error("Cannot update metadata outside of a task run"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + const response = await apiClient.updateRunMetadata(runId, { metadata }, requestOptions); + + this.store = response.metadata; + + return this.store; + } +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index d29aa1396b..37fa187551 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { BackgroundWorkerMetadata } from "./resources.js"; import { QueueOptions } from "./schemas.js"; import { SerializedError } from "./common.js"; +import { DeserializedJsonSchema, SerializableJsonSchema } from "../../schemas/json.js"; export const WhoAmIResponseSchema = z.object({ userId: z.string(), @@ -81,6 +82,8 @@ export const TriggerTaskRequestBody = z.object({ ttl: z.string().or(z.number().nonnegative().int()).optional(), tags: RunTags.optional(), maxAttempts: z.number().int().optional(), + metadata: z.any(), + metadataType: z.string().optional(), }) .optional(), }); @@ -505,6 +508,7 @@ const CommonRunFields = { costInCents: z.number(), baseCostInCents: z.number(), durationMs: z.number(), + metadata: z.record(z.any()).optional(), }; const RetrieveRunCommandFields = { @@ -608,3 +612,16 @@ export const EnvironmentVariable = z.object({ export const EnvironmentVariables = z.array(EnvironmentVariable); export type EnvironmentVariables = z.infer; + +export const UpdateMetadataRequestBody = z.object({ + metadata: z.record(DeserializedJsonSchema), + metadataType: z.string().optional(), +}); + +export type UpdateMetadataRequestBody = z.infer; + +export const UpdateMetadataResponseBody = z.object({ + metadata: z.record(DeserializedJsonSchema), +}); + +export type UpdateMetadataResponseBody = z.infer; diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 575d7f506b..f65fcd2680 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { DeserializedJsonSchema } from "../../schemas/json.js"; // Defaults to 0.5 export const MachineCpu = z.union([ @@ -142,6 +143,7 @@ export const TaskRun = z.object({ costInCents: z.number().default(0), baseCostInCents: z.number().default(0), version: z.string().optional(), + metadata: z.record(DeserializedJsonSchema).optional(), }); export type TaskRun = z.infer; diff --git a/packages/core/src/v3/workers/taskExecutor.ts b/packages/core/src/v3/workers/taskExecutor.ts index 1ab8689bb8..b1740af48d 100644 --- a/packages/core/src/v3/workers/taskExecutor.ts +++ b/packages/core/src/v3/workers/taskExecutor.ts @@ -3,7 +3,7 @@ import { VERSION } from "../../version.js"; import { ApiError, RateLimitError } from "../apiClient/errors.js"; import { ConsoleInterceptor } from "../consoleInterceptor.js"; import { parseError, sanitizeError } from "../errors.js"; -import { TriggerConfig } from "../index.js"; +import { runMetadata, TriggerConfig } from "../index.js"; import { recordSpanException, TracingSDK } from "../otel/index.js"; import { ServerBackgroundWorker, @@ -26,7 +26,6 @@ import { stringifyIO, } from "../utils/ioSerialization.js"; import { calculateNextRetryDelay } from "../utils/retries.js"; -import { accessoryAttributes } from "../utils/styleAttributes.js"; export type TaskExecutorOptions = { tracingSDK: TracingSDK; @@ -73,6 +72,10 @@ export class TaskExecutor { worker, }); + if (ctx.run.metadata) { + runMetadata.enterWithMetadata(ctx.run.metadata); + } + this._tracingSDK.asyncResourceDetector.resolveWithAttributes({ ...taskContext.attributes, [SemanticInternalAttributes.SDK_VERSION]: VERSION, diff --git a/packages/database/prisma/migrations/20240925092304_add_metadata_and_output_to_task_run/migration.sql b/packages/database/prisma/migrations/20240925092304_add_metadata_and_output_to_task_run/migration.sql new file mode 100644 index 0000000000..24df8ec3cc --- /dev/null +++ b/packages/database/prisma/migrations/20240925092304_add_metadata_and_output_to_task_run/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "metadata" TEXT, +ADD COLUMN "metadataType" TEXT NOT NULL DEFAULT 'application/json', +ADD COLUMN "output" TEXT, +ADD COLUMN "outputType" TEXT NOT NULL DEFAULT 'application/json'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 9bfafe3ee3..2e0b3d50d2 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1751,6 +1751,14 @@ model TaskRun { /// The span ID of the "trigger" span in the parent task run parentSpanId String? + /// Run metadata + metadata String? + metadataType String @default("application/json") + + /// Run output + output String? + outputType String @default("application/json") + @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) // Finding child runs @@index([parentTaskRunId]) diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 597961e31a..00436d1189 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -7,6 +7,7 @@ export * from "./wait.js"; export * from "./usage.js"; export * from "./idempotencyKeys.js"; export * from "./tags.js"; +export * from "./metadata.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/metadata.ts b/packages/trigger-sdk/src/v3/metadata.ts new file mode 100644 index 0000000000..5482c11c49 --- /dev/null +++ b/packages/trigger-sdk/src/v3/metadata.ts @@ -0,0 +1,102 @@ +import { DeserializedJson } from "@trigger.dev/core"; +import { + accessoryAttributes, + ApiRequestOptions, + mergeRequestOptions, + runMetadata, +} from "@trigger.dev/core/v3"; +import { tracer } from "./tracer.js"; + +export const metadata = { + current: currentMetadata, + set: setMetadataKey, + del: deleteMetadataKey, + update: updateMetadata, +}; + +export type RunMetadata = Record; + +/** + * Returns the metadata of the current run if inside a task run. + */ +function currentMetadata(): RunMetadata | undefined { + return runMetadata.current(); +} + +/** + * Set a key in the metadata of the current run if inside a task run. + * + * @returns The updated metadata. + */ +async function setMetadataKey( + key: string, + value: DeserializedJson, + requestOptions?: ApiRequestOptions +): Promise { + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "metadata.set()", + icon: "code-plus", + attributes: { + ...accessoryAttributes({ + items: [ + { + text: key, + variant: "normal", + }, + ], + style: "codepath", + }), + key, + }, + }, + requestOptions + ); + + return await runMetadata.setKey(key, value, $requestOptions); +} + +async function deleteMetadataKey( + key: string, + requestOptions?: ApiRequestOptions +): Promise { + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "metadata.del()", + icon: "code-minus", + attributes: { + ...accessoryAttributes({ + items: [ + { + text: key, + variant: "normal", + }, + ], + style: "codepath", + }), + key, + }, + }, + requestOptions + ); + + return await runMetadata.deleteKey(key, $requestOptions); +} + +async function updateMetadata( + metadata: RunMetadata, + requestOptions?: ApiRequestOptions +): Promise { + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "metadata.update()", + icon: "code-plus", + }, + requestOptions + ); + + return await runMetadata.update(metadata, $requestOptions); +} diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index d97cd7a1b0..719521404e 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -39,6 +39,7 @@ import { import { IdempotencyKey, idempotencyKeys, isIdempotencyKey } from "./idempotencyKeys.js"; import { PollOptions, RetrieveRunResult, runs } from "./runs.js"; import { tracer } from "./tracer.js"; +import { SerializableJson } from "@trigger.dev/core"; export type Context = TaskRunContext; @@ -496,6 +497,11 @@ export type TaskRunOptions = { * ``` */ tags?: RunTags; + + /** + * Metadata to attach to the run. Metadata can be used to store additional information about the run. Limited to 8KB. + */ + metadata?: Record; }; type TaskRunConcurrencyOptions = Queue; @@ -792,6 +798,7 @@ async function trigger_internal( tags: options?.tags, maxAttempts: options?.maxAttempts, parentAttempt: taskContext.ctx?.attempt.id, + metadata: options?.metadata, }, }, { @@ -863,6 +870,7 @@ async function batchTrigger_internal( tags: item.options?.tags, maxAttempts: item.options?.maxAttempts, parentAttempt: taskContext.ctx?.attempt.id, + metadata: item.options?.metadata, }, }; }) @@ -939,6 +947,7 @@ async function triggerAndWait_internal( ttl: options?.ttl, tags: options?.tags, maxAttempts: options?.maxAttempts, + metadata: options?.metadata, }, }, {}, @@ -1035,6 +1044,7 @@ async function batchTriggerAndWait_internal( ttl: item.options?.ttl, tags: item.options?.tags, maxAttempts: item.options?.maxAttempts, + metadata: item.options?.metadata, }, }; }) diff --git a/references/v3-catalog/src/trigger/runMetadata.ts b/references/v3-catalog/src/trigger/runMetadata.ts new file mode 100644 index 0000000000..6f3ba0ef37 --- /dev/null +++ b/references/v3-catalog/src/trigger/runMetadata.ts @@ -0,0 +1,49 @@ +import { logger, task, metadata } from "@trigger.dev/sdk/v3"; + +export const runMetadataTask = task({ + id: "run-metadata-task", + run: async (payload: any) => { + await runMetadataChildTask.triggerAndWait(payload, { + metadata: { + hello: "world", + date: new Date(), + anotherThing: { + a: 1, + b: 2, + }, + }, + }); + }, +}); + +export const runMetadataChildTask = task({ + id: "run-metadata-child-task", + run: async (payload: any, { ctx }) => { + logger.info("metadata", { metadata: ctx.run.metadata }); + + await metadata.set("child", "task"); + + logger.info("metadata", { metadata: metadata.current() }); + + const returnedMetadata = await metadata.set("child-2", "task-2"); + + logger.info("metadata", { metadata: returnedMetadata, current: metadata.current() }); + + await metadata.del("hello"); + + logger.info("metadata", { metadata: metadata.current() }); + + await metadata.update({ + there: { + is: { + something: "here", + }, + }, + }); + + // Now try and update the metadata with something larger than 8KB + await metadata.update({ + large: new Array(10000).fill("a").join(""), + }); + }, +}); From a3b2f0001d6949e4f534abeaf38221cc26839e17 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 25 Sep 2024 14:33:48 +0100 Subject: [PATCH 02/10] =?UTF-8?q?Remove=20metadata=20from=20context,=20mov?= =?UTF-8?q?e=20it=20to=20it=E2=80=99s=20own=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/presenters/v3/SpanPresenter.server.ts | 9 ++-- .../route.tsx | 21 ++++++++ .../app/routes/resources.runs.$runParam.ts | 13 +---- docs/runs/metadata.mdx | 48 +++++++++++++++++++ docs/v3-openapi.yaml | 4 ++ packages/core/src/v3/runMetadata/index.ts | 4 ++ packages/core/src/v3/schemas/common.ts | 2 +- packages/core/src/v3/workers/taskExecutor.ts | 4 +- packages/trigger-sdk/src/v3/metadata.ts | 14 +++++- .../v3-catalog/src/trigger/runMetadata.ts | 12 +++-- 10 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 docs/runs/metadata.mdx diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 011eed0c1a..9b80f96d03 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -192,10 +192,9 @@ export class SpanPresenter extends BasePresenter { const span = await eventRepository.getSpan(spanId, run.traceId); - const metadata = await parsePacket({ - data: run.metadata ?? undefined, - dataType: run.metadataType, - }); + const metadata = run.metadata + ? await prettyPrintPacket(run.metadata, run.metadataType) + : undefined; const context = { task: { @@ -215,7 +214,6 @@ export class SpanPresenter extends BasePresenter { baseCostInCents: run.baseCostInCents, maxAttempts: run.maxAttempts ?? undefined, version: run.lockedToVersion?.version, - metadata, }, queue: { name: run.queue, @@ -285,6 +283,7 @@ export class SpanPresenter extends BasePresenter { error, links: span?.links, context: JSON.stringify(context, null, 2), + metadata, }; } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx index 5c2e51fc15..552538f553 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx @@ -421,6 +421,17 @@ function RunBody({ > Context + + { + replace({ tab: "metadata" }); + }} + shortcut={{ key: "m" }} + > + Metadata +
@@ -607,6 +618,16 @@ function RunBody({
+ ) : tab === "metadata" ? ( +
+ {run.metadata ? ( + + ) : ( + + No metadata set for this run. View our metadata documentation to learn more. + + )} +
) : (
diff --git a/apps/webapp/app/routes/resources.runs.$runParam.ts b/apps/webapp/app/routes/resources.runs.$runParam.ts index 0cb942f3cb..fc4f3a9b0f 100644 --- a/apps/webapp/app/routes/resources.runs.$runParam.ts +++ b/apps/webapp/app/routes/resources.runs.$runParam.ts @@ -1,10 +1,5 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { - MachinePresetName, - parsePacket, - prettyPrintPacket, - TaskRunError, -} from "@trigger.dev/core/v3"; +import { MachinePresetName, prettyPrintPacket, TaskRunError } from "@trigger.dev/core/v3"; import { typedjson, UseDataFunctionReturn } from "remix-typedjson"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { $replica } from "~/db.server"; @@ -158,11 +153,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } } - const metadata = await parsePacket({ - data: run.metadata ?? undefined, - dataType: run.metadataType, - }); - const context = { task: { id: run.taskIdentifier, @@ -181,7 +171,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { baseCostInCents: run.baseCostInCents, maxAttempts: run.maxAttempts ?? undefined, version: run.lockedToVersion?.version, - metadata, }, queue: { name: run.queue, diff --git a/docs/runs/metadata.mdx b/docs/runs/metadata.mdx new file mode 100644 index 0000000000..fd6a6dab77 --- /dev/null +++ b/docs/runs/metadata.mdx @@ -0,0 +1,48 @@ +--- +title: "Run metadata" +description: "Attach a small amount of data to a run and update it as the run progresses." +--- + +You can attach up to 8KB of metadata to a run, which you can then access from inside the run function, via the API, and in the dashboard. You can use metadata to store additional, structured information on an run. For example, you could store your user’s full name and corresponding unique identifier from your system on every task that is associated with that user: + +```ts +const handle = await myTask.trigger( + { message: "hello world" }, + { metadata: { user: { name: "Eric", id: "user_1234" } } } +); +``` + +Then inside your run function, you can access the metadata like this: + +```ts +import { task, metadata } from "@trigger.dev/sdk/v3"; + +export const myTask = task({ + id: "my-task", + run: async (payload: { message: string }, { ctx }) => { + const user = metadata.get("user"); + console.log(user.name); // "Eric" + console.log(user.id); // "user_1234" + }, +}); +``` + +You can also update the metadata during the run: + +```ts +import { task, metadata } from "@trigger.dev/sdk/v3"; + +export const myTask = task({ + id: "my-task", + run: async (payload: { message: string }, { ctx }) => { + // Do some work + await metadata.set("progress", 0.1); + + // Do some more work + await metadata.set("progress", 0.5); + + // Do even more work + await metadata.set("progress", 1.0); + }, +}); +``` diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 2e141bb2d6..d914eef785 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -1897,6 +1897,10 @@ components: items: type: string description: A tag must be between 1 and 64 characters, a run can have up to 5 tags attached to it. + metadata: + type: object + description: The metadata of the run. See [Metadata](/runs/metadata) for more information. + example: { "foo": "bar" } costInCents: type: number example: 0.00292 diff --git a/packages/core/src/v3/runMetadata/index.ts b/packages/core/src/v3/runMetadata/index.ts index 85044bd9fa..d659576765 100644 --- a/packages/core/src/v3/runMetadata/index.ts +++ b/packages/core/src/v3/runMetadata/index.ts @@ -25,6 +25,10 @@ export class RunMetadataAPI { return this.store; } + public getKey(key: string): DeserializedJson | undefined { + return this.store?.[key]; + } + public async setKey( key: string, value: DeserializedJson, diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index f65fcd2680..e99b7dbc9e 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -223,7 +223,7 @@ export const TaskRunContext = z.object({ backgroundWorkerId: true, backgroundWorkerTaskId: true, }), - run: TaskRun.omit({ payload: true, payloadType: true }), + run: TaskRun.omit({ payload: true, payloadType: true, metadata: true }), queue: TaskRunExecutionQueue, environment: TaskRunExecutionEnvironment, organization: TaskRunExecutionOrganization, diff --git a/packages/core/src/v3/workers/taskExecutor.ts b/packages/core/src/v3/workers/taskExecutor.ts index b1740af48d..0a1ef93606 100644 --- a/packages/core/src/v3/workers/taskExecutor.ts +++ b/packages/core/src/v3/workers/taskExecutor.ts @@ -72,8 +72,8 @@ export class TaskExecutor { worker, }); - if (ctx.run.metadata) { - runMetadata.enterWithMetadata(ctx.run.metadata); + if (execution.run.metadata) { + runMetadata.enterWithMetadata(execution.run.metadata); } this._tracingSDK.asyncResourceDetector.resolveWithAttributes({ diff --git a/packages/trigger-sdk/src/v3/metadata.ts b/packages/trigger-sdk/src/v3/metadata.ts index 5482c11c49..1822fb29aa 100644 --- a/packages/trigger-sdk/src/v3/metadata.ts +++ b/packages/trigger-sdk/src/v3/metadata.ts @@ -2,6 +2,7 @@ import { DeserializedJson } from "@trigger.dev/core"; import { accessoryAttributes, ApiRequestOptions, + flattenAttributes, mergeRequestOptions, runMetadata, } from "@trigger.dev/core/v3"; @@ -9,6 +10,7 @@ import { tracer } from "./tracer.js"; export const metadata = { current: currentMetadata, + get: getMetadataKey, set: setMetadataKey, del: deleteMetadataKey, update: updateMetadata, @@ -23,6 +25,13 @@ function currentMetadata(): RunMetadata | undefined { return runMetadata.current(); } +/** + * Get a key from the metadata of the current run if inside a task run. + */ +function getMetadataKey(key: string): DeserializedJson | undefined { + return runMetadata.getKey(key); +} + /** * Set a key in the metadata of the current run if inside a task run. * @@ -48,7 +57,7 @@ async function setMetadataKey( ], style: "codepath", }), - key, + ...flattenAttributes(value, key), }, }, requestOptions @@ -94,6 +103,9 @@ async function updateMetadata( tracer, name: "metadata.update()", icon: "code-plus", + attributes: { + ...flattenAttributes(metadata), + }, }, requestOptions ); diff --git a/references/v3-catalog/src/trigger/runMetadata.ts b/references/v3-catalog/src/trigger/runMetadata.ts index 6f3ba0ef37..c7028ad4ff 100644 --- a/references/v3-catalog/src/trigger/runMetadata.ts +++ b/references/v3-catalog/src/trigger/runMetadata.ts @@ -19,8 +19,6 @@ export const runMetadataTask = task({ export const runMetadataChildTask = task({ id: "run-metadata-child-task", run: async (payload: any, { ctx }) => { - logger.info("metadata", { metadata: ctx.run.metadata }); - await metadata.set("child", "task"); logger.info("metadata", { metadata: metadata.current() }); @@ -41,9 +39,13 @@ export const runMetadataChildTask = task({ }, }); - // Now try and update the metadata with something larger than 8KB - await metadata.update({ - large: new Array(10000).fill("a").join(""), + await runMetadataChildTask2.triggerAndWait(payload, { + metadata: metadata.current(), }); }, }); + +export const runMetadataChildTask2 = task({ + id: "run-metadata-child-task-2", + run: async (payload: any, { ctx }) => {}, +}); From 590c52c303f00f6bba52a9ecc9cdc79423d0882d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 25 Sep 2024 17:01:09 +0100 Subject: [PATCH 03/10] More run metadata stuff - Add metadata to testing - Make using metadata outside of runs a no-op - Add docs --- .changeset/tasty-rats-rhyme.md | 2 +- .../webapp/app/components/code/JSONEditor.tsx | 4 +- .../webapp/app/components/primitives/Tabs.tsx | 1 + apps/webapp/app/env.server.ts | 2 +- .../presenters/v3/TestTaskPresenter.server.ts | 16 +- .../route.tsx | 128 +++++++--- .../route.tsx | 7 +- .../webapp/app/v3/services/testTask.server.ts | 1 + apps/webapp/app/v3/testTask.ts | 20 ++ docs/images/run-metadata.png | Bin 0 -> 681474 bytes docs/runs/metadata.mdx | 231 +++++++++++++++++- docs/tasks/overview.mdx | 21 +- docs/triggering.mdx | 9 + packages/core/src/v3/runMetadata/index.ts | 21 +- packages/trigger-sdk/src/v3/metadata.ts | 72 +++++- packages/trigger-sdk/src/v3/shared.ts | 2 +- .../v3-catalog/src/trigger/runMetadata.ts | 9 +- 17 files changed, 462 insertions(+), 84 deletions(-) create mode 100644 docs/images/run-metadata.png diff --git a/.changeset/tasty-rats-rhyme.md b/.changeset/tasty-rats-rhyme.md index 47a6382f22..43950a9e21 100644 --- a/.changeset/tasty-rats-rhyme.md +++ b/.changeset/tasty-rats-rhyme.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -Add Run metadata to allow for storing up to 8KB of data on a run and update it during the run +Add Run metadata to allow for storing up to 4KB of data on a run and update it during the run diff --git a/apps/webapp/app/components/code/JSONEditor.tsx b/apps/webapp/app/components/code/JSONEditor.tsx index da09ad1cea..0313120b14 100644 --- a/apps/webapp/app/components/code/JSONEditor.tsx +++ b/apps/webapp/app/components/code/JSONEditor.tsx @@ -112,9 +112,9 @@ export function JSONEditor(opts: JSONEditorProps) { return (
{showButtons && ( diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index a20aca45c0..1829f163aa 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -97,6 +97,7 @@ export function TabButton({ return (