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/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/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 000000000..4ec95643c Binary files /dev/null and b/docs/guides/ai-agents/evaluator-optimizer.png differ 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. + +