From a2c70b450ae35e337474cd927e82418ce05166c3 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 13 Feb 2025 11:02:01 +0000 Subject: [PATCH 1/5] Upgrade local dev to use electric beta.15 (#1699) --- apps/webapp/test/realtimeClient.test.ts | 18 +++++++++++++++--- docker/docker-compose.yml | 2 +- internal-packages/testcontainers/src/utils.ts | 2 +- references/nextjs-realtime/src/app/actions.ts | 13 +------------ 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/webapp/test/realtimeClient.test.ts b/apps/webapp/test/realtimeClient.test.ts index bcd4dffb8..f8aab54fd 100644 --- a/apps/webapp/test/realtimeClient.test.ts +++ b/apps/webapp/test/realtimeClient.test.ts @@ -10,7 +10,11 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("RealtimeClient", () => { const client = new RealtimeClient({ electricOrigin, keyPrefix: "test:realtime", - redis: redis.options, + redis: { + host: redis.options.host, + port: redis.options.port, + tlsDisabled: true, + }, expiryTimeInSeconds: 5, cachedLimitProvider: { async getCachedLimit() { @@ -146,7 +150,11 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("RealtimeClient", () => { const client = new RealtimeClient({ electricOrigin, keyPrefix: "test:realtime", - redis: redis.options, + redis: { + host: redis.options.host, + port: redis.options.port, + tlsDisabled: true, + }, expiryTimeInSeconds: 5, cachedLimitProvider: { async getCachedLimit() { @@ -225,7 +233,11 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("RealtimeClient", () => { const client = new RealtimeClient({ electricOrigin, keyPrefix: "test:realtime", - redis: redis.options, + redis: { + host: redis.options.host, + port: redis.options.port, + tlsDisabled: true, + }, expiryTimeInSeconds: 5, cachedLimitProvider: { async getCachedLimit() { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 003f0db71..8cc7f4565 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -140,7 +140,7 @@ services: # - "REDIS_CLUSTER_CREATOR=yes" electric: - image: electricsql/electric:1.0.0-beta.1@sha256:2262f6f09caf5fa45f233731af97b84999128170a9529e5f9b9b53642308493f + image: electricsql/electric:1.0.0-beta.15@sha256:4ae0f895753b82684aa31ea1c708e9e86d0a9bca355acb7270dcb24062520810 restart: always environment: DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres?sslmode=disable diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index 140628630..e88ebd434 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -56,7 +56,7 @@ export async function createElectricContainer( )}:5432/${postgresContainer.getDatabase()}?sslmode=disable`; const container = await new GenericContainer( - "electricsql/electric:1.0.0-beta.1@sha256:2262f6f09caf5fa45f233731af97b84999128170a9529e5f9b9b53642308493f" + "electricsql/electric:1.0.0-beta.15@sha256:4ae0f895753b82684aa31ea1c708e9e86d0a9bca355acb7270dcb24062520810" ) .withExposedPorts(3000) .withNetwork(network) diff --git a/references/nextjs-realtime/src/app/actions.ts b/references/nextjs-realtime/src/app/actions.ts index d129fdf6e..8321d81bb 100644 --- a/references/nextjs-realtime/src/app/actions.ts +++ b/references/nextjs-realtime/src/app/actions.ts @@ -11,19 +11,8 @@ export async function triggerExampleTask() { id: randomUUID(), }); - const publicToken = await auth.createPublicToken({ - scopes: { - read: { - runs: [handle.id], - }, - }, - expirationTime: "2s", - }); - - console.log("Setting the run JWT in a cookie", publicToken); - // Set JWT in a secure, HTTP-only cookie - cookies().set("run_token", publicToken); + cookies().set("run_token", handle.publicAccessToken); // Redirect to the details page redirect(`/runs/${handle.id}`); From 440d413ce8424f45ec237d53871419b4d9cdebb5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 13 Feb 2025 13:44:36 +0000 Subject: [PATCH 2/5] Alert Webhook improvements (#1703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP with webhook SDK function and types * JSDocs added to the schema * Webhooks are working * Expanded the alert docs * Remove duplicate export of waitUntil.js * Use uncrypto * Don’t rate limit webhooks * Create slow-olives-fix.md --- .changeset/slow-olives-fix.md | 16 + apps/webapp/app/models/projectAlert.server.ts | 1 + .../app/routes/internal.webhooks.tester.ts | 50 ++ .../alerts/createAlertChannel.server.ts | 1 + .../v3/services/alerts/deliverAlert.server.ts | 583 ++++++++---------- .../alerts/performDeploymentAlerts.server.ts | 1 + .../alerts/performTaskRunAlerts.server.ts | 1 + .../app/v3/services/completeAttempt.server.ts | 2 +- docs/troubleshooting-alerts.mdx | 67 ++ packages/core/src/v3/schemas/index.ts | 1 + packages/core/src/v3/schemas/webhooks.ts | 205 ++++++ packages/trigger-sdk/package.json | 1 + .../trigger-sdk/src/imports/uncrypto-cjs.cts | 5 + packages/trigger-sdk/src/imports/uncrypto.ts | 5 + packages/trigger-sdk/src/v3/index.ts | 2 +- packages/trigger-sdk/src/v3/webhooks.ts | 171 +++++ pnpm-lock.yaml | 239 ++++++- 17 files changed, 1023 insertions(+), 328 deletions(-) create mode 100644 .changeset/slow-olives-fix.md create mode 100644 apps/webapp/app/routes/internal.webhooks.tester.ts create mode 100644 packages/core/src/v3/schemas/webhooks.ts create mode 100644 packages/trigger-sdk/src/imports/uncrypto-cjs.cts create mode 100644 packages/trigger-sdk/src/imports/uncrypto.ts create mode 100644 packages/trigger-sdk/src/v3/webhooks.ts diff --git a/.changeset/slow-olives-fix.md b/.changeset/slow-olives-fix.md new file mode 100644 index 000000000..7193075c8 --- /dev/null +++ b/.changeset/slow-olives-fix.md @@ -0,0 +1,16 @@ +--- +"@trigger.dev/sdk": patch +--- + +You can add Alerts in the dashboard. One of these is a webhook, which this change greatly improves. + +The main change is that there's now an SDK function to verify and parse them (similar to Stripe SDK). + +```ts +const event = await webhooks.constructEvent(request, process.env.ALERT_WEBHOOK_SECRET!); +``` + +If the signature you provide matches the one from the dashboard when you create the webhook, you will get a nicely typed object back for these three types: +- "alert.run.failed" +- "alert.deployment.success" +- "alert.deployment.failed" diff --git a/apps/webapp/app/models/projectAlert.server.ts b/apps/webapp/app/models/projectAlert.server.ts index cd3af4004..d2ab0be1d 100644 --- a/apps/webapp/app/models/projectAlert.server.ts +++ b/apps/webapp/app/models/projectAlert.server.ts @@ -4,6 +4,7 @@ import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.serve export const ProjectAlertWebhookProperties = z.object({ secret: EncryptedSecretValueSchema, url: z.string(), + version: z.string().optional().default("v1"), }); export type ProjectAlertWebhookProperties = z.infer; diff --git a/apps/webapp/app/routes/internal.webhooks.tester.ts b/apps/webapp/app/routes/internal.webhooks.tester.ts new file mode 100644 index 000000000..f49d12e60 --- /dev/null +++ b/apps/webapp/app/routes/internal.webhooks.tester.ts @@ -0,0 +1,50 @@ +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { webhooks } from "@trigger.dev/sdk/v3"; +import { WebhookError } from "@trigger.dev/sdk/v3"; +import { logger } from "~/services/logger.server"; + +/* + This route is for testing our webhooks +*/ +export async function action({ request }: ActionFunctionArgs) { + // Make sure this is a POST request + if (request.method !== "POST") { + return json({ error: "[Webhook Internal Test] Method not allowed" }, { status: 405 }); + } + + const clonedRequest = request.clone(); + const rawBody = await clonedRequest.text(); + logger.log("[Webhook Internal Test] Raw body:", { rawBody }); + + try { + // Construct and verify the webhook event + const event = await webhooks.constructEvent(request, process.env.INTERNAL_TEST_WEBHOOK_SECRET!); + + // Handle the webhook event + logger.log("[Webhook Internal Test] Received verified webhook:", event); + + // Process the event based on its type + switch (event.type) { + default: + logger.log(`[Webhook Internal Test] Unhandled event type: ${event.type}`); + } + + // Return a success response + return json({ received: true }, { status: 200 }); + } catch (err) { + // Handle webhook errors + if (err instanceof WebhookError) { + logger.error("[Webhook Internal Test] Webhook error:", { message: err.message }); + return json({ error: err.message }, { status: 400 }); + } + + if (err instanceof Error) { + logger.error("[Webhook Internal Test] Error processing webhook:", { message: err.message }); + return json({ error: err.message }, { status: 400 }); + } + + // Handle other errors + logger.error("[Webhook Internal Test] Error processing webhook:", { err }); + return json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts index edc42d389..b2bbb4239 100644 --- a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts +++ b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts @@ -100,6 +100,7 @@ export class CreateAlertChannelService extends BaseService { return { url: channel.url, secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()), + version: "v2", }; case "SLACK": return { diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index 51832bcd4..3c8d95cd5 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -6,7 +6,14 @@ import { WebAPIRateLimitedError, WebAPIRequestError, } from "@slack/web-api"; -import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3"; +import { + Webhook, + TaskRunError, + createJsonErrorObject, + RunFailedWebhook, + DeploymentFailedWebhook, + DeploymentSuccessWebhook, +} from "@trigger.dev/core/v3"; import assertNever from "assert-never"; import { subtle } from "crypto"; import { Prisma, prisma, PrismaClientOrTransaction } from "~/db.server"; @@ -29,8 +36,11 @@ import { commonWorker } from "~/v3/commonWorker.server"; import { FINAL_ATTEMPT_STATUSES } from "~/v3/taskStatus"; import { BaseService } from "../baseService.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; -import { ProjectAlertType } from "@trigger.dev/database"; +import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database"; import { alertsRateLimiter } from "~/v3/alertsRateLimiter.server"; +import { v3RunPath } from "~/utils/pathBuilder"; +import { isOOMError } from "../completeAttempt.server"; +import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; type FoundAlert = Prisma.Result< typeof prisma.projectAlert, @@ -193,43 +203,7 @@ export class DeliverAlertService extends BaseService { switch (alert.type) { case "TASK_RUN_ATTEMPT": { - if (alert.taskRunAttempt) { - const parseError = TaskRunError.safeParse(alert.taskRunAttempt.error); - - let taskRunError: TaskRunError; - - if (!parseError.success) { - logger.error("[DeliverAlert] Attempt: Failed to parse task run error", { - issues: parseError.error.issues, - taskAttemptError: alert.taskRunAttempt.error, - }); - - taskRunError = { - type: "STRING_ERROR" as const, - raw: "No error on task", - }; - } else { - taskRunError = parseError.data; - } - - await sendAlertEmail({ - email: "alert-attempt", - to: emailProperties.data.email, - taskIdentifier: alert.taskRunAttempt.taskRun.taskIdentifier, - fileName: alert.taskRunAttempt.backgroundWorkerTask.filePath, - exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName, - version: alert.taskRunAttempt.backgroundWorker.version, - environment: alert.environment.slug, - error: createJsonErrorObject(taskRunError), - attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`, - organization: alert.project.organization.title, - }); - } else { - logger.error("[DeliverAlert] Task run attempt not found", { - alert, - }); - } - + logger.error("[DeliverAlert] Task run attempt alerts are deprecated, not sending anything"); break; } case "TASK_RUN": { @@ -332,102 +306,110 @@ export class DeliverAlertService extends BaseService { switch (alert.type) { case "TASK_RUN_ATTEMPT": { - if (alert.taskRunAttempt) { - const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error); - - if (!taskRunError.success) { - logger.error("[DeliverAlert] Attempt: Failed to parse task run error", { - issues: taskRunError.error.issues, - taskAttemptError: alert.taskRunAttempt.error, - }); - - return; - } - - const error = createJsonErrorObject(taskRunError.data); - - const payload = { - task: { - id: alert.taskRunAttempt.taskRun.taskIdentifier, - filePath: alert.taskRunAttempt.backgroundWorkerTask.filePath, - exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName, - }, - attempt: { - id: alert.taskRunAttempt.friendlyId, - number: alert.taskRunAttempt.number, - startedAt: alert.taskRunAttempt.startedAt, - status: alert.taskRunAttempt.status, - }, - run: { - id: alert.taskRunAttempt.taskRun.friendlyId, - isTest: alert.taskRunAttempt.taskRun.isTest, - createdAt: alert.taskRunAttempt.taskRun.createdAt, - idempotencyKey: alert.taskRunAttempt.taskRun.idempotencyKey, - }, - environment: { - id: alert.environment.id, - type: alert.environment.type, - slug: alert.environment.slug, - }, - organization: { - id: alert.project.organizationId, - slug: alert.project.organization.slug, - name: alert.project.organization.title, - }, - project: { - id: alert.project.id, - ref: alert.project.externalRef, - slug: alert.project.slug, - name: alert.project.name, - }, - error, - }; - - await this.#deliverWebhook(payload, webhookProperties.data); - } else { - logger.error("[DeliverAlert] Task run attempt not found", { - alert, - }); - } - + logger.error("[DeliverAlert] Task run attempt alerts are deprecated, not sending anything"); break; } case "TASK_RUN": { if (alert.taskRun) { const error = this.#getRunError(alert); - const payload = { - task: { - id: alert.taskRun.taskIdentifier, - fileName: alert.taskRun.lockedBy?.filePath ?? "Unknown", - exportName: alert.taskRun.lockedBy?.exportName ?? "Unknown", - }, - run: { - id: alert.taskRun.friendlyId, - isTest: alert.taskRun.isTest, - createdAt: alert.taskRun.createdAt, - idempotencyKey: alert.taskRun.idempotencyKey, - }, - environment: { - id: alert.environment.id, - type: alert.environment.type, - slug: alert.environment.slug, - }, - organization: { - id: alert.project.organizationId, - slug: alert.project.organization.slug, - name: alert.project.organization.title, - }, - project: { - id: alert.project.id, - ref: alert.project.externalRef, - slug: alert.project.slug, - name: alert.project.name, - }, - error, - }; + switch (webhookProperties.data.version) { + case "v1": { + const payload = { + task: { + id: alert.taskRun.taskIdentifier, + fileName: alert.taskRun.lockedBy?.filePath ?? "Unknown", + exportName: alert.taskRun.lockedBy?.exportName ?? "Unknown", + }, + run: { + id: alert.taskRun.friendlyId, + isTest: alert.taskRun.isTest, + createdAt: alert.taskRun.createdAt, + idempotencyKey: alert.taskRun.idempotencyKey, + }, + environment: { + id: alert.environment.id, + type: alert.environment.type, + slug: alert.environment.slug, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + error, + }; - await this.#deliverWebhook(payload, webhookProperties.data); + await this.#deliverWebhook(payload, webhookProperties.data); + break; + } + case "v2": { + const payload: RunFailedWebhook = { + id: alert.id, + created: alert.createdAt, + webhookVersion: "v1", + type: "alert.run.failed", + object: { + task: { + id: alert.taskRun.taskIdentifier, + filePath: alert.taskRun.lockedBy?.filePath ?? "Unknown", + exportName: alert.taskRun.lockedBy?.exportName ?? "Unknown", + version: alert.taskRun.taskVersion ?? "Unknown", + sdkVersion: alert.taskRun.sdkVersion ?? "Unknown", + cliVersion: alert.taskRun.cliVersion ?? "Unknown", + }, + run: { + id: alert.taskRun.friendlyId, + number: alert.taskRun.number, + status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(alert.taskRun.status), + createdAt: alert.taskRun.createdAt, + startedAt: alert.taskRun.startedAt ?? undefined, + completedAt: alert.taskRun.completedAt ?? undefined, + isTest: alert.taskRun.isTest, + idempotencyKey: alert.taskRun.idempotencyKey ?? undefined, + tags: alert.taskRun.runTags, + error, + isOutOfMemoryError: isOOMError(error), + machine: alert.taskRun.machinePreset ?? "Unknown", + dashboardUrl: `${env.APP_ORIGIN}${v3RunPath( + alert.project.organization, + alert.project, + alert.taskRun + )}`, + }, + environment: { + id: alert.environment.id, + type: alert.environment.type, + slug: alert.environment.slug, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + }, + }; + + await this.#deliverWebhook(payload, webhookProperties.data); + + break; + } + default: { + throw new Error(`Unknown webhook version: ${webhookProperties.data.version}`); + } + } } else { logger.error("[DeliverAlert] Task run not found", { alert, @@ -450,34 +432,80 @@ export class DeliverAlertService extends BaseService { return; } - const payload = { - deployment: { - id: alert.workerDeployment.friendlyId, - status: alert.workerDeployment.status, - version: alert.workerDeployment.version, - shortCode: alert.workerDeployment.shortCode, - failedAt: alert.workerDeployment.failedAt ?? new Date(), - }, - environment: { - id: alert.environment.id, - type: alert.environment.type, - slug: alert.environment.slug, - }, - organization: { - id: alert.project.organizationId, - slug: alert.project.organization.slug, - name: alert.project.organization.title, - }, - project: { - id: alert.project.id, - ref: alert.project.externalRef, - slug: alert.project.slug, - name: alert.project.name, - }, - error: preparedError, - }; + switch (webhookProperties.data.version) { + case "v1": { + const payload = { + deployment: { + id: alert.workerDeployment.friendlyId, + status: alert.workerDeployment.status, + version: alert.workerDeployment.version, + shortCode: alert.workerDeployment.shortCode, + failedAt: alert.workerDeployment.failedAt ?? new Date(), + }, + environment: { + id: alert.environment.id, + type: alert.environment.type, + slug: alert.environment.slug, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + error: preparedError, + }; - await this.#deliverWebhook(payload, webhookProperties.data); + await this.#deliverWebhook(payload, webhookProperties.data); + break; + } + case "v2": { + const payload: DeploymentFailedWebhook = { + id: alert.id, + created: alert.createdAt, + webhookVersion: "v1", + type: "alert.deployment.failed", + object: { + deployment: { + id: alert.workerDeployment.friendlyId, + status: alert.workerDeployment.status, + version: alert.workerDeployment.version, + shortCode: alert.workerDeployment.shortCode, + failedAt: alert.workerDeployment.failedAt ?? new Date(), + }, + environment: { + id: alert.environment.id, + type: alert.environment.type, + slug: alert.environment.slug, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + error: preparedError, + }, + }; + + await this.#deliverWebhook(payload, webhookProperties.data); + + break; + } + default: { + throw new Error(`Unknown webhook version: ${webhookProperties.data.version}`); + } + } } else { logger.error("[DeliverAlert] Worker deployment not found", { alert, @@ -488,40 +516,92 @@ export class DeliverAlertService extends BaseService { } case "DEPLOYMENT_SUCCESS": { if (alert.workerDeployment) { - const payload = { - deployment: { - id: alert.workerDeployment.friendlyId, - status: alert.workerDeployment.status, - version: alert.workerDeployment.version, - shortCode: alert.workerDeployment.shortCode, - deployedAt: alert.workerDeployment.deployedAt ?? new Date(), - }, - tasks: - alert.workerDeployment.worker?.tasks.map((task) => ({ - id: task.slug, - filePath: task.filePath, - exportName: task.exportName, - triggerSource: task.triggerSource, - })) ?? [], - environment: { - id: alert.environment.id, - type: alert.environment.type, - slug: alert.environment.slug, - }, - organization: { - id: alert.project.organizationId, - slug: alert.project.organization.slug, - name: alert.project.organization.title, - }, - project: { - id: alert.project.id, - ref: alert.project.externalRef, - slug: alert.project.slug, - name: alert.project.name, - }, - }; + switch (webhookProperties.data.version) { + case "v1": { + const payload = { + deployment: { + id: alert.workerDeployment.friendlyId, + status: alert.workerDeployment.status, + version: alert.workerDeployment.version, + shortCode: alert.workerDeployment.shortCode, + deployedAt: alert.workerDeployment.deployedAt ?? new Date(), + }, + tasks: + alert.workerDeployment.worker?.tasks.map((task) => ({ + id: task.slug, + filePath: task.filePath, + exportName: task.exportName, + triggerSource: task.triggerSource, + })) ?? [], + environment: { + id: alert.environment.id, + type: alert.environment.type, + slug: alert.environment.slug, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + }; - await this.#deliverWebhook(payload, webhookProperties.data); + await this.#deliverWebhook(payload, webhookProperties.data); + break; + } + case "v2": { + const payload: DeploymentSuccessWebhook = { + id: alert.id, + created: alert.createdAt, + webhookVersion: "v1", + type: "alert.deployment.success", + object: { + deployment: { + id: alert.workerDeployment.friendlyId, + status: alert.workerDeployment.status, + version: alert.workerDeployment.version, + shortCode: alert.workerDeployment.shortCode, + deployedAt: alert.workerDeployment.deployedAt! ?? new Date(), + }, + tasks: + alert.workerDeployment.worker?.tasks.map((task) => ({ + id: task.slug, + filePath: task.filePath, + exportName: task.exportName, + triggerSource: task.triggerSource, + })) ?? [], + environment: { + id: alert.environment.id, + type: alert.environment.type, + slug: alert.environment.slug, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + }, + }; + + await this.#deliverWebhook(payload, webhookProperties.data); + + break; + } + default: { + throw new Error(`Unknown webhook version: ${webhookProperties.data.version}`); + } + } } else { logger.error("[DeliverAlert] Worker deployment not found", { alert, @@ -582,126 +662,7 @@ export class DeliverAlertService extends BaseService { switch (alert.type) { case "TASK_RUN_ATTEMPT": { - if (alert.taskRunAttempt) { - // Find existing storage by the run ID - const storage = await this._prisma.projectAlertStorage.findFirst({ - where: { - alertChannelId: alert.channel.id, - alertType: alert.type, - storageId: alert.taskRunAttempt.taskRunId, - }, - }); - - const storageData = storage - ? ProjectAlertSlackStorage.safeParse(storage.storageData) - : undefined; - - const thread_ts = - storageData && storageData.success ? storageData.data.message_ts : undefined; - - const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error); - - if (!taskRunError.success) { - logger.error("[DeliverAlert] Attempt: Failed to parse task run error", { - issues: taskRunError.error.issues, - taskAttemptError: alert.taskRunAttempt.error, - }); - - return; - } - - const error = createJsonErrorObject(taskRunError.data); - - const exportName = alert.taskRunAttempt.backgroundWorkerTask.exportName; - const version = alert.taskRunAttempt.backgroundWorker.version; - const environment = alert.environment.slug; - const taskIdentifier = alert.taskRunAttempt.backgroundWorkerTask.slug; - const timestamp = alert.taskRunAttempt.completedAt ?? new Date(); - const runId = alert.taskRunAttempt.taskRun.friendlyId; - const attemptNumber = alert.taskRunAttempt.number; - - const message = await this.#postSlackMessage(integration, { - thread_ts, - channel: slackProperties.data.channelId, - text: `Task error in ${alert.taskRunAttempt.backgroundWorkerTask.exportName} [${alert.taskRunAttempt.backgroundWorker.version}.${alert.environment.slug}]`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `:rotating_light: Error in *${exportName}* __`, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: this.#wrapInCodeBlock(error.stackTrace ?? error.message), - }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${runId}.${attemptNumber} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, - { - type: "divider", - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "Investigate", - }, - url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`, - }, - ], - }, - ], - }); - - // Upsert the storage - if (message.ts) { - if (storage) { - await this._prisma.projectAlertStorage.update({ - where: { - id: storage.id, - }, - data: { - storageData: { - message_ts: message.ts, - }, - }, - }); - } else { - await this._prisma.projectAlertStorage.create({ - data: { - alertChannelId: alert.channel.id, - alertType: alert.type, - storageId: alert.taskRunAttempt.taskRunId, - storageData: { - message_ts: message.ts, - }, - projectId: alert.project.id, - }, - }); - } - } - } else { - logger.error("[DeliverAlert] Task run attempt not found", { - alert, - }); - } - + logger.error("[DeliverAlert] Task run attempt alerts are deprecated, not sending anything"); break; } case "TASK_RUN": { @@ -945,7 +906,7 @@ export class DeliverAlertService extends BaseService { } } - async #deliverWebhook(payload: any, webhook: ProjectAlertWebhookProperties) { + async #deliverWebhook(payload: T, webhook: ProjectAlertWebhookProperties) { const rawPayload = JSON.stringify(payload); const hashPayload = Buffer.from(rawPayload, "utf-8"); @@ -1107,6 +1068,7 @@ export class DeliverAlertService extends BaseService { static async createAndSendAlert( { channelId, + channelType, projectId, environmentId, alertType, @@ -1114,6 +1076,7 @@ export class DeliverAlertService extends BaseService { taskRunId, }: { channelId: string; + channelType: ProjectAlertChannelType; projectId: string; environmentId: string; alertType: ProjectAlertType; @@ -1122,7 +1085,7 @@ export class DeliverAlertService extends BaseService { }, db: PrismaClientOrTransaction ) { - if (taskRunId) { + if (taskRunId && channelType !== "WEBHOOK") { try { const result = await alertsRateLimiter.check(channelId); diff --git a/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts b/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts index 7d8a71c58..fd390477b 100644 --- a/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts +++ b/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts @@ -49,6 +49,7 @@ export class PerformDeploymentAlertsService extends BaseService { await DeliverAlertService.createAndSendAlert( { channelId: alertChannel.id, + channelType: alertChannel.type, projectId: deployment.projectId, environmentId: deployment.environmentId, alertType, diff --git a/apps/webapp/app/v3/services/alerts/performTaskRunAlerts.server.ts b/apps/webapp/app/v3/services/alerts/performTaskRunAlerts.server.ts index 8b88a3f9d..6712392d0 100644 --- a/apps/webapp/app/v3/services/alerts/performTaskRunAlerts.server.ts +++ b/apps/webapp/app/v3/services/alerts/performTaskRunAlerts.server.ts @@ -49,6 +49,7 @@ export class PerformTaskRunAlertsService extends BaseService { await DeliverAlertService.createAndSendAlert( { channelId: alertChannel.id, + channelType: alertChannel.type, projectId: run.projectId, environmentId: run.runtimeEnvironmentId, alertType: "TASK_RUN", diff --git a/apps/webapp/app/v3/services/completeAttempt.server.ts b/apps/webapp/app/v3/services/completeAttempt.server.ts index cc4472f1e..235ae8735 100644 --- a/apps/webapp/app/v3/services/completeAttempt.server.ts +++ b/apps/webapp/app/v3/services/completeAttempt.server.ts @@ -738,7 +738,7 @@ async function findAttempt(prismaClient: PrismaClientOrTransaction, friendlyId: }); } -function isOOMError(error: TaskRunError) { +export function isOOMError(error: TaskRunError) { if (error.type === "INTERNAL_ERROR") { if ( error.code === "TASK_PROCESS_OOM_KILLED" || diff --git a/docs/troubleshooting-alerts.mdx b/docs/troubleshooting-alerts.mdx index 2ee194bc1..2bbd8e823 100644 --- a/docs/troubleshooting-alerts.mdx +++ b/docs/troubleshooting-alerts.mdx @@ -3,6 +3,13 @@ title: "Alerts" description: "Get alerted when runs or deployments fail, or when deployments succeed." --- +We support receiving alerts for the following events: +- Run fails +- Deployment fails +- Deployment succeeds + +## How to setup alerts + @@ -27,3 +34,63 @@ Click on the triple dot menu on the right side of the table row and select "Disa + + +## Alert webhooks + +For the alert webhooks you can use the SDK to parse them. Here is an example of how to parse the webhook payload in Remix: + +```ts +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { webhooks, WebhookError } from "@trigger.dev/sdk/v3"; + +export async function action({ request }: ActionFunctionArgs) { + // Make sure this is a POST request + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + try { + // Construct and verify the webhook event + // This secret can be found on your Alerts page when you create a webhook alert + const event = await webhooks.constructEvent(request, process.env.ALERT_WEBHOOK_SECRET!); + + // Process the event based on its type + switch (event.type) { + case "alert.run.failed": { + console.log("[Webhook Internal Test] Run failed alert webhook received", { event }); + break; + } + case "alert.deployment.success": { + console.log("[Webhook Internal Test] Deployment success alert webhook received", { event }); + break; + } + case "alert.deployment.failed": { + console.log("[Webhook Internal Test] Deployment failed alert webhook received", { event }); + break; + } + default: { + console.log("[Webhook Internal Test] Unhandled webhook type", { event }); + } + } + + // Return a success response + return json({ received: true }, { status: 200 }); + } catch (err) { + // Handle webhook errors + if (err instanceof WebhookError) { + console.error("Webhook error:", { message: err.message }); + return json({ error: err.message }, { status: 400 }); + } + + if (err instanceof Error) { + console.error("Error processing webhook:", { message: err.message }); + return json({ error: err.message }, { status: 400 }); + } + + // Handle other errors + console.error("Error processing webhook:", { err }); + return json({ error: "Internal server error" }, { status: 500 }); + } +} +``` diff --git a/packages/core/src/v3/schemas/index.ts b/packages/core/src/v3/schemas/index.ts index 6f8b74e64..ba94a4a16 100644 --- a/packages/core/src/v3/schemas/index.ts +++ b/packages/core/src/v3/schemas/index.ts @@ -10,3 +10,4 @@ export * from "./eventFilter.js"; export * from "./openTelemetry.js"; export * from "./config.js"; export * from "./build.js"; +export * from "./webhooks.js"; diff --git a/packages/core/src/v3/schemas/webhooks.ts b/packages/core/src/v3/schemas/webhooks.ts new file mode 100644 index 000000000..c583740d8 --- /dev/null +++ b/packages/core/src/v3/schemas/webhooks.ts @@ -0,0 +1,205 @@ +import { z } from "zod"; +import { RuntimeEnvironmentTypeSchema } from "../../schemas/api.js"; +import { RunStatus } from "./api.js"; +import { TaskRunError } from "./common.js"; + +/** Represents a failed run alert webhook payload */ +const AlertWebhookRunFailedObject = z.object({ + /** Task information */ + task: z.object({ + /** Unique identifier for the task */ + id: z.string(), + /** File path where the task is defined */ + filePath: z.string(), + /** Name of the exported task function */ + exportName: z.string(), + /** Version of the task */ + version: z.string(), + /** Version of the SDK used */ + sdkVersion: z.string(), + /** Version of the CLI used */ + cliVersion: z.string(), + }), + /** Run information */ + run: z.object({ + /** Unique identifier for the run */ + id: z.string(), + /** Run number */ + number: z.number(), + /** Current status of the run */ + status: RunStatus, + /** When the run was created */ + createdAt: z.coerce.date(), + /** When the run started executing */ + startedAt: z.coerce.date().optional(), + /** When the run finished executing */ + completedAt: z.coerce.date().optional(), + /** Whether this is a test run */ + isTest: z.boolean(), + /** Idempotency key for the run */ + idempotencyKey: z.string().optional(), + /** Associated tags */ + tags: z.array(z.string()), + /** Error information */ + error: TaskRunError, + /** Whether the run was an out-of-memory error */ + isOutOfMemoryError: z.boolean(), + /** Machine preset used for the run */ + machine: z.string(), + /** URL to view the run in the dashboard */ + dashboardUrl: z.string(), + }), + /** Environment information */ + environment: z.object({ + /** Environment ID */ + id: z.string(), + /** Environment type */ + type: RuntimeEnvironmentTypeSchema, + /** Environment slug */ + slug: z.string(), + }), + /** Organization information */ + organization: z.object({ + /** Organization ID */ + id: z.string(), + /** Organization slug */ + slug: z.string(), + /** Organization name */ + name: z.string(), + }), + /** Project information */ + project: z.object({ + /** Project ID */ + id: z.string(), + /** Project reference */ + ref: z.string(), + /** Project slug */ + slug: z.string(), + /** Project name */ + name: z.string(), + }), +}); +export type AlertWebhookRunFailedObject = z.infer; + +/** Represents a deployment error */ +export const DeployError = z.object({ + /** Error name */ + name: z.string(), + /** Error message */ + message: z.string(), + /** Error stack trace */ + stack: z.string().optional(), + /** Standard error output */ + stderr: z.string().optional(), +}); +export type DeployError = z.infer; + +const deploymentCommonProperties = { + /** Environment information */ + environment: z.object({ + id: z.string(), + type: RuntimeEnvironmentTypeSchema, + slug: z.string(), + }), + /** Organization information */ + organization: z.object({ + id: z.string(), + slug: z.string(), + name: z.string(), + }), + /** Project information */ + project: z.object({ + id: z.string(), + ref: z.string(), + slug: z.string(), + name: z.string(), + }), +}; + +const deploymentDeploymentCommonProperties = { + /** Deployment ID */ + id: z.string(), + /** Deployment status */ + status: z.string(), + /** Deployment version */ + version: z.string(), + /** Short code identifier */ + shortCode: z.string(), +}; + +/** Represents a successful deployment alert webhook payload */ +export const AlertWebhookDeploymentSuccessObject = z.object({ + ...deploymentCommonProperties, + deployment: z.object({ + ...deploymentDeploymentCommonProperties, + /** When the deployment completed */ + deployedAt: z.coerce.date(), + }), + /** Deployed tasks */ + tasks: z.array( + z.object({ + /** Task ID */ + id: z.string(), + /** File path where the task is defined */ + filePath: z.string(), + /** Name of the exported task function */ + exportName: z.string(), + /** Source of the trigger */ + triggerSource: z.string(), + }) + ), +}); + +/** Represents a failed deployment alert webhook payload */ +export const AlertWebhookDeploymentFailedObject = z.object({ + ...deploymentCommonProperties, + deployment: z.object({ + ...deploymentDeploymentCommonProperties, + /** When the deployment failed */ + failedAt: z.coerce.date(), + }), + /** Error information */ + error: DeployError, +}); + +export type AlertWebhookDeploymentSuccessObject = z.infer< + typeof AlertWebhookDeploymentSuccessObject +>; +export type AlertWebhookDeploymentFailedObject = z.infer; + +/** Common properties for all webhooks */ +const commonProperties = { + /** Webhook ID */ + id: z.string(), + /** When the webhook was created */ + created: z.coerce.date(), + /** Version of the webhook */ + webhookVersion: z.string(), +}; + +/** Represents all possible webhook types */ +export const Webhook = z.discriminatedUnion("type", [ + /** Run failed alert webhook */ + z.object({ + ...commonProperties, + type: z.literal("alert.run.failed"), + object: AlertWebhookRunFailedObject, + }), + /** Deployment success alert webhook */ + z.object({ + ...commonProperties, + type: z.literal("alert.deployment.success"), + object: AlertWebhookDeploymentSuccessObject, + }), + /** Deployment failed alert webhook */ + z.object({ + ...commonProperties, + type: z.literal("alert.deployment.failed"), + object: AlertWebhookDeploymentFailedObject, + }), +]); + +export type Webhook = z.infer; +export type RunFailedWebhook = Extract; +export type DeploymentSuccessWebhook = Extract; +export type DeploymentFailedWebhook = Extract; diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 7566597c0..02eded22b 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -56,6 +56,7 @@ "slug": "^6.0.0", "terminal-link": "^3.0.0", "ulid": "^2.3.0", + "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, diff --git a/packages/trigger-sdk/src/imports/uncrypto-cjs.cts b/packages/trigger-sdk/src/imports/uncrypto-cjs.cts new file mode 100644 index 000000000..a9e17c27e --- /dev/null +++ b/packages/trigger-sdk/src/imports/uncrypto-cjs.cts @@ -0,0 +1,5 @@ +// @ts-ignore +const { subtle } = require("uncrypto"); + +// @ts-ignore +module.exports.subtle = subtle; diff --git a/packages/trigger-sdk/src/imports/uncrypto.ts b/packages/trigger-sdk/src/imports/uncrypto.ts new file mode 100644 index 000000000..8e4a59494 --- /dev/null +++ b/packages/trigger-sdk/src/imports/uncrypto.ts @@ -0,0 +1,5 @@ +// @ts-ignore +import { subtle } from "uncrypto"; + +// @ts-ignore +export { subtle }; diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index b70ddfc26..f83254b8c 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -11,7 +11,7 @@ export * from "./idempotencyKeys.js"; export * from "./tags.js"; export * from "./metadata.js"; export * from "./timeout.js"; -export * from "./waitUntil.js"; +export * from "./webhooks.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/webhooks.ts b/packages/trigger-sdk/src/v3/webhooks.ts new file mode 100644 index 000000000..049cb1f5c --- /dev/null +++ b/packages/trigger-sdk/src/v3/webhooks.ts @@ -0,0 +1,171 @@ +import { Webhook } from "@trigger.dev/core/v3"; +import { subtle } from "../imports/uncrypto.js"; + +/** + * The type of error thrown when a webhook fails to parse or verify + */ +export class WebhookError extends Error { + constructor(message: string) { + super(message); + this.name = "WebhookError"; + } +} + +/** Header name used for webhook signatures */ +const SIGNATURE_HEADER_NAME = "x-trigger-signature-hmacsha256"; + +/** + * Options for constructing a webhook event + */ +type ConstructEventOptions = { + /** Raw payload as string or Buffer */ + payload: string | Buffer; + /** Signature header as string, Buffer, or string array */ + header: string | Buffer | Array; +}; + +/** + * Interface describing the webhook utilities + */ +interface Webhooks { + /** + * Constructs and validates a webhook event from an incoming request + * @param request - Either a Request object or ConstructEventOptions containing the payload and signature + * @param secret - Secret key used to verify the webhook signature + * @returns Promise resolving to a validated AlertWebhook object + * @throws {WebhookError} If validation fails or payload can't be parsed + * + * @example + * // Using with Request object + * const event = await webhooks.constructEvent(request, "webhook_secret"); + * + * @example + * // Using with manual options + * const event = await webhooks.constructEvent({ + * payload: rawBody, + * header: signatureHeader + * }, "webhook_secret"); + */ + constructEvent(request: ConstructEventOptions | Request, secret: string): Promise; + + /** Header name used for webhook signatures */ + SIGNATURE_HEADER_NAME: string; +} + +/** + * Webhook utilities for handling incoming webhook requests + */ +export const webhooks: Webhooks = { + constructEvent, + SIGNATURE_HEADER_NAME, +}; + +async function constructEvent( + request: ConstructEventOptions | Request, + secret: string +): Promise { + let payload: string; + let signature: string; + + if (request instanceof Request) { + if (!secret) { + throw new WebhookError("Secret is required when passing a Request object"); + } + + const signatureHeader = request.headers.get(SIGNATURE_HEADER_NAME); + if (!signatureHeader) { + throw new WebhookError("No signature header found"); + } + signature = signatureHeader; + + payload = await request.text(); + } else { + payload = request.payload.toString(); + + if (Array.isArray(request.header)) { + throw new WebhookError("Signature header cannot be an array"); + } + signature = request.header.toString(); + } + + // Verify the signature + const isValid = await verifySignature(payload, signature, secret); + + if (!isValid) { + throw new WebhookError("Invalid signature"); + } + + // Parse and validate the payload + try { + const jsonPayload = JSON.parse(payload); + const parsedPayload = Webhook.parse(jsonPayload); + return parsedPayload; + } catch (error) { + if (error instanceof Error) { + throw new WebhookError(`Webhook parsing failed: ${error.message}`); + } + throw new WebhookError("Webhook parsing failed"); + } +} + +/** + * Verifies the signature of a webhook payload + * @param payload - Raw payload string to verify + * @param signature - Expected signature to check against + * @param secret - Secret key used to generate the signature + * @returns Promise resolving to boolean indicating if signature is valid + * @throws {WebhookError} If signature verification process fails + * + * @example + * const isValid = await verifySignature( + * '{"event": "test"}', + * "abc123signature", + * "webhook_secret" + * ); + */ +async function verifySignature( + payload: string, + signature: string, + secret: string +): Promise { + try { + if (!secret) { + throw new WebhookError("Secret is required for signature verification"); + } + + // Convert the payload and secret to buffers + const hashPayload = Buffer.from(payload, "utf-8"); + const hmacSecret = Buffer.from(secret, "utf-8"); + + // Import the secret key + const key = await subtle.importKey( + "raw", + hmacSecret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); + + // Calculate the expected signature + const actualSignature = await subtle.sign("HMAC", key, hashPayload); + const actualSignatureHex = Buffer.from(actualSignature).toString("hex"); + + // Compare signatures using timing-safe comparison + return timingSafeEqual(signature, actualSignatureHex); + } catch (error) { + throw new WebhookError("Signature verification failed"); + } +} + +// Timing-safe comparison to prevent timing attacks +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b74e0168..f97aae3e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,7 +410,7 @@ importers: version: 8.6.6 '@uiw/react-codemirror': specifier: ^4.19.5 - version: 4.19.5(@babel/runtime@7.24.5)(@codemirror/autocomplete@6.4.0)(@codemirror/language@6.3.2)(@codemirror/lint@6.4.2)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/theme-one-dark@6.1.0)(@codemirror/view@6.7.2)(codemirror@6.0.1)(react-dom@18.2.0)(react@18.2.0) + version: 4.19.5(@babel/runtime@7.26.7)(@codemirror/autocomplete@6.4.0)(@codemirror/language@6.3.2)(@codemirror/lint@6.4.2)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/theme-one-dark@6.1.0)(@codemirror/view@6.7.2)(codemirror@6.0.1)(react-dom@18.2.0)(react@18.2.0) '@unkey/cache': specifier: ^1.5.0 version: 1.5.0 @@ -774,7 +774,7 @@ importers: version: 10.4.13(postcss@8.4.44) babel-loader: specifier: ^9.1.3 - version: 9.1.3(@babel/core@7.24.5)(webpack@5.88.2) + version: 9.1.3(@babel/core@7.26.8)(webpack@5.88.2) babel-preset-react-app: specifier: ^10.0.1 version: 10.0.1 @@ -1535,6 +1535,9 @@ importers: ulid: specifier: ^2.3.0 version: 2.3.0 + uncrypto: + specifier: ^0.1.3 + version: 0.1.3 uuid: specifier: ^9.0.0 version: 9.0.0 @@ -3329,6 +3332,15 @@ packages: '@babel/highlight': 7.24.7 picocolors: 1.0.1 + /@babel/code-frame@7.26.2: + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + dev: true + /@babel/compat-data@7.22.9: resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} engines: {node: '>=6.9.0'} @@ -3336,6 +3348,12 @@ packages: /@babel/compat-data@7.25.4: resolution: {integrity: sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==} engines: {node: '>=6.9.0'} + dev: false + + /@babel/compat-data@7.26.8: + resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} + engines: {node: '>=6.9.0'} + dev: true /@babel/core@7.22.17: resolution: {integrity: sha512-2EENLmhpwplDux5PSsZnSbnSkB3tZ6QTksgO25xwEL7pIDcNOMhF5v/s6RzwjMZzZzw9Ofc30gHv5ChCC8pifQ==} @@ -3380,6 +3398,31 @@ packages: semver: 6.3.1 transitivePeerDependencies: - supports-color + dev: false + + /@babel/core@7.26.8: + resolution: {integrity: sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.8 + '@babel/helper-compilation-targets': 7.26.5 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.8) + '@babel/helpers': 7.26.7 + '@babel/parser': 7.26.8 + '@babel/template': 7.26.8 + '@babel/traverse': 7.26.8 + '@babel/types': 7.26.8 + '@types/gensync': 1.0.4 + convert-source-map: 2.0.0 + debug: 4.3.7 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true /@babel/eslint-parser@7.21.8(@babel/core@7.22.17)(eslint@8.31.0): resolution: {integrity: sha512-HLhI+2q+BP3sf78mFUZNCGc10KEmoUqtUT1OCdMZsN+qr4qFeLUod62/zAnF3jNQstwyasDkZnVXwfK2Bml7MQ==} @@ -3421,6 +3464,18 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 + dev: false + + /@babel/generator@7.26.8: + resolution: {integrity: sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + dev: true /@babel/helper-annotate-as-pure@7.22.5: resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} @@ -3456,6 +3511,18 @@ packages: browserslist: 4.23.3 lru-cache: 5.1.1 semver: 6.3.1 + dev: false + + /@babel/helper-compilation-targets@7.26.5: + resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.26.8 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true /@babel/helper-create-class-features-plugin@7.21.8(@babel/core@7.22.17): resolution: {integrity: sha512-+THiN8MqiH2AczyuZrnrKL6cAxFRRQDKW9h1YkBvbgKmAm6mwiacig1qT73DHIWMGo40GRnsEfN3LA+E6NtmSw==} @@ -3623,6 +3690,17 @@ packages: '@babel/types': 7.24.7 transitivePeerDependencies: - supports-color + dev: false + + /@babel/helper-module-imports@7.25.9: + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.26.8 + '@babel/types': 7.26.8 + transitivePeerDependencies: + - supports-color + dev: true /@babel/helper-module-transforms@7.22.17(@babel/core@7.22.17): resolution: {integrity: sha512-XouDDhQESrLHTpnBtCKExJdyY4gJCdrvH2Pyv8r8kovX2U8G0dRUOT45T9XlbLtuu9CLXP15eusnkprhoPV5iQ==} @@ -3650,6 +3728,21 @@ packages: '@babel/traverse': 7.25.6 transitivePeerDependencies: - supports-color + dev: false + + /@babel/helper-module-transforms@7.26.0(@babel/core@7.26.8): + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.26.8 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.26.8 + transitivePeerDependencies: + - supports-color + dev: true /@babel/helper-optimise-call-expression@7.18.6: resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} @@ -3730,6 +3823,7 @@ packages: '@babel/types': 7.24.7 transitivePeerDependencies: - supports-color + dev: false /@babel/helper-skip-transparent-expression-wrappers@7.20.0: resolution: {integrity: sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==} @@ -3764,11 +3858,20 @@ packages: /@babel/helper-string-parser@7.24.8: resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} engines: {node: '>=6.9.0'} + dev: false + + /@babel/helper-string-parser@7.25.9: + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} /@babel/helper-validator-identifier@7.24.7: resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} + /@babel/helper-validator-identifier@7.25.9: + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + /@babel/helper-validator-option@7.22.15: resolution: {integrity: sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==} engines: {node: '>=6.9.0'} @@ -3776,6 +3879,12 @@ packages: /@babel/helper-validator-option@7.24.8: resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} engines: {node: '>=6.9.0'} + dev: false + + /@babel/helper-validator-option@7.25.9: + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + dev: true /@babel/helper-wrap-function@7.20.5: resolution: {integrity: sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==} @@ -3805,6 +3914,15 @@ packages: dependencies: '@babel/template': 7.25.0 '@babel/types': 7.25.6 + dev: false + + /@babel/helpers@7.26.7: + resolution: {integrity: sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.26.8 + '@babel/types': 7.26.8 + dev: true /@babel/highlight@7.22.13: resolution: {integrity: sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==} @@ -3860,6 +3978,14 @@ packages: hasBin: true dependencies: '@babel/types': 7.25.6 + dev: false + + /@babel/parser@7.26.8: + resolution: {integrity: sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.26.8 /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.18.6(@babel/core@7.22.17): resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==} @@ -4872,6 +4998,13 @@ packages: dependencies: regenerator-runtime: 0.14.1 + /@babel/runtime@7.26.7: + resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@babel/template@7.22.15: resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} engines: {node: '>=6.9.0'} @@ -4895,6 +5028,16 @@ packages: '@babel/code-frame': 7.24.7 '@babel/parser': 7.25.6 '@babel/types': 7.25.6 + dev: false + + /@babel/template@7.26.8: + resolution: {integrity: sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 + dev: true /@babel/traverse@7.22.17: resolution: {integrity: sha512-xK4Uwm0JnAMvxYZxOVecss85WxTEIbTa7bnGyf/+EgCL5Zt3U7htUpEOWv9detPlamGKuRzCqw74xVglDWpPdg==} @@ -4944,6 +5087,22 @@ packages: globals: 11.12.0 transitivePeerDependencies: - supports-color + dev: false + + /@babel/traverse@7.26.8: + resolution: {integrity: sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.8 + '@babel/parser': 7.26.8 + '@babel/template': 7.26.8 + '@babel/types': 7.26.8 + debug: 4.3.7 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true /@babel/types@7.24.0: resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} @@ -4968,6 +5127,14 @@ packages: '@babel/helper-string-parser': 7.24.8 '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 + dev: false + + /@babel/types@7.26.8: + resolution: {integrity: sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 /@balena/dockerignore@1.0.2: resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -16674,6 +16841,10 @@ packages: '@types/node': 18.19.20 dev: true + /@types/gensync@1.0.4: + resolution: {integrity: sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==} + dev: true + /@types/gradient-string@1.1.2: resolution: {integrity: sha512-zIet2KvHr2dkOCPI5ggQQ+WJVyfBSFaqK9sNelhgDjlE2K3Fu2muuPJwu5aKM3xoWuc3WXudVEMUwI1QWhykEQ==} dependencies: @@ -17288,7 +17459,7 @@ packages: '@codemirror/view': 6.7.2 dev: false - /@uiw/react-codemirror@4.19.5(@babel/runtime@7.24.5)(@codemirror/autocomplete@6.4.0)(@codemirror/language@6.3.2)(@codemirror/lint@6.4.2)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/theme-one-dark@6.1.0)(@codemirror/view@6.7.2)(codemirror@6.0.1)(react-dom@18.2.0)(react@18.2.0): + /@uiw/react-codemirror@4.19.5(@babel/runtime@7.26.7)(@codemirror/autocomplete@6.4.0)(@codemirror/language@6.3.2)(@codemirror/lint@6.4.2)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/theme-one-dark@6.1.0)(@codemirror/view@6.7.2)(codemirror@6.0.1)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-ZCHh8d7beXbF8/t7F1+yHht6A9Y6CdKeOkZq4A09lxJEnyTQrj1FMf2zvfaqc7K23KNjkTCtSlbqKKbVDgrWaw==} peerDependencies: '@babel/runtime': '>=7.11.0' @@ -17299,7 +17470,7 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@babel/runtime': 7.24.5 + '@babel/runtime': 7.26.7 '@codemirror/commands': 6.1.3 '@codemirror/state': 6.2.0 '@codemirror/theme-one-dark': 6.1.0 @@ -17590,7 +17761,7 @@ packages: /@vue/compiler-core@3.4.38: resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==} dependencies: - '@babel/parser': 7.25.6 + '@babel/parser': 7.26.8 '@vue/shared': 3.4.38 entities: 4.5.0 estree-walker: 2.0.2 @@ -17605,7 +17776,7 @@ packages: /@vue/compiler-sfc@3.4.38: resolution: {integrity: sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==} dependencies: - '@babel/parser': 7.25.6 + '@babel/parser': 7.26.8 '@vue/compiler-core': 3.4.38 '@vue/compiler-dom': 3.4.38 '@vue/compiler-ssr': 3.4.38 @@ -18598,14 +18769,14 @@ packages: /b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} - /babel-loader@9.1.3(@babel/core@7.24.5)(webpack@5.88.2): + /babel-loader@9.1.3(@babel/core@7.26.8)(webpack@5.88.2): resolution: {integrity: sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==} engines: {node: '>= 14.15.0'} peerDependencies: '@babel/core': ^7.12.0 webpack: '>=5' dependencies: - '@babel/core': 7.24.5 + '@babel/core': 7.26.8 find-cache-dir: 4.0.0 schema-utils: 4.0.1 webpack: 5.88.2(@swc/core@1.3.26)(esbuild@0.15.18) @@ -18893,6 +19064,17 @@ packages: node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.23.3) + /browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001699 + electron-to-chromium: 1.5.98 + node-releases: 2.0.19 + update-browserslist-db: 1.1.2(browserslist@4.24.4) + dev: true + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -19089,6 +19271,10 @@ packages: /caniuse-lite@1.0.30001655: resolution: {integrity: sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==} + /caniuse-lite@1.0.30001699: + resolution: {integrity: sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==} + dev: true + /capnp-ts@0.7.0: resolution: {integrity: sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==} dependencies: @@ -20617,6 +20803,10 @@ packages: /electron-to-chromium@1.5.13: resolution: {integrity: sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==} + /electron-to-chromium@1.5.98: + resolution: {integrity: sha512-bI/LbtRBxU2GzK7KK5xxFd2y9Lf9XguHooPYbcXWy6wUoT8NMnffsvRhPmSeUHLSDKAEtKuTaEtK4Ms15zkIEA==} + dev: true + /email-reply-parser@1.8.0: resolution: {integrity: sha512-hiie/4vNxT5NYBux8m/jZccUQdtQqiq5ytwJwbA9QIkTWbTNPQasJXfsfdetZo23hBn4ZJleRDyOA5nV4+ssSQ==} engines: {node: '>= 10.0.0'} @@ -25726,6 +25916,10 @@ packages: /node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + /node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + dev: true + /nodemailer@6.9.16: resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==} engines: {node: '>=6.0.0'} @@ -26265,14 +26459,14 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dependencies: - yocto-queue: 1.0.0 + yocto-queue: 1.1.1 dev: true /p-limit@5.0.0: resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} engines: {node: '>=18'} dependencies: - yocto-queue: 1.0.0 + yocto-queue: 1.1.1 dev: true /p-limit@6.2.0: @@ -26729,6 +26923,10 @@ packages: /picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + /picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + dev: true + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -31211,6 +31409,10 @@ packages: through: 2.3.8 dev: false + /uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + dev: false + /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -31398,6 +31600,17 @@ packages: escalade: 3.2.0 picocolors: 1.0.1 + /update-browserslist-db@1.1.2(browserslist@4.24.4): + resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + dev: true + /uploadthing@7.1.0(next@14.2.15)(tailwindcss@3.4.1): resolution: {integrity: sha512-l1bRHs+q/YLx3XwBav98t4Bl1wLWaskhPEwopxtYgiRrxX5nW3uUuSP0RJ9eKwx0+6ZhHWxHDvShf7ZLledqmQ==} engines: {node: '>=18.13.0'} @@ -32857,15 +33070,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - /yocto-queue@1.0.0: - resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} - engines: {node: '>=12.20'} - dev: true - /yocto-queue@1.1.1: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} - dev: false /youch@3.3.3: resolution: {integrity: sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==} From a62cb3e60b6e84d4ff8221d36dc7d50958bd19b2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 13 Feb 2025 14:51:43 +0000 Subject: [PATCH 3/5] Agent docs examples (#1706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Uses image cards for the frameworks * Removes old snippets * New AI agents side menu section * WIP adding new ai agent pages * Better overview page * More copy added to the agent example pages * Copy improvements * Removes “Creating a project” page and side menu section * Fixes broken links * Updates to the latest Mintlify version, fixes issues, changes theme * Adds descriptions to the main dropdown menu items * Reformatted Introduction docs ‘landing page’ * Retry heartbeat timeouts by putting back in the queue (#1689) * If there’s a heartbeat error and no attempts we put it back in the queue to try again * When nacking, return whether it was put back in the queue or not * Try and nack, if it fails then fail the run * Consolidated switch statement * Fail executing/retrying runs * OOM retrying on larger machines (#1691) * OOM retrying on larger machines * Create forty-windows-shop.md * Update forty-windows-shop.md * Only retry again if the machine is different from the original * Kubernetes OOMs appear as non-zero sigkills, adding support for treating these as OOMs * Complete the original attempt span if retrying due to an OOM * Revert "Complete the original attempt span if retrying due to an OOM" This reverts commit 5f652c6212728c170f12d95484587b425dd89df9. * chore: Update version for release (#1666) Co-authored-by: github-actions[bot] * Release 3.3.14 * Set machine when triggering docs * Batch queue runs that are waiting for deploy (#1693) * Detect ffmpeg OOM errors, added manual OutOfMemoryError (#1694) * Detect ffmpeg OOM errors, added manual OutOfMemoryError * Create eighty-spies-knock.md * Improved the machines docs, including the new OutOfMemoryError * chore: Update version for release (#1695) Co-authored-by: github-actions[bot] * Release 3.3.15 * Create new partitioned TaskEvent table, and switch to it gradually as new runs are created (#1696) * Create new partitioned TaskEvent table, and switch to it gradually as new runs are created * Add env var for partition window in seconds * Make startCreatedAt required in task event store * Don't create an attempt if the run is final, batchTriggerAndWait bad continue fix (#1698) * WIP fix for ResumeAttemptService selecting the wrong attempt (which has no error or output) * Don’t create an attempt if the run is already in a final status * Don’t get all the columns for the query. Improved the logging. * Added a log to the batch example * Filter out the undefined values * Fix missing logs on child runs by using the root task run createdAt if it exists (#1697) * Provider changes to support image cache (#1700) * add env var for additional pull secrets * make static images configurable * optional image prefixes * optional labels with sample rates * add missing core paths * remove excessive logs * Fix run container exits after OOM retries (#1701) * remove unused imports * tell run to exit before force requeue * handle exit for case where we already retried after oom * improve retry span and add machine props * don't try to exit run in dev * Upgrade local dev to use electric beta.15 (#1699) * Text fixes * Removed pnpm files --------- Co-authored-by: Matt Aitken Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Eric Allam Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- docs/docs.json | 484 ++++++++++++++++++ docs/guides/ai-agents/evaluator-optimizer.png | Bin 0 -> 20053 bytes .../ai-agents/generate-translate-copy.mdx | 120 +++++ .../guides/ai-agents/orchestrator-workers.png | Bin 0 -> 17641 bytes docs/guides/ai-agents/overview.mdx | 17 + docs/guides/ai-agents/parallelization.png | Bin 0 -> 16124 bytes docs/guides/ai-agents/prompt-chaining.png | Bin 0 -> 16700 bytes .../ai-agents/respond-and-check-content.mdx | 134 +++++ docs/guides/ai-agents/route-question.mdx | 114 +++++ docs/guides/ai-agents/routing.png | Bin 0 -> 17817 bytes .../guides/ai-agents/translate-and-refine.mdx | 170 ++++++ docs/guides/ai-agents/verify-news-article.mdx | 220 ++++++++ docs/guides/dashboard/creating-a-project.mdx | 36 -- .../example-projects/realtime-fal-ai.mdx | 20 +- .../examples/fal-ai-image-to-cartoon.mdx | 12 +- docs/guides/examples/fal-ai-realtime.mdx | 14 +- docs/guides/examples/scrape-hacker-news.mdx | 14 +- .../supabase-edge-functions-basic.mdx | 2 +- ...abase-edge-functions-database-webhooks.mdx | 2 +- docs/guides/introduction.mdx | 24 +- .../creating-a-project-1.png | Bin 202959 -> 0 bytes .../creating-a-project-2.png | Bin 156237 -> 0 bytes .../creating-a-project-3.png | Bin 205705 -> 0 bytes docs/images/intro-browserbase.jpg | Bin 0 -> 5397 bytes docs/images/intro-deepgram.jpg | Bin 0 -> 5951 bytes docs/images/intro-examples.jpg | Bin 0 -> 23883 bytes docs/images/intro-fal.jpg | Bin 0 -> 7463 bytes docs/images/intro-ffmpeg.jpg | Bin 0 -> 15533 bytes docs/images/intro-firecrawl.jpg | Bin 0 -> 11637 bytes docs/images/intro-frameworks.jpg | Bin 0 -> 25339 bytes docs/images/intro-libreoffice.jpg | Bin 0 -> 6022 bytes docs/images/intro-openai.jpg | Bin 0 -> 12924 bytes docs/images/intro-puppeteer.jpg | Bin 0 -> 9871 bytes docs/images/intro-quickstart.jpg | Bin 0 -> 25454 bytes docs/images/intro-resend.jpg | Bin 0 -> 5696 bytes docs/images/intro-sentry.jpg | Bin 0 -> 10346 bytes docs/images/intro-sharp.jpg | Bin 0 -> 10501 bytes docs/images/intro-supabase.jpg | Bin 0 -> 7874 bytes docs/images/intro-vercel.jpg | Bin 0 -> 4539 bytes docs/images/intro-video.jpg | Bin 0 -> 23246 bytes docs/images/logo-bun.png | Bin 0 -> 14260 bytes docs/images/logo-nextjs.png | Bin 0 -> 8014 bytes docs/images/logo-nodejs-1.png | Bin 0 -> 14096 bytes docs/images/logo-nodejs.png | Bin 0 -> 6632 bytes docs/images/logo-remix.png | Bin 0 -> 14259 bytes docs/introduction.mdx | 135 +++-- docs/mint.json | 392 -------------- docs/realtime/overview.mdx | 14 +- docs/snippets/card-bun.mdx | 10 - docs/snippets/card-nextjs.mdx | 22 - docs/snippets/card-nodejs.mdx | 11 - docs/snippets/card-remix.mdx | 206 -------- docs/snippets/card-supabase.mdx | 34 -- docs/snippets/framework-prerequisites.mdx | 2 +- docs/video-walkthrough.mdx | 4 +- 55 files changed, 1396 insertions(+), 817 deletions(-) create mode 100644 docs/docs.json create mode 100644 docs/guides/ai-agents/evaluator-optimizer.png create mode 100644 docs/guides/ai-agents/generate-translate-copy.mdx create mode 100644 docs/guides/ai-agents/orchestrator-workers.png create mode 100644 docs/guides/ai-agents/overview.mdx create mode 100644 docs/guides/ai-agents/parallelization.png create mode 100644 docs/guides/ai-agents/prompt-chaining.png create mode 100644 docs/guides/ai-agents/respond-and-check-content.mdx create mode 100644 docs/guides/ai-agents/route-question.mdx create mode 100644 docs/guides/ai-agents/routing.png create mode 100644 docs/guides/ai-agents/translate-and-refine.mdx create mode 100644 docs/guides/ai-agents/verify-news-article.mdx delete mode 100644 docs/guides/dashboard/creating-a-project.mdx delete mode 100644 docs/images/creating-a-project/creating-a-project-1.png delete mode 100644 docs/images/creating-a-project/creating-a-project-2.png delete mode 100644 docs/images/creating-a-project/creating-a-project-3.png create mode 100644 docs/images/intro-browserbase.jpg create mode 100644 docs/images/intro-deepgram.jpg create mode 100644 docs/images/intro-examples.jpg create mode 100644 docs/images/intro-fal.jpg create mode 100644 docs/images/intro-ffmpeg.jpg create mode 100644 docs/images/intro-firecrawl.jpg create mode 100644 docs/images/intro-frameworks.jpg create mode 100644 docs/images/intro-libreoffice.jpg create mode 100644 docs/images/intro-openai.jpg create mode 100644 docs/images/intro-puppeteer.jpg create mode 100644 docs/images/intro-quickstart.jpg create mode 100644 docs/images/intro-resend.jpg create mode 100644 docs/images/intro-sentry.jpg create mode 100644 docs/images/intro-sharp.jpg create mode 100644 docs/images/intro-supabase.jpg create mode 100644 docs/images/intro-vercel.jpg create mode 100644 docs/images/intro-video.jpg create mode 100644 docs/images/logo-bun.png create mode 100644 docs/images/logo-nextjs.png create mode 100644 docs/images/logo-nodejs-1.png create mode 100644 docs/images/logo-nodejs.png create mode 100644 docs/images/logo-remix.png delete mode 100644 docs/mint.json delete mode 100644 docs/snippets/card-bun.mdx delete mode 100644 docs/snippets/card-nextjs.mdx delete mode 100644 docs/snippets/card-nodejs.mdx delete mode 100644 docs/snippets/card-remix.mdx delete mode 100644 docs/snippets/card-supabase.mdx diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 000000000..8649b22cb --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,484 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "theme": "maple", + "name": "Trigger.dev", + "description": "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.", + "colors": { + "primary": "#A8FF53", + "light": "#A8FF53", + "dark": "#A8FF53" + }, + "favicon": "/images/favicon.png", + "navigation": { + "dropdowns": [ + { + "dropdown": "Documentation", + "description": "Resources for Trigger.dev", + "icon": "book-open", + "groups": [ + { + "group": "Getting started", + "pages": ["introduction", "quick-start", "video-walkthrough", "how-it-works", "limits"] + }, + { + "group": "Fundamentals", + "pages": [ + { + "group": "Tasks", + "pages": ["tasks/overview", "tasks/schemaTask", "tasks/scheduled"] + }, + "triggering", + "runs", + "apikeys", + { + "group": "Configuration", + "pages": ["config/config-file", "config/extensions/overview"] + } + ] + }, + { + "group": "Development", + "pages": ["cli-dev", "run-tests"] + }, + { + "group": "Deployment", + "pages": [ + "cli-deploy", + "deploy-environment-variables", + "github-actions", + { + "group": "Deployment integrations", + "pages": ["vercel-integration"] + } + ] + }, + { + "group": "Writing tasks", + "pages": [ + "writing-tasks-introduction", + "logging", + "errors-retrying", + { + "group": "Wait", + "pages": ["wait", "wait-for", "wait-until", "wait-for-event", "wait-for-request"] + }, + "queue-concurrency", + "versioning", + "machines", + "idempotency", + "replaying", + "runs/max-duration", + "tags", + "runs/metadata", + "run-usage", + "context", + "bulk-actions", + "examples" + ] + }, + { + "group": "Frontend usage", + "pages": [ + "frontend/overview", + { + "group": "React hooks", + "pages": [ + "frontend/react-hooks/overview", + "frontend/react-hooks/realtime", + "frontend/react-hooks/triggering" + ] + } + ] + }, + { + "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": "API reference", + "pages": [ + "management/overview", + { + "group": "Tasks API", + "pages": ["management/tasks/trigger", "management/tasks/batch-trigger"] + }, + { + "group": "Runs API", + "pages": [ + "management/runs/list", + "management/runs/retrieve", + "management/runs/replay", + "management/runs/cancel", + "management/runs/reschedule", + "management/runs/update-metadata" + ] + }, + { + "group": "Schedules API", + "pages": [ + "management/schedules/list", + "management/schedules/create", + "management/schedules/retrieve", + "management/schedules/update", + "management/schedules/delete", + "management/schedules/deactivate", + "management/schedules/activate", + "management/schedules/timezones" + ] + }, + { + "group": "Env Vars API", + "pages": [ + "management/envvars/list", + "management/envvars/import", + "management/envvars/create", + "management/envvars/retrieve", + "management/envvars/update", + "management/envvars/delete" + ] + }, + { + "group": "Projects API", + "pages": ["management/projects/runs"] + } + ] + }, + { + "group": "CLI", + "pages": [ + "cli-introduction", + { + "group": "Commands", + "pages": [ + "cli-login-commands", + "cli-init-commands", + "cli-dev-commands", + "cli-deploy-commands", + "cli-whoami-commands", + "cli-logout-commands", + "cli-list-profiles-commands", + "cli-update-commands" + ] + } + ] + }, + { + "group": "Open source", + "pages": [ + "open-source-self-hosting", + "open-source-contributing", + "github-repo", + "changelog", + "roadmap" + ] + }, + { + "group": "Troubleshooting", + "pages": [ + "troubleshooting", + "upgrading-packages", + "upgrading-beta", + "troubleshooting-alerts", + "troubleshooting-uptime-status", + "troubleshooting-github-issues", + "request-feature" + ] + }, + { + "group": "Help", + "pages": ["community", "help-slack", "help-email"] + } + ] + }, + { + "dropdown": "Guides & examples", + "description": "A great way to get started", + "icon": "book", + "groups": [ + { + "group": "Introduction", + "pages": ["guides/introduction"] + }, + { + "group": "Frameworks", + "pages": [ + "guides/frameworks/bun", + "guides/frameworks/nextjs", + "guides/frameworks/nodejs", + "guides/frameworks/remix" + ] + }, + { + "group": "Guides", + "pages": [ + { + "group": "AI Agents", + "icon": { + "name": "microchip-ai", + "style": "regular" + }, + "pages": [ + "guides/ai-agents/overview", + "guides/ai-agents/generate-translate-copy", + "guides/ai-agents/route-question", + "guides/ai-agents/respond-and-check-content", + "guides/ai-agents/verify-news-article", + "guides/ai-agents/translate-and-refine" + ] + }, + "guides/frameworks/drizzle", + "guides/frameworks/prisma", + "guides/frameworks/sequin", + { + "group": "Supabase", + "icon": { + "name": "bolt", + "style": "solid" + }, + "pages": [ + "guides/frameworks/supabase-guides-overview", + "guides/frameworks/supabase-edge-functions-basic", + "guides/frameworks/supabase-edge-functions-database-webhooks" + ] + }, + { + "group": "Webhooks", + "icon": { + "name": "webhook", + "style": "solid" + }, + "pages": [ + "guides/frameworks/webhooks-guides-overview", + "guides/frameworks/nextjs-webhooks", + "guides/frameworks/remix-webhooks", + "guides/examples/stripe-webhook" + ] + } + ] + }, + { + "group": "Example projects", + "pages": [ + "guides/example-projects/realtime-fal-ai", + "guides/example-projects/batch-llm-evaluator", + "guides/example-projects/realtime-csv-importer", + "guides/example-projects/vercel-ai-sdk-image-generator" + ] + }, + { + "group": "Example tasks", + "pages": [ + "guides/examples/dall-e3-generate-image", + "guides/examples/deepgram-transcribe-audio", + "guides/examples/fal-ai-image-to-cartoon", + "guides/examples/fal-ai-realtime", + "guides/examples/ffmpeg-video-processing", + "guides/examples/firecrawl-url-crawl", + "guides/examples/libreoffice-pdf-conversion", + "guides/examples/open-ai-with-retrying", + "guides/examples/pdf-to-image", + "guides/examples/puppeteer", + "guides/examples/scrape-hacker-news", + "guides/examples/sentry-error-tracking", + "guides/examples/sharp-image-processing", + "guides/examples/supabase-database-operations", + "guides/examples/supabase-storage-upload", + "guides/examples/react-pdf", + "guides/examples/resend-email-sequence", + "guides/examples/vercel-ai-sdk", + "guides/examples/vercel-sync-env-vars" + ] + }, + { + "group": "Migrations", + "pages": ["guides/use-cases/upgrading-from-v2"] + } + ] + } + ] + }, + "logo": { + "light": "/logo/light.png", + "dark": "/logo/dark.png", + "href": "https://trigger.dev" + }, + "api": { + "openapi": ["openapi.yml", "v3-openapi.yaml"], + "playground": { + "display": "simple" + } + }, + "appearance": { + "default": "dark", + "strict": true + }, + "background": { + "color": { + "light": "#fff", + "dark": "#121317" + } + }, + "navbar": { + "primary": { + "type": "github", + "href": "https://github.com/triggerdotdev/trigger.dev" + } + }, + "footer": { + "socials": { + "x": "https://twitter.com/triggerdotdev", + "github": "https://github.com/triggerdotdev", + "linkedin": "https://www.linkedin.com/company/triggerdotdev" + }, + "links": [ + { + "header": "Developers", + "items": [ + { + "label": "Changelog", + "href": "https://trigger.dev/changelog" + }, + { + "label": "Contributing", + "href": "https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md" + }, + { + "label": "Open source", + "href": "https://github.com/triggerdotdev/trigger.dev?tab=Apache-2.0-1-ov-file#readme" + }, + { + "label": "GitHub", + "href": "https://github.com/triggerdotdev/trigger.dev" + }, + { + "label": "OSS Friends", + "href": "https://trigger.dev/oss-friends" + } + ] + }, + { + "header": "Product", + "items": [ + { + "label": "Pricing", + "href": "https://trigger.dev/pricing" + }, + { + "label": "How it works", + "href": "https://trigger.dev/#how-it-works" + }, + { + "label": "Features", + "href": "https://trigger.dev/product" + }, + { + "label": "Roadmap", + "href": "https://feedback.trigger.dev/roadmap" + }, + { + "label": "FAQs", + "href": "https://trigger.dev/pricing#faqs" + }, + { + "label": "Uptime status", + "href": "https://status.trigger.dev/" + } + ] + }, + { + "header": "Company", + "items": [ + { + "label": "Blog", + "href": "https://trigger.dev/blog" + }, + { + "label": "Contact", + "href": "https://trigger.dev/contact" + }, + { + "label": "Careers", + "href": "https://trigger.dev/jobs" + }, + { + "label": "Privacy", + "href": "https://trigger.dev/legal/privacy" + }, + { + "label": "Terms of service", + "href": "https://trigger.dev/legal" + } + ] + } + ] + }, + "redirects": [ + { + "source": "/v3/feature-matrix", + "destination": "https://feedback.trigger.dev/roadmap" + }, + { + "source": "/v3/upgrading-from-v2", + "destination": "/guides/use-cases/upgrading-from-v2" + }, + { + "source": "/v3/open-source-self-hosting", + "destination": "/open-source-self-hosting" + }, + { + "source": "/v3/:slug*", + "destination": "/:slug*" + }, + { + "source": "/reattempting-replaying", + "destination": "/replaying" + }, + { + "source": "/tasks-overview", + "destination": "/tasks/overview" + }, + { + "source": "/tasks-scheduled", + "destination": "/tasks/scheduled" + }, + { + "source": "/trigger-folder", + "destination": "/config/config-file" + }, + { + "source": "/trigger-config", + "destination": "/config/config-file" + }, + { + "source": "/guides/frameworks/introduction", + "destination": "/guides/introduction" + }, + { + "source": "/guides/examples/intro", + "destination": "/guides/introduction" + }, + { + "source": "/examples/:slug*", + "destination": "/guides/examples/:slug*" + }, + { + "source": "/realtime", + "destination": "/realtime/overview" + }, + { + "source": "/runs-and-attempts", + "destination": "/runs" + }, + { + "source": "/frontend/react-hooks", + "destination": "/frontend/react-hooks/overview" + } + ] +} diff --git a/docs/guides/ai-agents/evaluator-optimizer.png b/docs/guides/ai-agents/evaluator-optimizer.png new file mode 100644 index 0000000000000000000000000000000000000000..4ec95643cf112fed442c3a0b6d20d9308893fd50 GIT binary patch literal 20053 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 Y`FhJfnYsn7uRx}Iy85}Sb4q9e0NNACWB>pF literal 0 HcmV?d00001 diff --git a/docs/guides/ai-agents/generate-translate-copy.mdx b/docs/guides/ai-agents/generate-translate-copy.mdx new file mode 100644 index 000000000..127cb028e --- /dev/null +++ b/docs/guides/ai-agents/generate-translate-copy.mdx @@ -0,0 +1,120 @@ +--- +title: "Generate and translate copy" +sidebarTitle: "Generate & translate copy" +description: "Create an AI agent workflow that generates and translates copy" +--- + +## Overview + +**Prompt chaining** is an AI workflow pattern that decomposes a complex task into a sequence of steps, where each LLM call processes the output of the previous one. This approach trades off latency for higher accuracy by making each LLM call an easier, more focused task, with the ability to add programmatic checks between steps to ensure the process remains on track. + +![Generating and translating copy](/guides/ai-agents/prompt-chaining.png) + +## Example task + +In this example, we'll create a workflow that generates and translates copy. This approach is particularly effective when tasks require different models or approaches for different inputs. + +**This task:** + +- Uses `generateText` from [Vercel's AI SDK](https://sdk.vercel.ai/docs/introduction) to interact with OpenAI models +- Uses `experimental_telemetry` to provide LLM logs +- Generates marketing copy based on subject and target word count +- Validates the generated copy meets word count requirements (±10 words) +- Translates the validated copy to the target language while preserving tone + +```typescript +import { openai } from "@ai-sdk/openai"; +import { task } from "@trigger.dev/sdk/v3"; +import { generateText } from "ai"; + +export interface TranslatePayload { + marketingSubject: string; + targetLanguage: string; + targetWordCount: number; +} + +export const generateAndTranslateTask = task({ + id: "generate-and-translate-copy", + maxDuration: 300, // Stop executing after 5 mins of compute + run: async (payload: TranslatePayload) => { + // Step 1: Generate marketing copy + const generatedCopy = await generateText({ + model: openai("o1-mini"), + messages: [ + { + role: "system", + content: "You are an expert copywriter.", + }, + { + role: "user", + content: `Generate as close as possible to ${payload.targetWordCount} words of compelling marketing copy for ${payload.marketingSubject}`, + }, + ], + experimental_telemetry: { + isEnabled: true, + functionId: "generate-and-translate-copy", + }, + }); + + // Gate: Validate the generated copy meets the word count target + const wordCount = generatedCopy.text.split(/\s+/).length; + + if ( + wordCount < payload.targetWordCount - 10 || + wordCount > payload.targetWordCount + 10 + ) { + throw new Error( + `Generated copy length (${wordCount} words) is outside acceptable range of ${ + payload.targetWordCount - 10 + }-${payload.targetWordCount + 10} words` + ); + } + + // Step 2: Translate to target language + const translatedCopy = await generateText({ + model: openai("o1-mini"), + messages: [ + { + role: "system", + content: `You are an expert translator specializing in marketing content translation into ${payload.targetLanguage}.`, + }, + { + role: "user", + content: `Translate the following marketing copy to ${payload.targetLanguage}, maintaining the same tone and marketing impact:\n\n${generatedCopy}`, + }, + ], + experimental_telemetry: { + isEnabled: true, + functionId: "generate-and-translate-copy", + }, + }); + + return { + englishCopy: generatedCopy, + translatedCopy, + }; + }, +}); +``` + +## Run a test + +On the Test page in the dashboard, select the `generate-and-translate-copy` task and include a payload like the following: + +```json +{ + marketingSubject: "The controversial new Jaguar electric concept car", + targetLanguage: "Spanish", + targetWordCount: 100, +} +``` + +This example payload generates copy and then translates it using sequential LLM calls. The translation only begins after the generated copy has been validated against the word count requirements. + +