From 1fd26ff98b94bc48f6fa98433282216f8c8e60fb Mon Sep 17 00:00:00 2001 From: Ramadan Omar <55840806+ramadanomar@users.noreply.github.com> Date: Wed, 8 May 2024 19:22:59 +0300 Subject: [PATCH 01/61] Improvement: Added more context to Shopify webhook registering errors (#1090) * Improvement: Added more context to Shopify webhook registering errors Co-authored-by: Ahmed Ramadan * revert: shopify reference jobs * fix: added error handling to some shopify client edge cases * small changes to crud error handling --------- Co-authored-by: Ahmed Ramadan Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- .changeset/blue-pumas-whisper.md | 5 ++ integrations/shopify/src/index.ts | 24 ++++++++- integrations/shopify/src/webhooks.ts | 81 +++++++++++++++++++--------- 3 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 .changeset/blue-pumas-whisper.md diff --git a/.changeset/blue-pumas-whisper.md b/.changeset/blue-pumas-whisper.md new file mode 100644 index 000000000..9bfa259b8 --- /dev/null +++ b/.changeset/blue-pumas-whisper.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/shopify": patch +--- + +improved error messages when a shopify webhook fails to register diff --git a/integrations/shopify/src/index.ts b/integrations/shopify/src/index.ts index 2bc03a14c..882f866e6 100644 --- a/integrations/shopify/src/index.ts +++ b/integrations/shopify/src/index.ts @@ -63,8 +63,30 @@ export class Shopify implements TriggerIntegration { throw `Can't create Shopify integration (${options.id}) as apiKey was undefined`; } + if (Object.keys(options).includes("apiSecretKey") && !options.apiSecretKey) { + throw `Can't create Shopify integration (${options.id}) as apiSecretKey was undefined`; + } + + if (Object.keys(options).includes("adminAccessToken") && !options.adminAccessToken) { + throw `Can't create Shopify integration (${options.id}) as adminAccessToken was undefined`; + } + + if (Object.keys(options).includes("hostName") && !options.hostName) { + throw `Can't create Shopify integration (${options.id}) as hostName was undefined`; + } + this._options = options; - this._shopDomain = this._options.hostName.replace("http://", "").replace("https://", ""); + // Extract the shop domain if user has entered the full URL + this._shopDomain = this._options.hostName + .replace(/^https?:\/\//, "") // Remove http:// or https:// + .replace(/\/$/, ""); // Remove trailing slash if it exists (e.g. `example.myshopify.com/`) + + // Regular expression to ensure the shopDomain is a valid `.myshopify.com` domain + const shopifyDomainPattern = /^[a-zA-Z0-9-]+\.myshopify\.com$/; + + if (!shopifyDomainPattern.test(this._shopDomain)) { + throw `Can't create Shopify integration (${options.id}) because hostName should be a valid ".myshopify.com" domain, not a custom primary domain. For example: my-domain.myshopify.com`; + } } get authSource() { diff --git a/integrations/shopify/src/webhooks.ts b/integrations/shopify/src/webhooks.ts index c39bcd8fa..8bcc8b0de 100644 --- a/integrations/shopify/src/webhooks.ts +++ b/integrations/shopify/src/webhooks.ts @@ -86,21 +86,36 @@ export function createWebhookEventSource(integration: Shopify) { key: (params) => params.topic, crud: { create: async ({ io, ctx }) => { - const webhook = await io.integration.rest.Webhook.save("create-webhook", { - fromData: { - address: ctx.url, - topic: ctx.params.topic, - // fields: ctx.params.fields, - }, - }); + try { + const webhook = await io.integration.rest.Webhook.save("create-webhook", { + fromData: { + address: ctx.url, + topic: ctx.params.topic, + // fields: ctx.params.fields, + }, + }); - const clientSecret = await io.integration.runTask( - "get-client-secret", - async (client) => client.config.apiSecretKey - ); + if (!webhook.id) { + throw new Error( + "Failed to create webhook. Ensure your Shopfiy client configuration is correct. Have you set the correct access scopes? Are you using the primary myshopify.com domain?" + ); + } - await io.store.job.set("set-id", "webhook-id", webhook.id); - await io.store.job.set("set-secret", "webhook-secret", clientSecret); + const clientSecret = await io.integration.runTask( + "get-client-secret", + async (client) => client.config.apiSecretKey + ); + + await io.store.job.set("set-id", "webhook-id", webhook.id); + await io.store.job.set("set-secret", "webhook-secret", clientSecret); + } catch (error) { + if (error instanceof Error) { + await io.logger.error(`Failed to create webhook: ${error.message}`); + } else { + await io.logger.error("Failed to create webhook", { rawError: error }); + } + throw error; + } }, delete: async ({ io, ctx }) => { const webhookId = await io.store.job.get("get-webhook-id", "webhook-id"); @@ -109,23 +124,41 @@ export function createWebhookEventSource(integration: Shopify) { throw new Error("Missing webhook ID for delete operation."); } - await io.integration.rest.Webhook.delete("delete-webhook", { - id: webhookId, - }); + try { + await io.integration.rest.Webhook.delete("delete-webhook", { + id: webhookId, + }); + } catch (error) { + if (error instanceof Error) { + await io.logger.error(`Failed to delete webhook: ${error.message}`); + } else { + await io.logger.error("Failed to delete webhook", { rawError: error }); + } + throw error; + } await io.store.job.delete("delete-webhook-id", "webhook-id"); }, update: async ({ io, ctx }) => { const webhookId = await io.store.job.get("get-webhook-id", "webhook-id"); - await io.integration.rest.Webhook.save("update-webhook", { - fromData: { - id: webhookId, - address: ctx.url, - topic: ctx.params.topic, - // fields: ctx.params.fields, - }, - }); + try { + await io.integration.rest.Webhook.save("update-webhook", { + fromData: { + id: webhookId, + address: ctx.url, + topic: ctx.params.topic, + // fields: ctx.params.fields, + }, + }); + } catch (error) { + if (error instanceof Error) { + await io.logger.error(`Failed to update webhook: ${error.message}`); + } else { + await io.logger.error("Failed to update webhook", { rawError: error }); + } + throw error; + } }, }, verify: async ({ request, client, ctx }) => { From 223768a025628abe59b8d644a26a9f35e98724f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 May 2024 17:25:22 +0100 Subject: [PATCH 02/61] chore: Update version for release (beta) (#1091) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 1 + integrations/airtable/CHANGELOG.md | 7 +++++++ integrations/airtable/package.json | 6 +++--- integrations/github/CHANGELOG.md | 7 +++++++ integrations/github/package.json | 6 +++--- integrations/linear/CHANGELOG.md | 7 +++++++ integrations/linear/package.json | 6 +++--- integrations/openai/CHANGELOG.md | 7 +++++++ integrations/openai/package.json | 6 +++--- integrations/plain/CHANGELOG.md | 7 +++++++ integrations/plain/package.json | 6 +++--- integrations/replicate/CHANGELOG.md | 7 +++++++ integrations/replicate/package.json | 6 +++--- integrations/resend/CHANGELOG.md | 7 +++++++ integrations/resend/package.json | 6 +++--- integrations/sendgrid/CHANGELOG.md | 7 +++++++ integrations/sendgrid/package.json | 6 +++--- integrations/shopify/CHANGELOG.md | 8 ++++++++ integrations/shopify/package.json | 6 +++--- integrations/slack/CHANGELOG.md | 6 ++++++ integrations/slack/package.json | 4 ++-- integrations/stripe/CHANGELOG.md | 7 +++++++ integrations/stripe/package.json | 6 +++--- integrations/supabase/CHANGELOG.md | 7 +++++++ integrations/supabase/package.json | 6 +++--- integrations/typeform/CHANGELOG.md | 7 +++++++ integrations/typeform/package.json | 6 +++--- packages/astro/CHANGELOG.md | 6 ++++++ packages/astro/package.json | 4 ++-- packages/cli-v3/CHANGELOG.md | 6 ++++++ packages/cli-v3/package.json | 4 ++-- packages/cli/CHANGELOG.md | 7 +++++++ packages/cli/package.json | 2 +- packages/core-apps/CHANGELOG.md | 2 ++ packages/core-apps/package.json | 2 +- packages/core-backend/CHANGELOG.md | 2 ++ packages/core-backend/package.json | 2 +- packages/core/CHANGELOG.md | 2 ++ packages/core/package.json | 2 +- packages/eslint-plugin/CHANGELOG.md | 2 ++ packages/eslint-plugin/package.json | 2 +- packages/express/CHANGELOG.md | 6 ++++++ packages/express/package.json | 6 +++--- packages/hono/CHANGELOG.md | 6 ++++++ packages/hono/package.json | 4 ++-- packages/integration-kit/CHANGELOG.md | 6 ++++++ packages/integration-kit/package.json | 2 +- packages/nestjs/CHANGELOG.md | 6 ++++++ packages/nestjs/package.json | 6 +++--- packages/nextjs/CHANGELOG.md | 6 ++++++ packages/nextjs/package.json | 4 ++-- packages/otlp-importer/CHANGELOG.md | 2 ++ packages/otlp-importer/package.json | 2 +- packages/react/CHANGELOG.md | 6 ++++++ packages/react/package.json | 4 ++-- packages/remix/CHANGELOG.md | 6 ++++++ packages/remix/package.json | 4 ++-- packages/sveltekit/CHANGELOG.md | 6 ++++++ packages/sveltekit/package.json | 4 ++-- packages/testing/CHANGELOG.md | 7 +++++++ packages/testing/package.json | 2 +- packages/trigger-sdk/CHANGELOG.md | 7 +++++++ packages/trigger-sdk/package.json | 6 +++--- packages/yalt/CHANGELOG.md | 2 ++ packages/yalt/package.json | 2 +- 65 files changed, 255 insertions(+), 70 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 8310ccd9b..89b996c06 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -47,6 +47,7 @@ "angry-eagles-trade", "beige-pens-dance", "big-tomatoes-deliver", + "blue-pumas-whisper", "breezy-gorillas-mate", "chilled-hornets-move", "clean-pianos-listen", diff --git a/integrations/airtable/CHANGELOG.md b/integrations/airtable/CHANGELOG.md index 0664db0a8..7d5e9d46b 100644 --- a/integrations/airtable/CHANGELOG.md +++ b/integrations/airtable/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/airtable +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/airtable/package.json b/integrations/airtable/package.json index d08437bb8..f7e7a8604 100644 --- a/integrations/airtable/package.json +++ b/integrations/airtable/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/airtable", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for airtable", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,8 +25,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "airtable": "^0.12.1", "zod": "3.22.3" }, diff --git a/integrations/github/CHANGELOG.md b/integrations/github/CHANGELOG.md index a042a8520..c37005dec 100644 --- a/integrations/github/CHANGELOG.md +++ b/integrations/github/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/github +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/github/package.json b/integrations/github/package.json index 0ed5fb486..7a7515c37 100644 --- a/integrations/github/package.json +++ b/integrations/github/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/github", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The official GitHub integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -30,8 +30,8 @@ "@octokit/request-error": "^5.0.1", "@octokit/webhooks": "^12.0.10", "octokit": "^3.1.2", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "zod": "3.22.3" }, "engines": { diff --git a/integrations/linear/CHANGELOG.md b/integrations/linear/CHANGELOG.md index 373cb2aa9..346138bfd 100644 --- a/integrations/linear/CHANGELOG.md +++ b/integrations/linear/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/linear +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/linear/package.json b/integrations/linear/package.json index ea9703c26..5c02e1f07 100644 --- a/integrations/linear/package.json +++ b/integrations/linear/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/linear", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for @linear/sdk", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@linear/sdk": "^8.0.0", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "zod": "3.22.3" }, "engines": { diff --git a/integrations/openai/CHANGELOG.md b/integrations/openai/CHANGELOG.md index b1abb6d3a..41483105a 100644 --- a/integrations/openai/CHANGELOG.md +++ b/integrations/openai/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/slack +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/openai/package.json b/integrations/openai/package.json index 1b994ab1c..75b57d12a 100644 --- a/integrations/openai/package.json +++ b/integrations/openai/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/openai", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The official OpenAI integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,8 +42,8 @@ }, "dependencies": { "openai": "^4.16.1", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29" }, "engines": { "node": ">=18.0.0" diff --git a/integrations/plain/CHANGELOG.md b/integrations/plain/CHANGELOG.md index 1c85b8c23..456bcf110 100644 --- a/integrations/plain/CHANGELOG.md +++ b/integrations/plain/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/plain +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/plain/package.json b/integrations/plain/package.json index d369f1b5f..497e79216 100644 --- a/integrations/plain/package.json +++ b/integrations/plain/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/plain", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The official Plain.com integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,8 +24,8 @@ "build:tsup": "tsup" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "@team-plain/typescript-sdk": "^2.7.0" }, "engines": { diff --git a/integrations/replicate/CHANGELOG.md b/integrations/replicate/CHANGELOG.md index 9b59ec88b..36b715675 100644 --- a/integrations/replicate/CHANGELOG.md +++ b/integrations/replicate/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/replicate +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/replicate/package.json b/integrations/replicate/package.json index d3d99c413..6a2ccd574 100644 --- a/integrations/replicate/package.json +++ b/integrations/replicate/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/replicate", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for replicate", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,8 +25,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "replicate": "^0.18.1", "zod": "3.22.3" }, diff --git a/integrations/resend/CHANGELOG.md b/integrations/resend/CHANGELOG.md index c60aa7934..073953141 100644 --- a/integrations/resend/CHANGELOG.md +++ b/integrations/resend/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/resend +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/resend/package.json b/integrations/resend/package.json index 52565b96b..030982587 100644 --- a/integrations/resend/package.json +++ b/integrations/resend/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/resend", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The official Resend.com integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,8 +24,8 @@ "build:tsup": "tsup" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "resend": "^2.1.0" }, "engines": { diff --git a/integrations/sendgrid/CHANGELOG.md b/integrations/sendgrid/CHANGELOG.md index 077668bab..51a93dd59 100644 --- a/integrations/sendgrid/CHANGELOG.md +++ b/integrations/sendgrid/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sendgrid +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/sendgrid/package.json b/integrations/sendgrid/package.json index fe275b146..03ed4d322 100644 --- a/integrations/sendgrid/package.json +++ b/integrations/sendgrid/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sendgrid", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for @sendgrid/mail", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@sendgrid/mail": "^7.7.0", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29" }, "engines": { "node": ">=16.8.0" diff --git a/integrations/shopify/CHANGELOG.md b/integrations/shopify/CHANGELOG.md index 173934bdf..f6ace0f2a 100644 --- a/integrations/shopify/CHANGELOG.md +++ b/integrations/shopify/CHANGELOG.md @@ -1,5 +1,13 @@ # @trigger.dev/shopify +## 3.0.0-beta.29 + +### Patch Changes + +- 1fd26ff98: improved error messages when a shopify webhook fails to register + - @trigger.dev/integration-kit@3.0.0-beta.29 + - @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/shopify/package.json b/integrations/shopify/package.json index 4f3d602a0..69a2b9243 100644 --- a/integrations/shopify/package.json +++ b/integrations/shopify/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/shopify", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for @shopify/shopify-api", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@shopify/shopify-api": "^8.0.2", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", "zod": "3.22.3" }, "engines": { diff --git a/integrations/slack/CHANGELOG.md b/integrations/slack/CHANGELOG.md index 689b570e8..f52b230f1 100644 --- a/integrations/slack/CHANGELOG.md +++ b/integrations/slack/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/slack +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/slack/package.json b/integrations/slack/package.json index 63e1e20d4..2f6c113c7 100644 --- a/integrations/slack/package.json +++ b/integrations/slack/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/slack", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The official Slack integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,7 +25,7 @@ }, "dependencies": { "@slack/web-api": "^6.8.1", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "zod": "3.22.3" }, "engines": { diff --git a/integrations/stripe/CHANGELOG.md b/integrations/stripe/CHANGELOG.md index 0b42c1574..44f3f12cd 100644 --- a/integrations/stripe/CHANGELOG.md +++ b/integrations/stripe/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/stripe +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/stripe/package.json b/integrations/stripe/package.json index 9deff6025..f9756e7ff 100644 --- a/integrations/stripe/package.json +++ b/integrations/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/stripe", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for stripe", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,8 +25,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "stripe": "^12.14.0", "zod": "3.22.3" }, diff --git a/integrations/supabase/CHANGELOG.md b/integrations/supabase/CHANGELOG.md index 69c829e4d..5c528d2ae 100644 --- a/integrations/supabase/CHANGELOG.md +++ b/integrations/supabase/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/supabase +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/supabase/package.json b/integrations/supabase/package.json index abcd44520..e9cc25395 100644 --- a/integrations/supabase/package.json +++ b/integrations/supabase/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/supabase", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev integration for @supabase/supabase-js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@supabase/supabase-js": "^2.26.0", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "supabase-management-js": "^1.0.0", "zod": "3.22.3" }, diff --git a/integrations/typeform/CHANGELOG.md b/integrations/typeform/CHANGELOG.md index 6bb3651a0..0f5878ae0 100644 --- a/integrations/typeform/CHANGELOG.md +++ b/integrations/typeform/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/typeform +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/integrations/typeform/package.json b/integrations/typeform/package.json index 95958ed41..63f093207 100644 --- a/integrations/typeform/package.json +++ b/integrations/typeform/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/typeform", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The official Typeform integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,8 +24,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.28", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "@typeform/api-client": "^1.8.0", "zod": "3.22.3" }, diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index 2108db0d6..43ceefba8 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/astro +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/astro/package.json b/packages/astro/package.json index 64a953223..c4907f309 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,7 +1,7 @@ { "name": "@trigger.dev/astro", "description": "An Astro-native integration for Trigger.dev background jobs platform", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ @@ -20,7 +20,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29" }, "devDependencies": { "astro": "^3.0.12", diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index 751047403..2ac485c09 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,11 @@ # trigger.dev +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/core@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index a722e070b..f89aef685 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "A Command-Line Interface for Trigger.dev (v3) projects", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -86,7 +86,7 @@ "@opentelemetry/sdk-trace-base": "^1.22.0", "@opentelemetry/sdk-trace-node": "^1.22.0", "@opentelemetry/semantic-conventions": "^1.22.0", - "@trigger.dev/core": "workspace:3.0.0-beta.28", + "@trigger.dev/core": "workspace:3.0.0-beta.29", "@types/degit": "^2.8.3", "chalk": "^5.2.0", "chokidar": "^3.5.3", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index f73222152..13dc69af6 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # create-trigger +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/core@3.0.0-beta.29 +- @trigger.dev/yalt@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index d75a58408..b18571ba1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/cli", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "The Trigger.dev CLI", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/core-apps/CHANGELOG.md b/packages/core-apps/CHANGELOG.md index 67724bb44..f09d465a0 100644 --- a/packages/core-apps/CHANGELOG.md +++ b/packages/core-apps/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/core-apps +## 3.0.0-beta.29 + ## 3.0.0-beta.28 ## 3.0.0-beta.27 diff --git a/packages/core-apps/package.json b/packages/core-apps/package.json index 4be4b1f94..e12db65c6 100644 --- a/packages/core-apps/package.json +++ b/packages/core-apps/package.json @@ -1,7 +1,7 @@ { "name": "@trigger.dev/core-apps", "description": "Backend core code used across apps", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "private": true, "license": "MIT", "main": "./dist/index.js", diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index fe178deec..9b0c316d2 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/core-backend +## 3.0.0-beta.29 + ## 3.0.0-beta.28 ## 3.0.0-beta.27 diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index 70f2b4785..f0ca50645 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core-backend", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Core code used across `@trigger.dev/sdk` and Trigger.dev server", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 0cdd7ebd0..9b9544dcd 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # internal-platform +## 3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 5186c211d..a2ac068fc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index 0c8281a43..ff9c87fbb 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/eslint-plugin +## 3.0.0-beta.29 + ## 3.0.0-beta.28 ## 3.0.0-beta.27 diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 3a3d6b2c0..99af35a9c 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/eslint-plugin", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "ESLint plugin with trigger.dev best practices", "keywords": [ "eslint", diff --git a/packages/express/CHANGELOG.md b/packages/express/CHANGELOG.md index aafb1b1f7..32d8b0291 100644 --- a/packages/express/CHANGELOG.md +++ b/packages/express/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/express +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/express/package.json b/packages/express/package.json index 20bbeb873..61f59eea0 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/express", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Official Express adapter for Trigger.dev", "license": "MIT", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "./package.json": "./package.json" }, "devDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "@trigger.dev/tsconfig": "workspace:*", "@types/debug": "^4.1.7", "@types/express": "^4.17.13", @@ -39,7 +39,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29" }, "dependencies": { "debug": "^4.3.4", diff --git a/packages/hono/CHANGELOG.md b/packages/hono/CHANGELOG.md index da3b93a90..ebe4adfed 100644 --- a/packages/hono/CHANGELOG.md +++ b/packages/hono/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/hono +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/hono/package.json b/packages/hono/package.json index be35cad3f..7feb00acc 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/hono", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "A Trigger.dev adapter for Hono.dev", "license": "MIT", "main": "./dist/index.js", @@ -32,7 +32,7 @@ }, "peerDependencies": { "hono": "3.x", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29" }, "devDependencies": { "@trigger.dev/tsconfig": "workspace:*", diff --git a/packages/integration-kit/CHANGELOG.md b/packages/integration-kit/CHANGELOG.md index c85afcaa7..73499cacb 100644 --- a/packages/integration-kit/CHANGELOG.md +++ b/packages/integration-kit/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/integration-kit +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/core@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/integration-kit/package.json b/packages/integration-kit/package.json index e36accbaf..bb99b08bd 100644 --- a/packages/integration-kit/package.json +++ b/packages/integration-kit/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/integration-kit", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev Integration Kit has helpers to make creating integrations easier", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/nestjs/CHANGELOG.md b/packages/nestjs/CHANGELOG.md index fe5d63fdc..bb4f01781 100644 --- a/packages/nestjs/CHANGELOG.md +++ b/packages/nestjs/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/nestjs +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 00d41fa2e..3ff5cdf8d 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/nestjs", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Official NestJS adapter for Trigger.dev", "license": "MIT", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "./package.json": "./package.json" }, "devDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "@trigger.dev/tsconfig": "workspace:*", "@types/debug": "^4.1.7", "@types/express": "^4.17.13", @@ -41,7 +41,7 @@ }, "peerDependencies": { "@nestjs/common": ">=10.0.0", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29" }, "dependencies": { "@nestjs/common": "^10.2.4", diff --git a/packages/nextjs/CHANGELOG.md b/packages/nextjs/CHANGELOG.md index 00779e18e..de469021e 100644 --- a/packages/nextjs/CHANGELOG.md +++ b/packages/nextjs/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/nextjs +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 976248517..7193eee19 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/nextjs", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev Next.js integration", "license": "MIT", "main": "./dist/index.js", @@ -41,7 +41,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "next": ">=12.0.0" }, "dependencies": { diff --git a/packages/otlp-importer/CHANGELOG.md b/packages/otlp-importer/CHANGELOG.md index 02b45e3e4..600956e27 100644 --- a/packages/otlp-importer/CHANGELOG.md +++ b/packages/otlp-importer/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/otlp-importer +## 3.0.0-beta.29 + ## 3.0.0-beta.28 ## 3.0.0-beta.27 diff --git a/packages/otlp-importer/package.json b/packages/otlp-importer/package.json index ee9e579ee..393970a75 100644 --- a/packages/otlp-importer/package.json +++ b/packages/otlp-importer/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/otlp-importer", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "OpenTelemetry OTLP Importer for Node.js written in TypeScript", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index cf21ce46a..7a959aec0 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/react +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/core@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 2dde2887c..18183d875 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev React SDK", "license": "MIT", "types": "dist/index.d.ts", @@ -27,7 +27,7 @@ }, "dependencies": { "@tanstack/react-query": "5.0.0-beta.2", - "@trigger.dev/core": "workspace:^3.0.0-beta.28", + "@trigger.dev/core": "workspace:^3.0.0-beta.29", "debug": "^4.3.4", "zod": "3.22.3" }, diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index a0bad19d3..a3c9c84b6 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/remix +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/remix/package.json b/packages/remix/package.json index ca53c812e..800f36ffd 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/remix", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev Remix integration", "license": "MIT", "main": "./dist/index.js", @@ -39,7 +39,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29", "@remix-run/server-runtime": ">1.19.0" }, "dependencies": { diff --git a/packages/sveltekit/CHANGELOG.md b/packages/sveltekit/CHANGELOG.md index 47fe9ed12..2e013b198 100644 --- a/packages/sveltekit/CHANGELOG.md +++ b/packages/sveltekit/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/sveltekit +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 42c1c5176..439b92c71 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sveltekit", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "Trigger.dev svelteKit integration", "license": "MIT", "main": "./dist/index.js", @@ -39,7 +39,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.28" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.29" }, "dependencies": { "debug": "^4.3.4" diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md index 7c6961d3e..1bd88fe54 100644 --- a/packages/testing/CHANGELOG.md +++ b/packages/testing/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/testing +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/core@3.0.0-beta.29 +- @trigger.dev/sdk@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/testing/package.json b/packages/testing/package.json index 6ecdabab1..63fff51ff 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,7 +1,7 @@ { "name": "@trigger.dev/testing", "description": "A collection of useful tools to write tests for Trigger.dev.", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index a892393c3..e371f49b6 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sdk +## 3.0.0-beta.29 + +### Patch Changes + +- @trigger.dev/core@3.0.0-beta.29 +- @trigger.dev/core-backend@3.0.0-beta.29 + ## 3.0.0-beta.28 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 71447f194..8f2e6994d 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "trigger.dev Node.JS SDK", "license": "MIT", "main": "./dist/index.js", @@ -48,8 +48,8 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/api-logs": "^0.48.0", "@opentelemetry/semantic-conventions": "^1.22.0", - "@trigger.dev/core": "workspace:3.0.0-beta.28", - "@trigger.dev/core-backend": "workspace:3.0.0-beta.28", + "@trigger.dev/core": "workspace:3.0.0-beta.29", + "@trigger.dev/core-backend": "workspace:3.0.0-beta.29", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/packages/yalt/CHANGELOG.md b/packages/yalt/CHANGELOG.md index 8513bc1d3..221c97697 100644 --- a/packages/yalt/CHANGELOG.md +++ b/packages/yalt/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/yalt +## 3.0.0-beta.29 + ## 3.0.0-beta.28 ## 3.0.0-beta.27 diff --git a/packages/yalt/package.json b/packages/yalt/package.json index 4a1599e5e..701082be4 100644 --- a/packages/yalt/package.json +++ b/packages/yalt/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/yalt", - "version": "3.0.0-beta.28", + "version": "3.0.0-beta.29", "description": "yalt.dev client library", "license": "MIT", "main": "./dist/index.js", From cfe4b5409a4d1af9c86a66df8e19f5e147865e38 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 8 May 2024 17:27:34 +0100 Subject: [PATCH 03/61] Fix lockfile --- pnpm-lock.yaml | 76 +++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25c73dba4..f7ce3b246 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -407,7 +407,7 @@ importers: version: 8.6.6 '@uiw/react-codemirror': specifier: ^4.19.5 - version: 4.19.5(@babel/runtime@7.22.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.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) '@upstash/ratelimit': specifier: ^1.0.1 version: 1.0.1 @@ -857,10 +857,10 @@ importers: integrations/airtable: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk airtable: specifier: ^0.12.1 @@ -900,10 +900,10 @@ importers: specifier: ^12.0.10 version: 12.0.10 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk octokit: specifier: ^3.1.2 @@ -943,10 +943,10 @@ importers: specifier: ^8.0.0 version: 8.0.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk zod: specifier: 3.22.3 @@ -974,10 +974,10 @@ importers: integrations/openai: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk openai: specifier: ^4.16.1 @@ -1017,10 +1017,10 @@ importers: specifier: ^2.7.0 version: 2.7.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk devDependencies: '@trigger.dev/tsconfig': @@ -1045,10 +1045,10 @@ importers: integrations/replicate: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk replicate: specifier: ^0.18.1 @@ -1079,10 +1079,10 @@ importers: integrations/resend: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk resend: specifier: ^2.1.0 @@ -1113,10 +1113,10 @@ importers: specifier: ^7.7.0 version: 7.7.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk devDependencies: '@trigger.dev/tsconfig': @@ -1144,10 +1144,10 @@ importers: specifier: ^8.0.2 version: 8.0.2 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk zod: specifier: 3.22.3 @@ -1178,7 +1178,7 @@ importers: specifier: ^6.8.1 version: 6.8.1 '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk zod: specifier: 3.22.3 @@ -1206,10 +1206,10 @@ importers: integrations/stripe: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk stripe: specifier: ^12.14.0 @@ -1243,10 +1243,10 @@ importers: specifier: ^2.26.0 version: 2.31.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk supabase-management-js: specifier: ^1.0.0 @@ -1277,10 +1277,10 @@ importers: integrations/typeform: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../../packages/trigger-sdk '@typeform/api-client': specifier: ^1.8.0 @@ -1308,7 +1308,7 @@ importers: packages/astro: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -1526,7 +1526,7 @@ importers: specifier: ^1.22.0 version: 1.22.0 '@trigger.dev/core': - specifier: workspace:3.0.0-beta.28 + specifier: workspace:3.0.0-beta.29 version: link:../core '@types/degit': specifier: ^2.8.3 @@ -1968,7 +1968,7 @@ importers: version: 4.18.2 devDependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../trigger-sdk '@trigger.dev/tsconfig': specifier: workspace:* @@ -2063,7 +2063,7 @@ importers: version: 4.3.4(supports-color@8.1.1) devDependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../trigger-sdk '@trigger.dev/tsconfig': specifier: workspace:* @@ -2096,7 +2096,7 @@ importers: packages/nextjs: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -2182,7 +2182,7 @@ importers: specifier: 5.0.0-beta.2 version: 5.0.0-beta.2(react@18.2.0) '@trigger.dev/core': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../core debug: specifier: ^4.3.4 @@ -2225,7 +2225,7 @@ importers: packages/remix: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -2262,7 +2262,7 @@ importers: packages/sveltekit: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.28 + specifier: workspace:^3.0.0-beta.29 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -2336,10 +2336,10 @@ importers: specifier: ^1.22.0 version: 1.22.0 '@trigger.dev/core': - specifier: workspace:3.0.0-beta.28 + specifier: workspace:3.0.0-beta.29 version: link:../core '@trigger.dev/core-backend': - specifier: workspace:3.0.0-beta.28 + specifier: workspace:3.0.0-beta.29 version: link:../core-backend chalk: specifier: ^5.2.0 @@ -15356,7 +15356,7 @@ packages: '@codemirror/view': 6.7.2 dev: false - /@uiw/react-codemirror@4.19.5(@babel/runtime@7.22.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.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): resolution: {integrity: sha512-ZCHh8d7beXbF8/t7F1+yHht6A9Y6CdKeOkZq4A09lxJEnyTQrj1FMf2zvfaqc7K23KNjkTCtSlbqKKbVDgrWaw==} peerDependencies: '@babel/runtime': '>=7.11.0' @@ -15367,7 +15367,7 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.24.5 '@codemirror/commands': 6.1.3 '@codemirror/state': 6.2.0 '@codemirror/theme-one-dark': 6.1.0 From 23062176971383dfd09e8dfbedab1a592a11a355 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 9 May 2024 10:16:00 +0100 Subject: [PATCH 04/61] v3: automatic pod cleanup (#1092) * add automatic pod cleaner * fix task monitor namespace override --- apps/kubernetes-provider/src/index.ts | 9 + apps/kubernetes-provider/src/podCleaner.ts | 264 ++++++++++++++++++++ apps/kubernetes-provider/src/taskMonitor.ts | 6 + 3 files changed, 279 insertions(+) create mode 100644 apps/kubernetes-provider/src/podCleaner.ts diff --git a/apps/kubernetes-provider/src/index.ts b/apps/kubernetes-provider/src/index.ts index b6a8574a4..8981c85d9 100644 --- a/apps/kubernetes-provider/src/index.ts +++ b/apps/kubernetes-provider/src/index.ts @@ -10,6 +10,7 @@ import { import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3"; import { randomUUID } from "crypto"; import { TaskMonitor } from "./taskMonitor"; +import { PodCleaner } from "./podCleaner"; const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local"; const NODE_NAME = process.env.NODE_NAME || "local"; @@ -543,3 +544,11 @@ const taskMonitor = new TaskMonitor({ }); taskMonitor.start(); + +const podCleaner = new PodCleaner({ + runtimeEnv: RUNTIME_ENV, + namespace: "default", + intervalInSeconds: 300, +}); + +podCleaner.start(); diff --git a/apps/kubernetes-provider/src/podCleaner.ts b/apps/kubernetes-provider/src/podCleaner.ts new file mode 100644 index 000000000..29955bab3 --- /dev/null +++ b/apps/kubernetes-provider/src/podCleaner.ts @@ -0,0 +1,264 @@ +import * as k8s from "@kubernetes/client-node"; +import { SimpleLogger } from "@trigger.dev/core-apps"; + +type PodCleanerOptions = { + runtimeEnv: "local" | "kubernetes"; + namespace?: string; + intervalInSeconds?: number; +}; + +export class PodCleaner { + private enabled = false; + private namespace = "default"; + private intervalInSeconds = 300; + + private logger = new SimpleLogger("[PodCleaner]"); + private k8sClient: { + core: k8s.CoreV1Api; + kubeConfig: k8s.KubeConfig; + }; + + constructor(private opts: PodCleanerOptions) { + if (opts.namespace) { + this.namespace = opts.namespace; + } + + if (opts.intervalInSeconds) { + this.intervalInSeconds = opts.intervalInSeconds; + } + + this.k8sClient = this.#createK8sClient(); + } + + #createK8sClient() { + const kubeConfig = new k8s.KubeConfig(); + + if (this.opts.runtimeEnv === "local") { + kubeConfig.loadFromDefault(); + } else if (this.opts.runtimeEnv === "kubernetes") { + kubeConfig.loadFromCluster(); + } else { + throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`); + } + + return { + core: kubeConfig.makeApiClient(k8s.CoreV1Api), + kubeConfig: kubeConfig, + }; + } + + #isRecord(candidate: unknown): candidate is Record { + if (typeof candidate !== "object" || candidate === null) { + return false; + } else { + return true; + } + } + + #logK8sError(err: unknown, debugOnly = false) { + if (debugOnly) { + this.logger.debug("K8s API Error", err); + } else { + this.logger.error("K8s API Error", err); + } + } + + #handleK8sError(err: unknown) { + if (!this.#isRecord(err) || !this.#isRecord(err.body)) { + this.#logK8sError(err); + return; + } + + this.#logK8sError(err, true); + + if (typeof err.body.message === "string") { + this.#logK8sError({ message: err.body.message }); + return; + } + + this.#logK8sError({ body: err.body }); + } + + async #deletePods(opts: { + namespace: string; + dryRun?: boolean; + fieldSelector?: string; + labelSelector?: string; + }) { + return await this.k8sClient.core + .deleteCollectionNamespacedPod( + opts.namespace, + undefined, // pretty + undefined, // continue + opts.dryRun ? "All" : undefined, + opts.fieldSelector, + undefined, // gracePeriodSeconds + opts.labelSelector + ) + .catch(this.#handleK8sError.bind(this)); + } + + async #deleteCompletedRuns() { + this.logger.log("Deleting completed runs"); + + const start = Date.now(); + + const result = await this.#deletePods({ + namespace: this.namespace, + fieldSelector: "status.phase=Succeeded", + labelSelector: "app=task-run", + }); + + const elapsedMs = Date.now() - start; + + if (!result) { + this.logger.log("Deleting completed runs: No delete result", { elapsedMs }); + return; + } + + const total = (result.response as any)?.body?.items?.length ?? 0; + + this.logger.log("Deleting completed runs: Done", { total, elapsedMs }); + } + + async #deleteFailedRuns() { + this.logger.log("Deleting failed runs"); + + const start = Date.now(); + + const result = await this.#deletePods({ + namespace: this.namespace, + fieldSelector: "status.phase=Failed", + labelSelector: "app=task-run", + }); + + const elapsedMs = Date.now() - start; + + if (!result) { + this.logger.log("Deleting failed runs: No delete result", { elapsedMs }); + return; + } + + const total = (result.response as any)?.body?.items?.length ?? 0; + + this.logger.log("Deleting failed runs: Done", { total, elapsedMs }); + } + + async #deleteUnrecoverableRuns() { + await this.#deletePods({ + namespace: this.namespace, + fieldSelector: "status.phase=?", + labelSelector: "app=task-run", + }); + } + + async start() { + this.enabled = true; + this.logger.log("Starting"); + + const completedInterval = setInterval(async () => { + if (!this.enabled) { + clearInterval(completedInterval); + return; + } + + try { + await this.#deleteCompletedRuns(); + } catch (error) { + this.logger.error("Error deleting completed runs", error); + } + }, this.intervalInSeconds * 1000); + + const failedInterval = setInterval( + async () => { + if (!this.enabled) { + clearInterval(failedInterval); + return; + } + + try { + await this.#deleteFailedRuns(); + } catch (error) { + this.logger.error("Error deleting completed runs", error); + } + }, + // Use a longer interval for failed runs. This is only a backup in case the task monitor fails. + 2 * this.intervalInSeconds * 1000 + ); + + // this.#launchTests(); + } + + async stop() { + if (!this.enabled) { + return; + } + + this.enabled = false; + this.logger.log("Shutting down.."); + } + + async #launchTests() { + const createPod = async ( + container: k8s.V1Container, + name: string, + labels?: Record + ) => { + this.logger.log("Creating pod:", name); + + const pod = { + metadata: { + name, + labels, + }, + spec: { + restartPolicy: "Never", + automountServiceAccountToken: false, + terminationGracePeriodSeconds: 1, + containers: [container], + }, + } satisfies k8s.V1Pod; + + await this.k8sClient.core + .createNamespacedPod(this.namespace, pod) + .catch(this.#handleK8sError.bind(this)); + }; + + const createIdlePod = async (name: string, labels?: Record) => { + const container = { + name, + image: "docker.io/library/busybox", + command: ["sh"], + args: ["-c", "sleep infinity"], + } satisfies k8s.V1Container; + + await createPod(container, name, labels); + }; + + const createCompletedPod = async (name: string, labels?: Record) => { + const container = { + name, + image: "docker.io/library/busybox", + command: ["sh"], + args: ["-c", "true"], + } satisfies k8s.V1Container; + + await createPod(container, name, labels); + }; + + const createFailedPod = async (name: string, labels?: Record) => { + const container = { + name, + image: "docker.io/library/busybox", + command: ["sh"], + args: ["-c", "false"], + } satisfies k8s.V1Container; + + await createPod(container, name, labels); + }; + + await createIdlePod("test-idle-1", { app: "task-run" }); + await createFailedPod("test-failed-1", { app: "task-run" }); + await createCompletedPod("test-completed-1", { app: "task-run" }); + } +} diff --git a/apps/kubernetes-provider/src/taskMonitor.ts b/apps/kubernetes-provider/src/taskMonitor.ts index e07638ed2..c0296788d 100644 --- a/apps/kubernetes-provider/src/taskMonitor.ts +++ b/apps/kubernetes-provider/src/taskMonitor.ts @@ -30,10 +30,12 @@ type TaskMonitorOptions = { export class TaskMonitor { #enabled = false; + #logger = new SimpleLogger("[TaskMonitor]"); #taskInformer: ReturnType>; #processedPods = new Map(); #queue = new PQueue({ concurrency: 10 }); + #k8sClient: { core: k8s.CoreV1Api; kubeConfig: k8s.KubeConfig; @@ -44,6 +46,10 @@ export class TaskMonitor { private labelSelector = "app in (task-index, task-run)"; constructor(private opts: TaskMonitorOptions) { + if (opts.namespace) { + this.namespace = opts.namespace; + } + this.#k8sClient = this.#createK8sClient(); this.#taskInformer = this.#createTaskInformer(); From f90960fc138c329fd9e413d7b466d362cc663fad Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 9 May 2024 10:32:38 +0100 Subject: [PATCH 05/61] Better display when there are lots of dev environment (#1093) * Created an EnvironmentLabels component that puts extra dev environments behind a + * Added new environment labels components to schedules pages --- .../environments/EnvironmentLabel.tsx | 71 ++++++++++++++++++- .../EnvironmentsPresenter.server.ts | 2 +- .../HttpEndpointPresenter.server.ts | 2 +- .../app/presenters/ProjectPresenter.server.ts | 2 +- .../presenters/v3/ApiKeysPresenter.server.ts | 2 +- .../EnvironmentVariablesPresenter.server.ts | 2 +- .../presenters/v3/TaskListPresenter.server.ts | 2 +- .../app/presenters/v3/TestPresenter.server.ts | 2 +- .../route.tsx | 12 +--- .../route.tsx | 13 +--- .../route.tsx | 12 +--- .../environmentSort.ts} | 1 - 12 files changed, 83 insertions(+), 40 deletions(-) rename apps/webapp/app/{services/environmentSort.server.ts => utils/environmentSort.ts} (94%) diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index cc3852202..015feb4f6 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -1,5 +1,7 @@ -import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server"; +import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server"; import { cn } from "~/utils/cn"; +import { sortEnvironments } from "~/utils/environmentSort"; +import { SimpleTooltip } from "../primitives/Tooltip"; type Environment = Pick; const variants = { @@ -33,6 +35,73 @@ export function EnvironmentLabel({ ); } +type EnvironmentWithUsername = Environment & { userName?: string }; + +export function EnvironmentLabels({ + environments, + size = "small", + className, +}: { + environments: EnvironmentWithUsername[]; + size?: keyof typeof variants; + className?: string; +}) { + const devEnvironments = sortEnvironments( + environments.filter((env) => env.type === "DEVELOPMENT") + ); + const firstDevEnvironment = devEnvironments[0]; + const otherDevEnvironments = devEnvironments.slice(1); + const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT"); + + return ( +
+ {firstDevEnvironment && ( + + )} + {otherDevEnvironments.length > 0 ? ( + + +{otherDevEnvironments.length} + + } + content={ +
+ {otherDevEnvironments.map((environment, index) => ( + + ))} +
+ } + /> + ) : null} + {otherEnvironments.map((environment, index) => ( + + ))} +
+ ); +} + export function environmentTitle(environment: Environment, username?: string) { switch (environment.type) { case "PRODUCTION": diff --git a/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts b/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts index ff0600dcd..50a22425e 100644 --- a/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts +++ b/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts @@ -14,7 +14,7 @@ import { IndexEndpointStats, parseEndpointIndexStats, } from "@trigger.dev/core"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; export type Client = { slug: string; diff --git a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts index 886b4192d..fe791481e 100644 --- a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts +++ b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { PrismaClient, prisma } from "~/db.server"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { projectPath } from "~/utils/pathBuilder"; diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts index 7f61825e1..0ca954d8b 100644 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ b/apps/webapp/app/presenters/ProjectPresenter.server.ts @@ -2,7 +2,7 @@ import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { displayableEnvironments } from "~/models/runtimeEnvironment.server"; import { User } from "~/models/user.server"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; export class ProjectPresenter { #prismaClient: PrismaClient; diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index 406bb13b2..eeb7b9f2e 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -1,7 +1,7 @@ import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; export class ApiKeysPresenter { #prismaClient: PrismaClient; diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index cff76a397..bc200dc10 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,7 +1,7 @@ import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; type Result = Awaited>; diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index e7ac50573..c0a8935ba 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -10,7 +10,7 @@ import { Organization } from "~/models/organization.server"; import { Project } from "~/models/project.server"; import { displayableEnvironments } from "~/models/runtimeEnvironment.server"; import { User } from "~/models/user.server"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; import { logger } from "~/services/logger.server"; import { BasePresenter } from "./basePresenter.server"; diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index 130935b2b..cbe2e2456 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -1,7 +1,7 @@ import { TaskTriggerSource } from "@trigger.dev/database"; import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server"; import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route"; -import { sortEnvironments } from "~/services/environmentSort.server"; +import { sortEnvironments } from "~/utils/environmentSort"; import { createSearchParams } from "~/utils/searchParams"; import { getUsername } from "~/utils/username"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx index 5ed4e7ab8..23d6593f4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam._index/route.tsx @@ -11,7 +11,7 @@ import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/component import { StepContentContainer } from "~/components/StepContentContainer"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; @@ -261,15 +261,7 @@ export default function Page() { -
- {task.environments.map((environment) => ( - - ))} -
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.$scheduleParam/route.tsx index 760cbe783..bc28a1607 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.$scheduleParam/route.tsx @@ -8,7 +8,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { @@ -211,16 +211,7 @@ export default function Page() { -
- {schedule.environments.map((env) => ( - - ))} -
+
{schedule.externalId ? schedule.externalId : "–"} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx index 710a6f035..6a9a7c50c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx @@ -6,7 +6,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { BlankstateInstructions } from "~/components/BlankstateInstructions"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; @@ -281,15 +281,7 @@ function SchedulesTable({ {schedule.lastRun ? : "–"} -
- {schedule.environments.map((environment) => ( - - ))} -
+
diff --git a/apps/webapp/app/services/environmentSort.server.ts b/apps/webapp/app/utils/environmentSort.ts similarity index 94% rename from apps/webapp/app/services/environmentSort.server.ts rename to apps/webapp/app/utils/environmentSort.ts index a1e22d70f..d1e0ac9c3 100644 --- a/apps/webapp/app/services/environmentSort.server.ts +++ b/apps/webapp/app/utils/environmentSort.ts @@ -1,5 +1,4 @@ import { RuntimeEnvironmentType } from "@trigger.dev/database"; -import { logger } from "./logger.server"; const environmentSortOrder: RuntimeEnvironmentType[] = [ "DEVELOPMENT", From 0e919f56f22300487f5ca6572dd3e9d7afee343f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 9 May 2024 12:12:00 +0100 Subject: [PATCH 06/61] Alerts v1 (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Alerts v1 * Encrypt alert webhook secrets and allow them to be generated by the server * Alert v1 UI * Remove unnecessary emails * Move to using `@react-email/components` * WIP slack alerts * More slack alerts WIP * Update pnpm lock after rebase * Finish implementing Slack alerts * Use a more error like emoji * New secondary variant for the segmented control * Added a simple checkbox style variant to storybook * Style tweak to the segmented control * UI improvements to the alert modal * Use searchable Select for alerts. Changed default variant for SegmentedControl * Secondary button now using secondary colour * segmented control style tweak * Improved the channel column in the alerts table * Updated logo-mono.png * Updated email styles * Email templates updated to new styles * Don’t log the decrypted secret * await enqueing the deployment alert when an index fails * await enqueing the timeout alert --------- Co-authored-by: James Ritchie Co-authored-by: Matt Aitken --- .changeset/nice-bulldogs-turn.md | 6 + .../app/components/billing/PricingTiers.tsx | 1 + .../app/components/navigation/SideMenu.tsx | 9 + .../app/components/primitives/Buttons.tsx | 6 +- .../app/components/primitives/DetailCell.tsx | 17 +- .../primitives/SegmentedControl.tsx | 33 +- apps/webapp/app/env.server.ts | 3 + .../app/models/orgIntegration.server.ts | 229 +++ apps/webapp/app/models/project.server.ts | 12 + apps/webapp/app/models/projectAlert.server.ts | 33 + .../v3/AlertChannelListPresenter.server.ts | 73 + .../v3/ApiAlertChannelPresenter.server.ts | 131 ++ .../v3/DeploymentPresenter.server.ts | 4 +- .../v3/NewAlertChannelPresenter.server.ts | 104 + ...rojectParam.alerts.new.connect-to-slack.ts | 50 + .../route.tsx | 397 ++++ .../route.tsx | 445 ++++ .../route.tsx | 5 +- ...i.v1.projects.$projectRef.alertChannels.ts | 94 + .../integrations.$serviceName.callback.ts | 66 + .../internal.webhooks.slack.interactivity.ts | 5 + .../app/routes/storybook.buttons/route.tsx | 12 +- .../app/routes/storybook.checkboxes/route.tsx | 7 + .../storybook.segmented-control/route.tsx | 12 +- .../services/secrets/secretStore.server.ts | 72 +- apps/webapp/app/services/worker.server.ts | 39 + apps/webapp/app/utils/pathBuilder.ts | 15 + .../alerts/createAlertChannel.server.ts | 102 + .../v3/services/alerts/deliverAlert.server.ts | 775 +++++++ .../alerts/performDeploymentAlerts.server.ts | 74 + .../alerts/performTaskAttemptAlerts.server.ts | 82 + .../app/v3/services/completeAttempt.server.ts | 9 +- .../createDeployedBackgroundWorker.server.ts | 2 + .../services/createOrgIntegration.server.ts | 35 + .../services/deploymentIndexFailed.server.ts | 3 + .../startDeploymentIndexing.server.ts | 7 +- .../v3/services/timeoutDeployment.server.ts | 3 + apps/webapp/package.json | 1 + apps/webapp/public/emails/logo-mono.png | Bin 3709 -> 6835 bytes packages/cli-v3/src/commands/deploy.ts | 17 +- packages/core/src/v3/errors.ts | 27 + packages/core/src/v3/schemas/api.ts | 1 + .../migration.sql | 64 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 9 + .../migration.sql | 8 + .../migration.sql | 31 + .../migration.sql | 16 + .../migration.sql | 19 + packages/database/prisma/schema.prisma | 143 +- .../emails/emails/alert-attempt-failure.tsx | 87 + .../emails/emails/components/BasePath.tsx | 17 +- packages/emails/emails/components/Footer.tsx | 4 +- packages/emails/emails/components/Image.tsx | 7 +- packages/emails/emails/components/styles.ts | 35 +- .../emails/emails/connect-integration.tsx | 40 - packages/emails/emails/deployment-failure.tsx | 80 + packages/emails/emails/deployment-success.tsx | 60 + packages/emails/emails/invite.tsx | 21 +- packages/emails/emails/magic-link.tsx | 21 +- packages/emails/emails/welcome.tsx | 28 +- packages/emails/emails/workflow-failed.tsx | 35 - .../emails/emails/workflow-integration.tsx | 40 - packages/emails/package.json | 16 +- packages/emails/src/index.tsx | 67 +- pnpm-lock.yaml | 1804 +++++++++++++---- references/v3-catalog/src/trigger/retries.ts | 10 +- references/v3-catalog/src/trigger/simple.ts | 4 +- references/v3-catalog/trigger.config.ts | 5 +- 70 files changed, 5020 insertions(+), 673 deletions(-) create mode 100644 .changeset/nice-bulldogs-turn.md create mode 100644 apps/webapp/app/models/orgIntegration.server.ts create mode 100644 apps/webapp/app/models/projectAlert.server.ts create mode 100644 apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts create mode 100644 apps/webapp/app/routes/integrations.$serviceName.callback.ts create mode 100644 apps/webapp/app/routes/internal.webhooks.slack.interactivity.ts create mode 100644 apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/deliverAlert.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts create mode 100644 apps/webapp/app/v3/services/createOrgIntegration.server.ts create mode 100644 packages/database/prisma/migrations/20240425122814_add_alert_schema/migration.sql create mode 100644 packages/database/prisma/migrations/20240425131147_add_enabled_flag_to_alert_channels/migration.sql create mode 100644 packages/database/prisma/migrations/20240426095144_add_deployment_success_alert_type/migration.sql create mode 100644 packages/database/prisma/migrations/20240426095622_add_deduplication_key_to_alert_channels/migration.sql create mode 100644 packages/database/prisma/migrations/20240426102405_add_unique_deduplication_index_to_alert_channels/migration.sql create mode 100644 packages/database/prisma/migrations/20240428142050_add_models_for_slack_integration/migration.sql create mode 100644 packages/database/prisma/migrations/20240428150144_org_integration_non_optional_fields/migration.sql create mode 100644 packages/database/prisma/migrations/20240507113449_add_alert_storage/migration.sql create mode 100644 packages/emails/emails/alert-attempt-failure.tsx delete mode 100644 packages/emails/emails/connect-integration.tsx create mode 100644 packages/emails/emails/deployment-failure.tsx create mode 100644 packages/emails/emails/deployment-success.tsx delete mode 100644 packages/emails/emails/workflow-failed.tsx delete mode 100644 packages/emails/emails/workflow-integration.tsx diff --git a/.changeset/nice-bulldogs-turn.md b/.changeset/nice-bulldogs-turn.md new file mode 100644 index 000000000..f58877e8a --- /dev/null +++ b/.changeset/nice-bulldogs-turn.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Better handle uncaught exceptions diff --git a/apps/webapp/app/components/billing/PricingTiers.tsx b/apps/webapp/app/components/billing/PricingTiers.tsx index 30b9c2a00..dfe216f43 100644 --- a/apps/webapp/app/components/billing/PricingTiers.tsx +++ b/apps/webapp/app/components/billing/PricingTiers.tsx @@ -290,6 +290,7 @@ export function TierPro({ options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))} fullWidth value={concurrentBracketCode} + variant="primary" onChange={(v) => setConcurrentBracketCode(v)} />
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4d5e17296..18ce63425 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -3,6 +3,7 @@ import { ArrowRightIcon, ArrowRightOnRectangleIcon, BeakerIcon, + BellAlertIcon, ChartBarIcon, ClockIcon, CursorArrowRaysIcon, @@ -45,6 +46,7 @@ import { v3ApiKeysPath, v3DeploymentsPath, v3EnvironmentVariablesPath, + v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsPath, v3RunsPath, @@ -601,6 +603,13 @@ function V3ProjectSideMenu({ to={v3DeploymentsPath(organization, project)} data-action="deployments" /> + +
{label} {description && ( {description} diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 5576ad2cf..024f4ee54 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react"; import { motion } from "framer-motion"; import { cn } from "~/utils/cn"; +const variants = { + primary: { + base: "bg-charcoal-700", + active: "text-text-bright hover:bg-charcoal-750/50", + }, + secondary: { + base: "bg-charcoal-700/50", + active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50", + }, +}; + +type Variants = keyof typeof variants; + type Options = { label: string; value: string; @@ -12,6 +25,7 @@ type SegmentedControlProps = { value?: string; defaultValue?: string; options: Options[]; + variant?: Variants; fullWidth?: boolean; onChange?: (value: string) => void; }; @@ -21,11 +35,18 @@ export default function SegmentedControl({ value, defaultValue, options, + variant = "secondary", fullWidth, onChange, }: SegmentedControlProps) { return ( -
+
@@ -60,12 +81,12 @@ export default function SegmentedControl({
{option.label}
- {checked && ( + {checked && variant === "primary" && ( + /> )}
diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 4d53c4bb1..612823278 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -153,6 +153,9 @@ const EnvironmentSchema = z.object({ INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"), INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"), INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"), + + ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(), + ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts new file mode 100644 index 000000000..b5ae32ad4 --- /dev/null +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -0,0 +1,229 @@ +import { WebClient } from "@slack/web-api"; +import { + IntegrationService, + Organization, + OrganizationIntegration, + SecretReference, +} from "@trigger.dev/database"; +import { z } from "zod"; +import { $transaction, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; + +const SlackSecretSchema = z.object({ + botAccessToken: z.string(), + userAccessToken: z.string().optional(), + expiresIn: z.number().optional(), + refreshToken: z.string().optional(), + botScopes: z.array(z.string()).optional(), + userScopes: z.array(z.string()).optional(), + raw: z.record(z.any()).optional(), +}); + +type SlackSecret = z.infer; + +const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth"; + +type OrganizationIntegrationForService = Omit< + AuthenticatableIntegration, + "service" +> & { + service: TService; +}; + +type AuthenticatedClientOptions = TService extends "SLACK" + ? { + forceBotToken?: boolean; + } + : undefined; + +type AuthenticatedClientForIntegration = + TService extends "SLACK" ? InstanceType : never; + +export type AuthenticatableIntegration = OrganizationIntegration & { + tokenReference: SecretReference; +}; + +export class OrgIntegrationRepository { + static async getAuthenticatedClientForIntegration( + integration: OrganizationIntegrationForService, + options?: AuthenticatedClientOptions + ): Promise> { + const secretStore = getSecretStore(integration.tokenReference.provider); + + switch (integration.service) { + case "SLACK": { + const secret = await secretStore.getSecret( + SlackSecretSchema, + integration.tokenReference.key + ); + + if (!secret) { + throw new Error("Failed to get access token"); + } + + // TODO refresh access token here + return new WebClient( + options?.forceBotToken + ? secret.botAccessToken + : secret.userAccessToken ?? secret.botAccessToken + ) as AuthenticatedClientForIntegration; + } + default: { + throw new Error(`Unsupported service ${integration.service}`); + } + } + } + + static isSlackSupported = + !!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET; + + static slackAuthorizationUrl( + state: string, + scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"], + userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"] + ) { + return `https://slack.com/oauth/v2/authorize?client_id=${ + env.ORG_SLACK_INTEGRATION_CLIENT_ID + }&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${ + env.APP_ORIGIN + }/integrations/slack/callback`; + } + + static async redirectToAuthService( + service: IntegrationService, + state: string, + request: Request, + redirectTo: string + ) { + const session = await getUserSession(request); + session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo); + + const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined; + + if (!authUrl) { + throw new Response("Unsupported service", { status: 400 }); + } + + logger.debug("Redirecting to auth service", { + service, + authUrl, + redirectTo, + }); + + return new Response(null, { + status: 302, + headers: { + location: authUrl, + "Set-Cookie": await commitSession(session), + }, + }); + } + + static async redirectAfterAuth(request: Request) { + const session = await getUserSession(request); + + logger.debug("Redirecting back after auth", { + sessionData: session.data, + }); + + const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY); + + if (!redirectTo) { + throw new Response("Invalid redirect", { status: 400 }); + } + + session.unset(REDIRECT_AFTER_AUTH_KEY); + + return new Response(null, { + status: 302, + headers: { + location: redirectTo, + "Set-Cookie": await commitSession(session), + }, + }); + } + + static async createOrgIntegration(serviceName: string, code: string, org: Organization) { + switch (serviceName) { + case "slack": { + if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) { + throw new Error("Slack integration not configured"); + } + + const client = new WebClient(); + + const result = await client.oauth.v2.access({ + client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID, + client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET, + code, + redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`, + }); + + if (result.ok) { + logger.debug("Received slack access token", { + result, + }); + + if (!result.access_token) { + throw new Error("Failed to get access token"); + } + + return await $transaction(prisma, async (tx) => { + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + const integrationFriendlyId = generateFriendlyId("org_integration"); + + const secretValue: SlackSecret = { + botAccessToken: result.access_token!, + userAccessToken: result.authed_user ? result.authed_user.access_token : undefined, + expiresIn: result.expires_in, + refreshToken: result.refresh_token, + botScopes: result.scope ? result.scope.split(",") : [], + userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [], + raw: result, + }; + + logger.debug("Setting secret", { + secretValue, + }); + + await secretStore.setSecret(integrationFriendlyId, secretValue); + + const reference = await tx.secretReference.create({ + data: { + provider: "DATABASE", + key: integrationFriendlyId, + }, + }); + + return await tx.organizationIntegration.create({ + data: { + friendlyId: integrationFriendlyId, + organizationId: org.id, + service: "SLACK", + tokenReferenceId: reference.id, + integrationData: { + team: result.team, + user: result.authed_user + ? { + id: result.authed_user.id, + } + : undefined, + } as any, + }, + }); + }); + } + } + default: { + throw new Error(`Service ${serviceName} not supported`); + } + } + } +} diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 47d9097ca..f285901d9 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -110,3 +110,15 @@ export async function findProjectBySlug(orgSlug: string, projectSlug: string, us }, }); } + +export async function findProjectByRef(externalRef: string, userId: string) { + // Find the project scoped to the organization, making sure the user belongs to that org + return await prisma.project.findFirst({ + where: { + externalRef, + organization: { + members: { some: { userId } }, + }, + }, + }); +} diff --git a/apps/webapp/app/models/projectAlert.server.ts b/apps/webapp/app/models/projectAlert.server.ts new file mode 100644 index 000000000..cd3af4004 --- /dev/null +++ b/apps/webapp/app/models/projectAlert.server.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server"; + +export const ProjectAlertWebhookProperties = z.object({ + secret: EncryptedSecretValueSchema, + url: z.string(), +}); + +export type ProjectAlertWebhookProperties = z.infer; + +export const ProjectAlertEmailProperties = z.object({ + email: z.string(), +}); + +export type ProjectAlertEmailProperties = z.infer; + +export const DeleteProjectAlertChannel = z.object({ + id: z.string(), +}); + +export const ProjectAlertSlackProperties = z.object({ + channelId: z.string(), + channelName: z.string(), + integrationId: z.string().nullish(), +}); + +export type ProjectAlertSlackProperties = z.infer; + +export const ProjectAlertSlackStorage = z.object({ + message_ts: z.string(), +}); + +export type ProjectAlertSlackStorage = z.infer; diff --git a/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts b/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts new file mode 100644 index 000000000..01d28b950 --- /dev/null +++ b/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts @@ -0,0 +1,73 @@ +import { logger } from "~/services/logger.server"; +import { BasePresenter } from "./basePresenter.server"; +import { ProjectAlertChannel } from "@trigger.dev/database"; +import { decryptSecret } from "~/services/secrets/secretStore.server"; +import { env } from "~/env.server"; +import { + ProjectAlertEmailProperties, + ProjectAlertSlackProperties, + ProjectAlertWebhookProperties, +} from "~/models/projectAlert.server"; + +export type AlertChannelListPresenterData = Awaited>; +export type AlertChannelListPresenterRecord = + AlertChannelListPresenterData["alertChannels"][number]; +export type AlertChannelListPresenterAlertProperties = NonNullable< + AlertChannelListPresenterRecord["properties"] +>; + +export class AlertChannelListPresenter extends BasePresenter { + public async call(projectId: string) { + logger.debug("AlertChannelListPresenter", { projectId }); + + const alertChannels = await this._prisma.projectAlertChannel.findMany({ + where: { + projectId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return { + alertChannels: await Promise.all( + alertChannels.map(async (alertChannel) => ({ + ...alertChannel, + properties: await this.#presentProperties(alertChannel), + })) + ), + }; + } + + async #presentProperties(alertChannel: ProjectAlertChannel) { + if (!alertChannel.properties) { + return; + } + + switch (alertChannel.type) { + case "WEBHOOK": + const parsedProperties = ProjectAlertWebhookProperties.parse(alertChannel.properties); + + const secret = await decryptSecret(env.ENCRYPTION_KEY, parsedProperties.secret); + + return { + type: "WEBHOOK" as const, + url: parsedProperties.url, + secret, + }; + case "EMAIL": + return { + type: "EMAIL" as const, + ...ProjectAlertEmailProperties.parse(alertChannel.properties), + }; + case "SLACK": { + return { + type: "SLACK" as const, + ...ProjectAlertSlackProperties.parse(alertChannel.properties), + }; + } + default: + throw new Error(`Unsupported alert channel type: ${alertChannel.type}`); + } + } +} diff --git a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts new file mode 100644 index 000000000..0693354fd --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts @@ -0,0 +1,131 @@ +import { + ProjectAlertChannel, + ProjectAlertChannelType, + ProjectAlertType, +} from "@trigger.dev/database"; +import assertNever from "assert-never"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { + ProjectAlertEmailProperties, + ProjectAlertWebhookProperties, +} from "~/models/projectAlert.server"; +import { decryptSecret } from "~/services/secrets/secretStore.server"; + +export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "deployment_success"]); + +export type ApiAlertType = z.infer; + +export const ApiAlertChannel = z.enum(["email", "webhook"]); + +export type ApiAlertChannel = z.infer; + +export const ApiAlertChannelData = z.object({ + email: z.string().optional(), + url: z.string().optional(), + secret: z.string().optional(), +}); + +export type ApiAlertChannelData = z.infer; + +export const ApiCreateAlertChannel = z.object({ + alertTypes: ApiAlertType.array(), + name: z.string(), + channel: ApiAlertChannel, + channelData: ApiAlertChannelData, + deduplicationKey: z.string().optional(), +}); + +export type ApiCreateAlertChannel = z.infer; + +export const ApiAlertChannelObject = z.object({ + id: z.string(), + name: z.string(), + alertTypes: ApiAlertType.array(), + channel: ApiAlertChannel, + channelData: ApiAlertChannelData, + deduplicationKey: z.string().optional(), +}); + +export type ApiAlertChannelObject = z.infer; + +export class ApiAlertChannelPresenter { + public static async alertChannelToApi( + alertChannel: ProjectAlertChannel + ): Promise { + return { + id: alertChannel.friendlyId, + name: alertChannel.name, + alertTypes: alertChannel.alertTypes.map((type) => this.alertTypeToApi(type)), + channel: this.alertChannelTypeToApi(alertChannel.type), + channelData: await channelDataFromProperties(alertChannel.type, alertChannel.properties), + deduplicationKey: alertChannel.userProvidedDeduplicationKey + ? alertChannel.deduplicationKey + : undefined, + }; + } + + public static alertTypeToApi(alertType: ProjectAlertType): ApiAlertType { + switch (alertType) { + case "TASK_RUN_ATTEMPT": + return "attempt_failure"; + case "DEPLOYMENT_FAILURE": + return "deployment_failure"; + case "DEPLOYMENT_SUCCESS": + return "deployment_success"; + default: + assertNever(alertType); + } + } + + public static alertTypeFromApi(alertType: ApiAlertType): ProjectAlertType { + switch (alertType) { + case "attempt_failure": + return "TASK_RUN_ATTEMPT"; + case "deployment_failure": + return "DEPLOYMENT_FAILURE"; + case "deployment_success": + return "DEPLOYMENT_SUCCESS"; + default: + assertNever(alertType); + } + } + + public static alertChannelTypeToApi(type: ProjectAlertChannelType): ApiAlertChannel { + switch (type) { + case "EMAIL": + return "email"; + case "WEBHOOK": + return "webhook"; + case "SLACK": + throw new Error("Slack channels are not supported"); + default: + assertNever(type); + } + } +} + +async function channelDataFromProperties( + type: ProjectAlertChannelType, + properties: ProjectAlertChannel["properties"] +): Promise { + if (!properties) { + return {}; + } + + switch (type) { + case "EMAIL": + return ProjectAlertEmailProperties.parse(properties); + case "WEBHOOK": + const { url, secret } = ProjectAlertWebhookProperties.parse(properties); + + return { + url, + secret: await decryptSecret(env.ENCRYPTION_KEY, secret), + }; + case "SLACK": + throw new Error("Slack channels are not supported"); + default: + assertNever(type); + } +} diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index dcf9cc287..a920bdd1f 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -144,18 +144,18 @@ export class DeploymentPresenter { userName: getUsername(deployment.environment.orgMember?.user), }, deployedBy: deployment.triggeredBy, - errorData: this.#prepareErrorData(deployment.errorData), sdkVersion: deployment.worker?.sdkVersion, imageReference: deployment.imageReference, externalBuildData: externalBuildData && externalBuildData.success ? externalBuildData.data : undefined, projectId: deployment.projectId, organizationId: project.organizationId, + errorData: DeploymentPresenter.prepareErrorData(deployment.errorData), }, }; } - #prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined { + public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined { if (!errorData) { return; } diff --git a/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts new file mode 100644 index 000000000..24688d8b7 --- /dev/null +++ b/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts @@ -0,0 +1,104 @@ +import { + AuthenticatableIntegration, + OrgIntegrationRepository, +} from "~/models/orgIntegration.server"; +import { logger } from "~/services/logger.server"; +import { BasePresenter } from "./basePresenter.server"; +import { WebClient } from "@slack/web-api"; + +export class NewAlertChannelPresenter extends BasePresenter { + public async call(projectId: string) { + const project = await this._prisma.project.findUniqueOrThrow({ + where: { + id: projectId, + }, + }); + + // Find the latest Slack integration + const slackIntegration = await this._prisma.organizationIntegration.findFirst({ + where: { + service: "SLACK", + organizationId: project.organizationId, + }, + orderBy: { + createdAt: "desc", + }, + include: { + tokenReference: true, + }, + }); + + // If there is a slack integration, then we need to get a list of Slack Channels + if (slackIntegration) { + const channels = await getSlackChannelsForToken(slackIntegration); + + return { + slack: { + status: "READY" as const, + channels, + integrationId: slackIntegration.id, + }, + }; + } else { + if (OrgIntegrationRepository.isSlackSupported) { + return { + slack: { + status: "NOT_CONFIGURED" as const, + }, + }; + } else { + return { + slack: { + status: "NOT_AVAILABLE" as const, + }, + }; + } + } + } +} + +async function getSlackChannelsForToken(integration: AuthenticatableIntegration) { + const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(integration); + + const channels = await getAllSlackConversations(client); + + logger.debug("Received a list of slack conversations", { + channels, + }); + + return (channels ?? []) + .filter((channel) => !channel.is_archived) + .filter((channel) => channel.is_channel) + .filter((channel) => !channel.is_ext_shared) + .filter((channel) => channel.unlinked === 0) + .filter((channel) => channel.num_members) + .sort((a, b) => a.name!.localeCompare(b.name!)); +} + +type Channels = Awaited>["channels"]; + +async function getSlackConversationsPage(client: WebClient, nextCursor?: string) { + return client.conversations.list({ + types: "public_channel,private_channel", + exclude_archived: true, + cursor: nextCursor, + }); +} + +async function getAllSlackConversations(client: WebClient) { + let nextCursor: string | undefined = undefined; + let channels: Channels = []; + + do { + const response = await getSlackConversationsPage(client, nextCursor); + + if (!response.ok) { + throw new Error(`Failed to get channels: ${response.error}`); + } + + channels = channels.concat(response.channels ?? []); + nextCursor = response.response_metadata?.next_cursor; + } while (nextCursor); + + return channels; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts new file mode 100644 index 000000000..2f02562f2 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts @@ -0,0 +1,50 @@ +import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { + redirectBackWithSuccessMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { requireUserId } from "~/services/session.server"; +import { getUserSession } from "~/services/sessionStorage.server"; +import { + ProjectParamSchema, + v3NewProjectAlertPath, + v3NewProjectAlertPathConnectToSlackPath, +} from "~/utils/pathBuilder"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + // Find an integration for Slack for this org + const integration = await prisma.organizationIntegration.findFirst({ + where: { + service: "SLACK", + organizationId: project.organizationId, + }, + }); + + if (integration) { + return redirectWithSuccessMessage( + `${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`, + request, + "Successfully connected your Slack workspace" + ); + } else { + // Redirect to Slack + return await OrgIntegrationRepository.redirectToAuthService( + "SLACK", + project.organizationId, + request, + v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project) + ); + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx new file mode 100644 index 000000000..7b9b1a31a --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx @@ -0,0 +1,397 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; +import { LoaderFunctionArgs } from "@remix-run/router"; +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { SlackIcon } from "@trigger.dev/companyicons"; +import { useEffect, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Checkbox } from "~/components/primitives/Checkbox"; +import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import SegmentedControl from "~/components/primitives/SegmentedControl"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder"; +import { + CreateAlertChannelOptions, + CreateAlertChannelService, +} from "~/v3/services/alerts/createAlertChannel.server"; + +const FormSchema = z + .object({ + alertTypes: z + .array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])) + .min(1) + .or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])), + type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"), + channelValue: z.string().nonempty(), + integrationId: z.string().optional(), + }) + .refine( + (value) => + value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true, + { + message: "Must be a valid email address", + path: ["channelValue"], + } + ) + .refine( + (value) => + value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true, + { + message: "Must be a valid URL", + path: ["channelValue"], + } + ) + .refine( + (value) => + value.type === "SLACK" + ? typeof value.channelValue === "string" && value.channelValue.startsWith("C") + : true, + { + message: "Must select a Slack channel", + path: ["channelValue"], + } + ); + +function formDataToCreateAlertChannelOptions( + formData: z.infer +): CreateAlertChannelOptions { + switch (formData.type) { + case "WEBHOOK": { + return { + name: `Webhook to ${new URL(formData.channelValue).hostname}`, + alertTypes: Array.isArray(formData.alertTypes) + ? formData.alertTypes + : [formData.alertTypes], + channel: { + type: "WEBHOOK", + url: formData.channelValue, + }, + }; + } + case "EMAIL": { + return { + name: `Email to ${formData.channelValue}`, + alertTypes: Array.isArray(formData.alertTypes) + ? formData.alertTypes + : [formData.alertTypes], + channel: { + type: "EMAIL", + email: formData.channelValue, + }, + }; + } + case "SLACK": { + const [channelId, channelName] = formData.channelValue.split("/"); + + return { + name: `Slack message to ${channelName}`, + alertTypes: Array.isArray(formData.alertTypes) + ? formData.alertTypes + : [formData.alertTypes], + channel: { + type: "SLACK", + channelId, + channelName, + integrationId: formData.integrationId, + }, + }; + } + } +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const presenter = new NewAlertChannelPresenter(); + + const results = await presenter.call(project.id); + + const url = new URL(request.url); + const option = url.searchParams.get("option"); + + return typedjson({ + ...results, + option: option === "slack" ? ("SLACK" as const) : undefined, + }); +} + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + if (request.method.toUpperCase() !== "POST") { + return { status: 405, body: "Method Not Allowed" }; + } + + const formData = await request.formData(); + + const submission = parse(formData, { schema: FormSchema }); + + if (!submission.value) { + return json(submission); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + submission.error.key = "Project not found"; + return json(submission); + } + + const service = new CreateAlertChannelService(); + const alertChannel = await service.call( + project.externalRef, + userId, + formDataToCreateAlertChannelOptions(submission.value) + ); + + if (!alertChannel) { + submission.error.key = "Failed to create alert channel"; + return json(submission); + } + + return redirectWithSuccessMessage( + v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Created ${alertChannel.name} alert` + ); +}; + +export default function Page() { + const [isOpen, setIsOpen] = useState(false); + const { slack, option } = useTypedLoaderData(); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + const navigate = useNavigate(); + const organization = useOrganization(); + const project = useProject(); + const [currentAlertChannel, setCurrentAlertChannel] = useState(option ?? "EMAIL"); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "create"; + + const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({ + id: "create-alert", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: FormSchema }); + }, + shouldRevalidate: "onSubmit", + }); + + useEffect(() => { + setIsOpen(true); + }, []); + + useEffect(() => { + if (navigation.state !== "idle") return; + if (lastSubmission !== undefined) return; + + form.ref.current?.reset(); + }, [navigation.state, lastSubmission]); + + return ( + { + if (!o) { + navigate(v3ProjectAlertsPath(organization, project)); + } + }} + > + + New alert +
+
+ + { + setCurrentAlertChannel(value); + }} + fullWidth + defaultValue={currentAlertChannel ?? undefined} + /> + + + {currentAlertChannel === "EMAIL" ? ( + + + + {channelValue.error} + + ) : currentAlertChannel === "SLACK" ? ( + + {slack.status === "READY" ? ( + <> + + + If selecting a private channel, you will need to invite the bot to the channel + using /invite @Trigger.dev + + {channelValue.error} + + + ) : slack.status === "NOT_CONFIGURED" ? ( + + + Connect to Slack + + + ) : ( + + Slack integration is not available. Please contact your organization + administrator. + + )} + + ) : ( + + + + {channelValue.error} + We'll issue POST requests to this URL with a JSON payload. + + )} + + + + + + + + + + + {alertTypes.error} + + + {form.error} +
+ + {isLoading ? "Saving…" : "Save"} + + } + cancelButton={ + + Cancel + + } + /> +
+
+
+
+
+ ); +} + +function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) { + return ( +
+ {is_private ? : } + {name} +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx new file mode 100644 index 000000000..4566144a9 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx @@ -0,0 +1,445 @@ +import { useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { + BoltIcon, + BoltSlashIcon, + BookOpenIcon, + EnvelopeIcon, + GlobeAltIcon, + LockClosedIcon, + PlusIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; +import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react"; +import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { SlackIcon } from "@trigger.dev/companyicons"; +import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database"; +import assertNever from "assert-never"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { DetailCell } from "~/components/primitives/DetailCell"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; +import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; +import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { + AlertChannelListPresenter, + AlertChannelListPresenterRecord, +} from "~/presenters/v3/AlertChannelListPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { + ProjectParamSchema, + docsPath, + v3NewProjectAlertPath, + v3ProjectAlertsPath, +} from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = ProjectParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const presenter = new AlertChannelListPresenter(); + const data = await presenter.call(project.id); + + return typedjson(data); +}; + +const schema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("delete"), id: z.string() }), + z.object({ action: z.literal("disable"), id: z.string() }), + z.object({ action: z.literal("enable"), id: z.string() }), +]); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + if (request.method.toUpperCase() !== "POST") { + return { status: 405, body: "Method Not Allowed" }; + } + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return json(submission); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + submission.error.key = "Project not found"; + return json(submission); + } + + switch (submission.value.action) { + case "delete": { + const alertChannel = await prisma.projectAlertChannel.delete({ + where: { id: submission.value.id, projectId: project.id }, + }); + + return redirectWithSuccessMessage( + v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Deleted ${alertChannel.name} alert` + ); + } + case "disable": { + const alertChannel = await prisma.projectAlertChannel.update({ + where: { id: submission.value.id, projectId: project.id }, + data: { enabled: false }, + }); + + return redirectWithSuccessMessage( + v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Disabled ${alertChannel.name} alert` + ); + } + case "enable": { + const alertChannel = await prisma.projectAlertChannel.update({ + where: { id: submission.value.id, projectId: project.id }, + data: { enabled: true }, + }); + + return redirectWithSuccessMessage( + v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Enabled ${alertChannel.name} alert` + ); + } + } +}; + +export default function Page() { + const { alertChannels } = useTypedLoaderData(); + const project = useProject(); + const organization = useOrganization(); + + return ( + + + + + + Alerts docs + + + + +
+
+ + New alert + +
+ + + + Name + Alert Types + Channel + Enabled + Actions + + + + {alertChannels.length > 0 ? ( + alertChannels.map((alertChannel) => ( + + + {alertChannel.name} + + + {alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")} + + + + + + + + + {alertChannel.enabled ? ( + + ) : ( + + )} + + + + + )) + ) : ( + + +
+ No alerts have been created +
+
+
+ )} +
+
+
+ +
+
+ ); +} + +function DeleteAlertChannelButton(props: { id: string }) { + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "delete"; + + const [form, { id }] = useForm({ + id: "delete-alert-channel", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + return ( +
+ + +
+ ); +} + +function DisableAlertChannelButton(props: { id: string }) { + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "delete"; + + const [form, { id }] = useForm({ + id: "disable-alert-channel", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + return ( +
+ + + +
+ ); +} + +function EnableAlertChannelButton(props: { id: string }) { + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "delete"; + + const [form, { id }] = useForm({ + id: "enable-alert-channel", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + return ( +
+ + + +
+ ); +} + +function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) { + switch (alertChannel.properties?.type) { + case "EMAIL": { + return ( + + } + leadingIconClassName="text-charcoal-400" + label={"Email"} + description={alertChannel.properties.email} + /> + ); + } + case "WEBHOOK": { + return ( + + + + + + Webhook + + + } + leadingIconClassName="text-charcoal-400" + label={alertChannel.properties.url} + description={ + } + iconButton + secure={"•".repeat(alertChannel.properties.secret.length)} + className="mt-1 w-80" + /> + } + /> + ); + } + case "SLACK": { + return ( + + } + leadingIconClassName="text-charcoal-400" + label={"Slack"} + description={`#${alertChannel.properties.channelName}`} + /> + ); + } + } + + return null; +} + +export function alertTypeTitle(alertType: ProjectAlertType): string { + switch (alertType) { + case "TASK_RUN_ATTEMPT": + return "Task attempt failure"; + case "DEPLOYMENT_FAILURE": + return "Deployment failure"; + case "DEPLOYMENT_SUCCESS": + return "Deployment success"; + default: { + assertNever(alertType); + } + } +} + +export function AlertChannelTypeIcon({ + channelType, + className, +}: { + channelType: ProjectAlertChannelType; + className: string; +}) { + switch (channelType) { + case "EMAIL": + return ; + case "SLACK": + return ; + case "WEBHOOK": + return ; + default: { + assertNever(channelType); + } + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx index 418d82e16..868182e30 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx @@ -46,13 +46,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments } = await presenter.call({ + const { environments } = await presenter.call({ userId, projectSlug: projectParam, }); return typedjson({ - environmentVariables, environments, }); } catch (error) { @@ -150,7 +149,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const [isOpen, setIsOpen] = useState(false); - const { environmentVariables, environments } = useTypedLoaderData(); + const { environments } = useTypedLoaderData(); const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts new file mode 100644 index 000000000..0eb2dc11f --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts @@ -0,0 +1,94 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { + ApiAlertChannelPresenter, + ApiCreateAlertChannel, +} from "~/presenters/v3/ApiAlertChannelPresenter.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { projectRef } = parsedParams.data; + + const rawBody = await request.json(); + + const body = ApiCreateAlertChannel.safeParse(rawBody); + + if (!body.success) { + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); + } + + const service = new CreateAlertChannelService(); + + try { + if (body.data.channel === "email") { + if (!body.data.channelData.email) { + return json({ error: "Email is required" }, { status: 422 }); + } + + const alertChannel = await service.call(projectRef, authenticationResult.userId, { + name: body.data.name, + alertTypes: body.data.alertTypes.map((type) => + ApiAlertChannelPresenter.alertTypeFromApi(type) + ), + channel: { + type: "EMAIL", + email: body.data.channelData.email, + }, + deduplicationKey: body.data.deduplicationKey, + }); + + return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel)); + } + + if (body.data.channel === "webhook") { + if (!body.data.channelData.url) { + return json({ error: "webhook url is required" }, { status: 422 }); + } + + const alertChannel = await service.call(projectRef, authenticationResult.userId, { + name: body.data.name, + alertTypes: body.data.alertTypes.map((type) => + ApiAlertChannelPresenter.alertTypeFromApi(type) + ), + channel: { + type: "WEBHOOK", + url: body.data.channelData.url, + secret: body.data.channelData.secret, + }, + deduplicationKey: body.data.deduplicationKey, + }); + + return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel)); + } + + return json({ error: "Invalid channel type" }, { status: 422 }); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 422 }); + } + + return json( + { error: error instanceof Error ? error.message : "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/apps/webapp/app/routes/integrations.$serviceName.callback.ts b/apps/webapp/app/routes/integrations.$serviceName.callback.ts new file mode 100644 index 000000000..8d2160287 --- /dev/null +++ b/apps/webapp/app/routes/integrations.$serviceName.callback.ts @@ -0,0 +1,66 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import z from "zod"; +import { redirectBackWithErrorMessage } from "~/models/message.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { requireUserId } from "~/services/session.server"; +import { requestUrl } from "~/utils/requestUrl.server"; +import { CreateOrgIntegrationService } from "~/v3/services/createOrgIntegration.server"; + +const URLSearchSchema = z + .object({ + code: z.string().optional(), + state: z.string().optional(), + error: z.string().optional(), + }) + .passthrough(); + +const ParamsSchema = z.object({ + serviceName: z.string(), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + if (request.method.toUpperCase() !== "GET") { + return { status: 405, body: "Method Not Allowed" }; + } + + const userId = await requireUserId(request); + + const url = requestUrl(request); + + const parsedSearchParams = URLSearchSchema.safeParse(Object.fromEntries(url.searchParams)); + + if (!parsedSearchParams.success) { + // TODO: this needs to lookup the redirect url in the cookies + throw new Response("Invalid params", { status: 400 }); + } + + if (parsedSearchParams.data.error) { + // TODO: this needs to lookup the redirect url in the cookies + throw new Response(parsedSearchParams.data.error, { status: 400 }); + } + + if (!parsedSearchParams.data.code || !parsedSearchParams.data.state) { + throw new Response("Invalid params", { status: 400 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + throw new Response("Invalid params", { status: 400 }); + } + + const service = new CreateOrgIntegrationService(); + + const integration = await service.call( + userId, + parsedSearchParams.data.state, + parsedParams.data.serviceName, + parsedSearchParams.data.code + ); + + if (integration) { + return await OrgIntegrationRepository.redirectAfterAuth(request); + } + + return redirectBackWithErrorMessage(request, "Failed to connect to the service"); +} diff --git a/apps/webapp/app/routes/internal.webhooks.slack.interactivity.ts b/apps/webapp/app/routes/internal.webhooks.slack.interactivity.ts new file mode 100644 index 000000000..e0706a20f --- /dev/null +++ b/apps/webapp/app/routes/internal.webhooks.slack.interactivity.ts @@ -0,0 +1,5 @@ +import { ActionFunctionArgs } from "@remix-run/server-runtime"; + +export function action({ request }: ActionFunctionArgs) { + return new Response(null, { status: 200 }); +} diff --git a/apps/webapp/app/routes/storybook.buttons/route.tsx b/apps/webapp/app/routes/storybook.buttons/route.tsx index dd996a583..3baff3b1c 100644 --- a/apps/webapp/app/routes/storybook.buttons/route.tsx +++ b/apps/webapp/app/routes/storybook.buttons/route.tsx @@ -278,10 +278,8 @@ export default function Story() { Continue with GitHub + - + +
+ Primary + +
+
+ Secondary + +
); } diff --git a/apps/webapp/app/services/secrets/secretStore.server.ts b/apps/webapp/app/services/secrets/secretStore.server.ts index 45673d7f0..43ebf32a7 100644 --- a/apps/webapp/app/services/secrets/secretStore.server.ts +++ b/apps/webapp/app/services/secrets/secretStore.server.ts @@ -55,12 +55,14 @@ export class SecretStore { } } -const EncryptedSecretValueSchema = z.object({ +export const EncryptedSecretValueSchema = z.object({ nonce: z.string(), ciphertext: z.string(), tag: z.string(), }); +export type EncryptedSecretValue = z.infer; + /** This stores secrets in the Postgres Database, encrypted using aes-256-gcm */ class PrismaSecretStore implements SecretStoreProvider { #prismaClient: PrismaClientOrTransaction; @@ -181,18 +183,11 @@ class PrismaSecretStore implements SecretStoreProvider { } async #decrypt(nonce: string, ciphertext: string, tag: string): Promise { - const decipher = nodeCrypto.createDecipheriv( - "aes-256-gcm", - this.encryptionKey, - Buffer.from(nonce, "hex") - ); - - decipher.setAuthTag(Buffer.from(tag, "hex")); - - let decrypted = decipher.update(ciphertext, "hex", "utf8"); - decrypted += decipher.final("utf8"); - - return decrypted; + return await decryptSecret(this.encryptionKey, { + nonce, + ciphertext, + tag, + }); } async #encrypt(value: string): Promise<{ @@ -200,19 +195,7 @@ class PrismaSecretStore implements SecretStoreProvider { ciphertext: string; tag: string; }> { - const nonce = nodeCrypto.randomBytes(12); - const cipher = nodeCrypto.createCipheriv("aes-256-gcm", this.encryptionKey, nonce); - - let encrypted = cipher.update(value, "utf8", "hex"); - encrypted += cipher.final("hex"); - - const tag = cipher.getAuthTag().toString("hex"); - - return { - nonce: nonce.toString("hex"), - ciphertext: encrypted, - tag, - }; + return await encryptSecret(this.encryptionKey, value); } } @@ -234,3 +217,40 @@ export function getSecretStore< } } } + +export async function decryptSecret( + encryptionKey: string, + secret: EncryptedSecretValue +): Promise { + const decipher = nodeCrypto.createDecipheriv( + "aes-256-gcm", + encryptionKey, + Buffer.from(secret.nonce, "hex") + ); + + decipher.setAuthTag(Buffer.from(secret.tag, "hex")); + + let decrypted = decipher.update(secret.ciphertext, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; +} + +export async function encryptSecret( + encryptionKey: string, + value: string +): Promise { + const nonce = nodeCrypto.randomBytes(12); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", encryptionKey, nonce); + + let encrypted = cipher.update(value, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const tag = cipher.getAuthTag().toString("hex"); + + return { + nonce: nonce.toString("hex"), + ciphertext: encrypted, + tag, + }; +} diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index 98fdd80b0..c089560e5 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -37,6 +37,9 @@ import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server import { eventRepository } from "~/v3/eventRepository.server"; import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy"; import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server"; +import { PerformTaskAttemptAlertsService } from "~/v3/services/alerts/performTaskAttemptAlerts.server"; +import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server"; +import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server"; const workerCatalog = { indexEndpoint: z.object({ @@ -136,6 +139,15 @@ const workerCatalog = { "v3.triggerScheduledTask": z.object({ instanceId: z.string(), }), + "v3.performTaskAttemptAlerts": z.object({ + attemptId: z.string(), + }), + "v3.deliverAlert": z.object({ + alertId: z.string(), + }), + "v3.performDeploymentAlerts": z.object({ + deploymentId: z.string(), + }), }; const executionWorkerCatalog = { @@ -533,6 +545,33 @@ function getWorkerQueue() { return await service.call(payload.instanceId); }, }, + "v3.performTaskAttemptAlerts": { + priority: 0, + maxAttempts: 3, + handler: async (payload, job) => { + const service = new PerformTaskAttemptAlertsService(); + + return await service.call(payload.attemptId); + }, + }, + "v3.deliverAlert": { + priority: 0, + maxAttempts: 8, + handler: async (payload, job) => { + const service = new DeliverAlertService(); + + return await service.call(payload.alertId); + }, + }, + "v3.performDeploymentAlerts": { + priority: 0, + maxAttempts: 3, + handler: async (payload, job) => { + const service = new PerformDeploymentAlertsService(); + + return await service.call(payload.deploymentId); + }, + }, }, }); } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 6e5b3fc99..9741ba831 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -325,6 +325,21 @@ export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project: return `${v3EnvironmentVariablesPath(organization, project)}/new`; } +export function v3ProjectAlertsPath(organization: OrgForPath, project: ProjectForPath) { + return `${v3ProjectPath(organization, project)}/alerts`; +} + +export function v3NewProjectAlertPath(organization: OrgForPath, project: ProjectForPath) { + return `${v3ProjectAlertsPath(organization, project)}/new`; +} + +export function v3NewProjectAlertPathConnectToSlackPath( + organization: OrgForPath, + project: ProjectForPath +) { + return `${v3ProjectAlertsPath(organization, project)}/new/connect-to-slack`; +} + export function v3TestPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts new file mode 100644 index 000000000..8a2fb0b96 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts @@ -0,0 +1,102 @@ +import { ProjectAlertChannel, ProjectAlertType } from "@trigger.dev/database"; +import { nanoid } from "nanoid"; +import { env } from "~/env.server"; +import { findProjectByRef } from "~/models/project.server"; +import { encryptSecret } from "~/services/secrets/secretStore.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { BaseService, ServiceValidationError } from "../baseService.server"; + +export type CreateAlertChannelOptions = { + name: string; + alertTypes: ProjectAlertType[]; + deduplicationKey?: string; + channel: + | { + type: "EMAIL"; + email: string; + } + | { + type: "WEBHOOK"; + url: string; + secret?: string; + } + | { + type: "SLACK"; + channelId: string; + channelName: string; + integrationId: string | undefined; + }; +}; + +export class CreateAlertChannelService extends BaseService { + public async call( + projectRef: string, + userId: string, + options: CreateAlertChannelOptions + ): Promise { + const project = await findProjectByRef(projectRef, userId); + + if (!project) { + throw new ServiceValidationError("Project not found"); + } + + const existingAlertChannel = options.deduplicationKey + ? await this._prisma.projectAlertChannel.findUnique({ + where: { + projectId_deduplicationKey: { + projectId: project.id, + deduplicationKey: options.deduplicationKey, + }, + }, + }) + : undefined; + + if (existingAlertChannel) { + return await this._prisma.projectAlertChannel.update({ + where: { id: existingAlertChannel.id }, + data: { + name: options.name, + alertTypes: options.alertTypes, + type: options.channel.type, + properties: await this.#createProperties(options.channel), + }, + }); + } + + const alertChannel = await this._prisma.projectAlertChannel.create({ + data: { + friendlyId: generateFriendlyId("alert_channel"), + name: options.name, + alertTypes: options.alertTypes, + projectId: project.id, + type: options.channel.type, + properties: await this.#createProperties(options.channel), + enabled: true, + deduplicationKey: options.deduplicationKey, + userProvidedDeduplicationKey: options.deduplicationKey ? true : false, + }, + }); + + return alertChannel; + } + + async #createProperties(channel: CreateAlertChannelOptions["channel"]) { + switch (channel.type) { + case "EMAIL": + return { + email: channel.email, + }; + case "WEBHOOK": + return { + url: channel.url, + secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()), + }; + case "SLACK": + return { + channelId: channel.channelId, + channelName: channel.channelName, + integrationId: channel.integrationId, + }; + } + } +} diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts new file mode 100644 index 000000000..3b3ac7641 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -0,0 +1,775 @@ +import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3"; +import assertNever from "assert-never"; +import { subtle } from "crypto"; +import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { + ProjectAlertEmailProperties, + ProjectAlertSlackProperties, + ProjectAlertSlackStorage, + ProjectAlertWebhookProperties, +} from "~/models/projectAlert.server"; +import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; +import { sendEmail } from "~/services/email.server"; +import { logger } from "~/services/logger.server"; +import { decryptSecret } from "~/services/secrets/secretStore.server"; +import { workerQueue } from "~/services/worker.server"; +import { BaseService } from "../baseService.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; + +type FoundAlert = Prisma.Result< + typeof prisma.projectAlert, + { + include: { + channel: true; + project: { + include: { + organization: true; + }; + }; + environment: true; + taskRunAttempt: { + include: { + taskRun: true; + backgroundWorkerTask: true; + backgroundWorker: true; + }; + }; + workerDeployment: { + include: { + worker: { + include: { + tasks: true; + }; + }; + }; + }; + }; + }, + "findUniqueOrThrow" +>; + +export class DeliverAlertService extends BaseService { + public async call(alertId: string) { + const alert = await this._prisma.projectAlert.findUnique({ + where: { id: alertId }, + include: { + channel: true, + project: { + include: { + organization: true, + }, + }, + environment: true, + taskRunAttempt: { + include: { + taskRun: true, + backgroundWorkerTask: true, + backgroundWorker: true, + }, + }, + workerDeployment: { + include: { + worker: { + include: { + tasks: true, + }, + }, + }, + }, + }, + }); + + if (!alert) { + return; + } + + if (alert.status !== "PENDING") { + return; + } + + if (alert.environment.type === "DEVELOPMENT") { + return; + } + + switch (alert.channel.type) { + case "EMAIL": { + await this.#sendEmail(alert); + break; + } + case "SLACK": { + await this.#sendSlack(alert); + break; + } + case "WEBHOOK": { + await this.#sendWebhook(alert); + break; + } + default: { + assertNever(alert.channel.type); + } + } + + await this._prisma.projectAlert.update({ + where: { id: alertId }, + data: { + status: "SENT", + }, + }); + } + + async #sendEmail(alert: FoundAlert) { + const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties); + + if (!emailProperties.success) { + logger.error("[DeliverAlert] Failed to parse email properties", { + issues: emailProperties.error.issues, + properties: alert.channel.properties, + }); + + return; + } + + switch (alert.type) { + case "TASK_RUN_ATTEMPT": { + if (alert.taskRunAttempt) { + const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error); + + if (!taskRunError.success) { + logger.error("[DeliverAlert] Failed to parse task run error", { + issues: taskRunError.error.issues, + taskAttemptError: alert.taskRunAttempt.error, + }); + + return; + } + + await sendEmail({ + 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.data), + attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`, + }); + } else { + logger.error("[DeliverAlert] Task run attempt not found", { + alert, + }); + } + + break; + } + case "DEPLOYMENT_FAILURE": { + if (alert.workerDeployment) { + const preparedError = DeploymentPresenter.prepareErrorData( + alert.workerDeployment.errorData + ); + + if (!preparedError) { + logger.error("[DeliverAlert] Failed to prepare deployment error data", { + errorData: alert.workerDeployment.errorData, + }); + + return; + } + + await sendEmail({ + email: "alert-deployment-failure", + to: emailProperties.data.email, + version: alert.workerDeployment.version, + environment: alert.environment.slug, + shortCode: alert.workerDeployment.shortCode, + failedAt: alert.workerDeployment.failedAt ?? new Date(), + error: preparedError, + deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, + }); + } else { + logger.error("[DeliverAlert] Worker deployment not found", { + alert, + }); + } + + break; + } + case "DEPLOYMENT_SUCCESS": { + if (alert.workerDeployment) { + await sendEmail({ + email: "alert-deployment-success", + to: emailProperties.data.email, + version: alert.workerDeployment.version, + environment: alert.environment.slug, + shortCode: alert.workerDeployment.shortCode, + deployedAt: alert.workerDeployment.deployedAt ?? new Date(), + deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, + taskCount: alert.workerDeployment.worker?.tasks.length ?? 0, + }); + } else { + logger.error("[DeliverAlert] Worker deployment not found", { + alert, + }); + } + + break; + } + default: { + assertNever(alert.type); + } + } + } + + async #sendWebhook(alert: FoundAlert) { + const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties); + + if (!webhookProperties.success) { + logger.error("[DeliverAlert] Failed to parse webhook properties", { + issues: webhookProperties.error.issues, + properties: alert.channel.properties, + }); + + return; + } + + switch (alert.type) { + case "TASK_RUN_ATTEMPT": { + if (alert.taskRunAttempt) { + const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error); + + if (!taskRunError.success) { + logger.error("[DeliverAlert] 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, + }); + } + + break; + } + case "DEPLOYMENT_FAILURE": { + if (alert.workerDeployment) { + const preparedError = DeploymentPresenter.prepareErrorData( + alert.workerDeployment.errorData + ); + + if (!preparedError) { + logger.error("[DeliverAlert] Failed to prepare deployment error data", { + errorData: alert.workerDeployment.errorData, + }); + + 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, + }; + + await this.#deliverWebhook(payload, webhookProperties.data); + } else { + logger.error("[DeliverAlert] Worker deployment not found", { + alert, + }); + } + + break; + } + 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, + }, + }; + + await this.#deliverWebhook(payload, webhookProperties.data); + } else { + logger.error("[DeliverAlert] Worker deployment not found", { + alert, + }); + } + + break; + } + default: { + assertNever(alert.type); + } + } + } + + async #sendSlack(alert: FoundAlert) { + const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties); + + if (!slackProperties.success) { + logger.error("[DeliverAlert] Failed to parse slack properties", { + issues: slackProperties.error.issues, + properties: alert.channel.properties, + }); + + return; + } + + // Get the org integration + const integration = slackProperties.data.integrationId + ? await this._prisma.organizationIntegration.findUnique({ + where: { + id: slackProperties.data.integrationId, + organizationId: alert.project.organizationId, + }, + include: { + tokenReference: true, + }, + }) + : await this._prisma.organizationIntegration.findFirst({ + where: { + service: "SLACK", + organizationId: alert.project.organizationId, + }, + orderBy: { + createdAt: "desc", + }, + include: { + tokenReference: true, + }, + }); + + if (!integration) { + logger.error("[DeliverAlert] Slack integration not found", { + alert, + }); + + return; + } + + // Get the client + const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration( + integration, + { forceBotToken: true } + ); + + 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] 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; + + try { + const message = await client.chat.postMessage({ + 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: `\`\`\`${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, + }, + }); + } + } + } catch (error) { + logger.error("[DeliverAlert] Failed to send slack message", { + error, + alert, + }); + + throw error; + } + } else { + logger.error("[DeliverAlert] Task run attempt not found", { + alert, + }); + } + + break; + } + case "DEPLOYMENT_FAILURE": { + if (alert.workerDeployment) { + const preparedError = DeploymentPresenter.prepareErrorData( + alert.workerDeployment.errorData + ); + + if (!preparedError) { + logger.error("[DeliverAlert] Failed to prepare deployment error data", { + errorData: alert.workerDeployment.errorData, + }); + + return; + } + + const version = alert.workerDeployment.version; + const environment = alert.environment.slug; + const timestamp = alert.workerDeployment.failedAt ?? new Date(); + + try { + await client.chat.postMessage({ + channel: slackProperties.data.channelId, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:rotating_light: Deployment failed *${version}.${environment}* __`, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Deployment", + }, + url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, + }, + ], + }, + ], + }); + } catch (error) { + logger.error("[DeliverAlert] Failed to send slack message", { + error, + alert, + }); + + throw error; + } + } else { + logger.error("[DeliverAlert] Worker deployment not found", { + alert, + }); + } + + break; + } + case "DEPLOYMENT_SUCCESS": { + if (alert.workerDeployment) { + const version = alert.workerDeployment.version; + const environment = alert.environment.slug; + const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0; + const timestamp = alert.workerDeployment.deployedAt ?? new Date(); + + await client.chat.postMessage({ + channel: slackProperties.data.channelId, + text: `Deployment ${alert.workerDeployment.version} [${alert.environment.slug}] succeeded`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:rocket: Deployed *${version}.${environment}* successfully __`, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Deployment", + }, + url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, + }, + ], + }, + ], + }); + + return; + } else { + logger.error("[DeliverAlert] Worker deployment not found", { + alert, + }); + + return; + } + } + } + } + + async #deliverWebhook(payload: any, webhook: ProjectAlertWebhookProperties) { + const rawPayload = JSON.stringify(payload); + const hashPayload = Buffer.from(rawPayload, "utf-8"); + + const secret = await decryptSecret(env.ENCRYPTION_KEY, webhook.secret); + + const hmacSecret = Buffer.from(secret, "utf-8"); + const key = await subtle.importKey( + "raw", + hmacSecret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await subtle.sign("HMAC", key, hashPayload); + const signatureHex = Buffer.from(signature).toString("hex"); + + // Send the webhook to the URL specified in webhook.url + const response = await fetch(webhook.url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trigger-signature-hmacsha256": signatureHex, + }, + body: rawPayload, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + logger.error("[DeliverAlert] Failed to send alert webhook", { + status: response.status, + statusText: response.statusText, + url: webhook.url, + body: payload, + signature, + }); + + throw new Error(`Failed to send alert webhook to ${webhook.url}`); + } + } + + static async enqueue( + alertId: string, + tx: PrismaClientOrTransaction, + options?: { runAt?: Date; queueName?: string } + ) { + return await workerQueue.enqueue( + "v3.deliverAlert", + { + alertId, + }, + { + tx, + runAt: options?.runAt, + jobKey: `deliverAlert:${alertId}`, + queueName: options?.queueName, + } + ); + } +} diff --git a/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts b/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts new file mode 100644 index 000000000..df2f7017d --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts @@ -0,0 +1,74 @@ +import { ProjectAlertChannel, ProjectAlertType, WorkerDeployment } from "@trigger.dev/database"; +import { $transaction, PrismaClientOrTransaction } from "~/db.server"; +import { workerQueue } from "~/services/worker.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { BaseService } from "../baseService.server"; +import { DeliverAlertService } from "./deliverAlert.server"; + +export class PerformDeploymentAlertsService extends BaseService { + public async call(deploymentId: string) { + const deployment = await this._prisma.workerDeployment.findUnique({ + where: { id: deploymentId }, + }); + + if (!deployment) { + return; + } + + const alertType = + deployment.status === "DEPLOYED" ? "DEPLOYMENT_SUCCESS" : "DEPLOYMENT_FAILURE"; + + // Find all the alert channels + const alertChannels = await this._prisma.projectAlertChannel.findMany({ + where: { + projectId: deployment.projectId, + alertTypes: { + has: alertType, + }, + enabled: true, + }, + }); + + for (const alertChannel of alertChannels) { + await this.#createAndSendAlert(alertChannel, deployment, alertType); + } + } + + async #createAndSendAlert( + alertChannel: ProjectAlertChannel, + deployment: WorkerDeployment, + alertType: ProjectAlertType + ) { + await $transaction(this._prisma, async (tx) => { + const alert = await this._prisma.projectAlert.create({ + data: { + friendlyId: generateFriendlyId("alert"), + channelId: alertChannel.id, + projectId: deployment.projectId, + environmentId: deployment.environmentId, + status: "PENDING", + type: alertType, + workerDeploymentId: deployment.id, + }, + }); + + await DeliverAlertService.enqueue(alert.id, tx, { + queueName: `alert-channel:${alertChannel.id}`, + }); + }); + } + + static async enqueue(deploymentId: string, tx: PrismaClientOrTransaction, runAt?: Date) { + return await workerQueue.enqueue( + "v3.performDeploymentAlerts", + { + deploymentId, + }, + { + tx, + runAt, + jobKey: `performDeploymentAlerts:${deploymentId}`, + } + ); + } +} diff --git a/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts b/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts new file mode 100644 index 000000000..1b1b78b20 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts @@ -0,0 +1,82 @@ +import { Prisma, ProjectAlertChannel } from "@trigger.dev/database"; +import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server"; +import { workerQueue } from "~/services/worker.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { BaseService } from "../baseService.server"; +import { DeliverAlertService } from "./deliverAlert.server"; + +type FoundTaskAttempt = Prisma.Result< + typeof prisma.taskRunAttempt, + { include: { taskRun: true; backgroundWorkerTask: true; runtimeEnvironment: true } }, + "findUniqueOrThrow" +>; + +export class PerformTaskAttemptAlertsService extends BaseService { + public async call(attemptId: string) { + const taskAttempt = await this._prisma.taskRunAttempt.findUnique({ + where: { id: attemptId }, + include: { + taskRun: true, + backgroundWorkerTask: true, + runtimeEnvironment: true, + }, + }); + + if (!taskAttempt) { + return; + } + + if (taskAttempt.runtimeEnvironment.type === "DEVELOPMENT") { + return; + } + + // Find all the alert channels + const alertChannels = await this._prisma.projectAlertChannel.findMany({ + where: { + projectId: taskAttempt.taskRun.projectId, + alertTypes: { + has: "TASK_RUN_ATTEMPT", + }, + enabled: true, + }, + }); + + for (const alertChannel of alertChannels) { + await this.#createAndSendAlert(alertChannel, taskAttempt); + } + } + + async #createAndSendAlert(alertChannel: ProjectAlertChannel, taskAttempt: FoundTaskAttempt) { + await $transaction(this._prisma, async (tx) => { + const alert = await this._prisma.projectAlert.create({ + data: { + friendlyId: generateFriendlyId("alert"), + channelId: alertChannel.id, + projectId: taskAttempt.taskRun.projectId, + environmentId: taskAttempt.runtimeEnvironmentId, + status: "PENDING", + type: "TASK_RUN_ATTEMPT", + taskRunAttemptId: taskAttempt.id, + }, + }); + + await DeliverAlertService.enqueue(alert.id, tx, { + queueName: `alert-channel:${alertChannel.id}`, + }); + }); + } + + static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) { + return await workerQueue.enqueue( + "v3.performTaskAttemptAlerts", + { + attemptId, + }, + { + tx, + runAt, + jobKey: `performTaskAttemptAlerts:${attemptId}`, + } + ); + } +} diff --git a/apps/webapp/app/v3/services/completeAttempt.server.ts b/apps/webapp/app/v3/services/completeAttempt.server.ts index ae16564ea..d18e3f16b 100644 --- a/apps/webapp/app/v3/services/completeAttempt.server.ts +++ b/apps/webapp/app/v3/services/completeAttempt.server.ts @@ -19,6 +19,7 @@ import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.se import { MAX_TASK_RUN_ATTEMPTS } from "~/consts"; import { CreateCheckpointService } from "./createCheckpoint.server"; import { TaskRun } from "@trigger.dev/database"; +import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server"; type FoundAttempt = Awaited>; @@ -154,9 +155,13 @@ export class CompleteAttemptService extends BaseService { }, }); - if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) { - const environment = env ?? (await this.#getEnvironment(execution.environment.id)); + const environment = env ?? (await this.#getEnvironment(execution.environment.id)); + if (environment.type !== "DEVELOPMENT") { + await PerformTaskAttemptAlertsService.enqueue(taskRunAttempt.id, this._prisma); + } + + if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) { const retryAt = new Date(completion.retry.timestamp); // Retry the task run diff --git a/apps/webapp/app/v3/services/createDeployedBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createDeployedBackgroundWorker.server.ts index 9ef6333f3..920fbeb4c 100644 --- a/apps/webapp/app/v3/services/createDeployedBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createDeployedBackgroundWorker.server.ts @@ -9,6 +9,7 @@ import { projectPubSub } from "./projectPubSub.server"; import { marqs } from "~/v3/marqs/index.server"; import { logger } from "~/services/logger.server"; import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy"; +import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; export class CreateDeployedBackgroundWorkerService extends BaseService { public async call( @@ -98,6 +99,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService { } await ExecuteTasksWaitingForDeployService.enqueue(backgroundWorker.id, this._prisma); + await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma); return backgroundWorker; }); diff --git a/apps/webapp/app/v3/services/createOrgIntegration.server.ts b/apps/webapp/app/v3/services/createOrgIntegration.server.ts new file mode 100644 index 000000000..4db6581c9 --- /dev/null +++ b/apps/webapp/app/v3/services/createOrgIntegration.server.ts @@ -0,0 +1,35 @@ +import { OrganizationIntegration } from "@trigger.dev/database"; +import { BaseService } from "./baseService.server"; +import { WebClient } from "@slack/web-api"; +import { env } from "~/env.server"; +import { $transaction } from "~/db.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { generateFriendlyId } from "../friendlyIdentifiers"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; + +export class CreateOrgIntegrationService extends BaseService { + public async call( + userId: string, + orgId: string, + serviceName: string, + code: string + ): Promise { + // Get the org + const org = await this._prisma.organization.findUnique({ + where: { + id: orgId, + members: { + some: { + userId, + }, + }, + }, + }); + + if (!org) { + throw new Error("Organization not found"); + } + + return OrgIntegrationRepository.createOrgIntegration(serviceName, code, org); + } +} diff --git a/apps/webapp/app/v3/services/deploymentIndexFailed.server.ts b/apps/webapp/app/v3/services/deploymentIndexFailed.server.ts index 9fa5c15f8..0c3242688 100644 --- a/apps/webapp/app/v3/services/deploymentIndexFailed.server.ts +++ b/apps/webapp/app/v3/services/deploymentIndexFailed.server.ts @@ -1,3 +1,4 @@ +import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; import { BaseService } from "./baseService.server"; export class DeploymentIndexFailed extends BaseService { @@ -22,6 +23,8 @@ export class DeploymentIndexFailed extends BaseService { }, }); + await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma); + return deployment; } } diff --git a/apps/webapp/app/v3/services/startDeploymentIndexing.server.ts b/apps/webapp/app/v3/services/startDeploymentIndexing.server.ts index 9a0156dba..7d03147e7 100644 --- a/apps/webapp/app/v3/services/startDeploymentIndexing.server.ts +++ b/apps/webapp/app/v3/services/startDeploymentIndexing.server.ts @@ -15,9 +15,10 @@ export class StartDeploymentIndexing extends BaseService { friendlyId: deploymentId, }, data: { - imageReference: registryProxy - ? registryProxy.rewriteImageReference(body.imageReference) - : body.imageReference, + imageReference: + registryProxy && body.selfHosted !== true + ? registryProxy.rewriteImageReference(body.imageReference) + : body.imageReference, status: "DEPLOYING", }, }); diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index d269a750f..c9ed0b52b 100644 --- a/apps/webapp/app/v3/services/timeoutDeployment.server.ts +++ b/apps/webapp/app/v3/services/timeoutDeployment.server.ts @@ -1,6 +1,7 @@ import { logger } from "~/services/logger.server"; import { BaseService } from "./baseService.server"; import { workerQueue } from "~/services/worker.server"; +import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; export class TimeoutDeploymentService extends BaseService { public async call(id: string, fromStatus: string, errorMessage: string) { @@ -32,6 +33,8 @@ export class TimeoutDeploymentService extends BaseService { errorData: { message: errorMessage, name: "TimeoutError" }, }, }); + + await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma); } static async enqueue( diff --git a/apps/webapp/package.json b/apps/webapp/package.json index ab06ac89d..fb7cc4bc7 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -84,6 +84,7 @@ "@remix-run/serve": "2.1.0", "@remix-run/server-runtime": "2.1.0", "@remix-run/v1-meta": "^0.1.3", + "@slack/web-api": "^6.8.1", "@socket.io/redis-adapter": "^8.3.0", "@tabler/icons-react": "^2.39.0", "@tailwindcss/container-queries": "^0.1.1", diff --git a/apps/webapp/public/emails/logo-mono.png b/apps/webapp/public/emails/logo-mono.png index 7de735eb4eca95cdbfbca8d1609865eda630be08..464ce90f9a669b89a7f5b0bcef7188a803f75541 100644 GIT binary patch literal 6835 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�RLGSCmr7*;os)Dg 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 delta 3694 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 M07*qoM6N<$g1>U)>;M1& diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index fa1408b90..f265cf1f0 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -79,6 +79,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({ apiUrl: z.string().optional(), saveLogs: z.boolean().default(false), skipUpdateCheck: z.boolean().default(false), + noCache: z.boolean().default(false), }); type DeployCommandOptions = z.infer; @@ -108,6 +109,12 @@ export function configureDeployCommand(program: Command) { "Build and load the image using your local Docker. Use the --registry option to specify the registry to push the image to when using --self-hosted, or just use --push-image to push to the default registry." ).hideHelp() ) + .addOption( + new CommandOption( + "--no-cache", + "Do not use the cache when building the image. This will slow down the build process but can be useful if you are experiencing issues with the cache." + ).hideHelp() + ) .addOption( new CommandOption( "--push", @@ -291,6 +298,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { buildPlatform: options.buildPlatform, pushImage: options.push, selfHostedRegistry: !!options.registry, + noCache: options.noCache, }); } @@ -316,6 +324,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { projectRef: resolvedConfig.config.project, loadImage: options.loadImage, buildPlatform: options.buildPlatform, + noCache: options.noCache, }, deploymentSpinner ); @@ -389,6 +398,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { deploymentResponse.data.id, { imageReference, + selfHosted: options.selfHosted, } ); @@ -752,6 +762,7 @@ type BuildAndPushImageOptions = { projectRef: string; loadImage: boolean; buildPlatform: string; + noCache: boolean; }; type BuildAndPushImageResults = @@ -795,6 +806,7 @@ async function buildAndPushImage( "build", "-f", "Containerfile", + options.noCache ? "--no-cache" : undefined, "--platform", options.buildPlatform, "--provenance", @@ -920,6 +932,7 @@ async function buildAndPushSelfHostedImage( "build", "-f", "Containerfile", + options.noCache ? "--no-cache" : undefined, "--platform", options.buildPlatform, "--build-arg", @@ -937,7 +950,9 @@ async function buildAndPushSelfHostedImage( ".", // The build context ].filter(Boolean) as string[]; - logger.debug(`docker ${buildArgs.join(" ")}`); + logger.debug(`docker ${buildArgs.join(" ")}`, { + cwd: options.cwd, + }); span.setAttribute("docker.command.build", `docker ${buildArgs.join(" ")}`); diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index c9a1e1551..61ee0575b 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -54,6 +54,33 @@ export function createErrorTaskError(error: TaskRunError): any { } } +export function createJsonErrorObject(error: TaskRunError) { + switch (error.type) { + case "BUILT_IN_ERROR": { + return { + name: error.name, + message: error.message, + stackTrace: error.stackTrace, + }; + } + case "STRING_ERROR": { + return { + message: error.raw, + }; + } + case "CUSTOM_ERROR": { + return { + message: error.raw, + }; + } + case "INTERNAL_ERROR": { + return { + message: `trigger.dev internal error (${error.code})`, + }; + } + } +} + export function correctErrorStackTrace( stackTrace: string, projectDir?: string, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index fc3a1e504..1fb90b6b0 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -115,6 +115,7 @@ export type GetEnvironmentVariablesResponseBody = z.infer< export const StartDeploymentIndexingRequestBody = z.object({ imageReference: z.string(), + selfHosted: z.boolean().optional(), }); export type StartDeploymentIndexingRequestBody = z.infer; diff --git a/packages/database/prisma/migrations/20240425122814_add_alert_schema/migration.sql b/packages/database/prisma/migrations/20240425122814_add_alert_schema/migration.sql new file mode 100644 index 000000000..06ad92b35 --- /dev/null +++ b/packages/database/prisma/migrations/20240425122814_add_alert_schema/migration.sql @@ -0,0 +1,64 @@ +-- CreateEnum +CREATE TYPE "ProjectAlertChannelType" AS ENUM ('EMAIL', 'SLACK', 'WEBHOOK'); + +-- CreateEnum +CREATE TYPE "ProjectAlertType" AS ENUM ('TASK_RUN_ATTEMPT', 'DEPLOYMENT_FAILURE'); + +-- CreateEnum +CREATE TYPE "ProjectAlertStatus" AS ENUM ('PENDING', 'SENT', 'FAILED'); + +-- CreateTable +CREATE TABLE "ProjectAlertChannel" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "type" "ProjectAlertChannelType" NOT NULL, + "name" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "alertTypes" "ProjectAlertType"[], + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectAlertChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectAlert" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "status" "ProjectAlertStatus" NOT NULL DEFAULT 'PENDING', + "type" "ProjectAlertType" NOT NULL, + "taskRunAttemptId" TEXT, + "workerDeploymentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectAlert_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectAlertChannel_friendlyId_key" ON "ProjectAlertChannel"("friendlyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectAlert_friendlyId_key" ON "ProjectAlert"("friendlyId"); + +-- AddForeignKey +ALTER TABLE "ProjectAlertChannel" ADD CONSTRAINT "ProjectAlertChannel_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ProjectAlertChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_taskRunAttemptId_fkey" FOREIGN KEY ("taskRunAttemptId") REFERENCES "TaskRunAttempt"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_workerDeploymentId_fkey" FOREIGN KEY ("workerDeploymentId") REFERENCES "WorkerDeployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20240425131147_add_enabled_flag_to_alert_channels/migration.sql b/packages/database/prisma/migrations/20240425131147_add_enabled_flag_to_alert_channels/migration.sql new file mode 100644 index 000000000..1b43b31bf --- /dev/null +++ b/packages/database/prisma/migrations/20240425131147_add_enabled_flag_to_alert_channels/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectAlertChannel" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/database/prisma/migrations/20240426095144_add_deployment_success_alert_type/migration.sql b/packages/database/prisma/migrations/20240426095144_add_deployment_success_alert_type/migration.sql new file mode 100644 index 000000000..80ee3726b --- /dev/null +++ b/packages/database/prisma/migrations/20240426095144_add_deployment_success_alert_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectAlertType" ADD VALUE 'DEPLOYMENT_SUCCESS'; diff --git a/packages/database/prisma/migrations/20240426095622_add_deduplication_key_to_alert_channels/migration.sql b/packages/database/prisma/migrations/20240426095622_add_deduplication_key_to_alert_channels/migration.sql new file mode 100644 index 000000000..f5254c4a0 --- /dev/null +++ b/packages/database/prisma/migrations/20240426095622_add_deduplication_key_to_alert_channels/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - The required column `deduplicationKey` was added to the `ProjectAlertChannel` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "ProjectAlertChannel" ADD COLUMN "deduplicationKey" TEXT NOT NULL, +ADD COLUMN "userProvidedDeduplicationKey" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/database/prisma/migrations/20240426102405_add_unique_deduplication_index_to_alert_channels/migration.sql b/packages/database/prisma/migrations/20240426102405_add_unique_deduplication_index_to_alert_channels/migration.sql new file mode 100644 index 000000000..47bb2fe97 --- /dev/null +++ b/packages/database/prisma/migrations/20240426102405_add_unique_deduplication_index_to_alert_channels/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[projectId,deduplicationKey]` on the table `ProjectAlertChannel` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "ProjectAlertChannel_projectId_deduplicationKey_key" ON "ProjectAlertChannel"("projectId", "deduplicationKey"); diff --git a/packages/database/prisma/migrations/20240428142050_add_models_for_slack_integration/migration.sql b/packages/database/prisma/migrations/20240428142050_add_models_for_slack_integration/migration.sql new file mode 100644 index 000000000..a4977bc84 --- /dev/null +++ b/packages/database/prisma/migrations/20240428142050_add_models_for_slack_integration/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "IntegrationService" AS ENUM ('SLACK'); + +-- AlterTable +ALTER TABLE "ProjectAlertChannel" ADD COLUMN "integrationId" TEXT; + +-- CreateTable +CREATE TABLE "OrganizationIntegration" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "service" "IntegrationService" NOT NULL, + "integrationData" JSONB, + "tokenReferenceId" TEXT, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationIntegration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationIntegration_friendlyId_key" ON "OrganizationIntegration"("friendlyId"); + +-- AddForeignKey +ALTER TABLE "ProjectAlertChannel" ADD CONSTRAINT "ProjectAlertChannel_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "OrganizationIntegration"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrganizationIntegration" ADD CONSTRAINT "OrganizationIntegration_tokenReferenceId_fkey" FOREIGN KEY ("tokenReferenceId") REFERENCES "SecretReference"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrganizationIntegration" ADD CONSTRAINT "OrganizationIntegration_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20240428150144_org_integration_non_optional_fields/migration.sql b/packages/database/prisma/migrations/20240428150144_org_integration_non_optional_fields/migration.sql new file mode 100644 index 000000000..56d2a83cb --- /dev/null +++ b/packages/database/prisma/migrations/20240428150144_org_integration_non_optional_fields/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - Made the column `integrationData` on table `OrganizationIntegration` required. This step will fail if there are existing NULL values in that column. + - Made the column `tokenReferenceId` on table `OrganizationIntegration` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "OrganizationIntegration" DROP CONSTRAINT "OrganizationIntegration_tokenReferenceId_fkey"; + +-- AlterTable +ALTER TABLE "OrganizationIntegration" ALTER COLUMN "integrationData" SET NOT NULL, +ALTER COLUMN "tokenReferenceId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "OrganizationIntegration" ADD CONSTRAINT "OrganizationIntegration_tokenReferenceId_fkey" FOREIGN KEY ("tokenReferenceId") REFERENCES "SecretReference"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20240507113449_add_alert_storage/migration.sql b/packages/database/prisma/migrations/20240507113449_add_alert_storage/migration.sql new file mode 100644 index 000000000..476f866ca --- /dev/null +++ b/packages/database/prisma/migrations/20240507113449_add_alert_storage/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "ProjectAlertStorage" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "alertChannelId" TEXT NOT NULL, + "alertType" "ProjectAlertType" NOT NULL, + "storageId" TEXT NOT NULL, + "storageData" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectAlertStorage_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ProjectAlertStorage" ADD CONSTRAINT "ProjectAlertStorage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAlertStorage" ADD CONSTRAINT "ProjectAlertStorage_alertChannelId_fkey" FOREIGN KEY ("alertChannelId") REFERENCES "ProjectAlertChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index d36c3061e..7b19c57c5 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -130,12 +130,13 @@ model Organization { events EventRecord[] jobRuns JobRun[] - projects Project[] - members OrgMember[] - invites OrgMemberInvite[] - externalAccounts ExternalAccount[] - integrations Integration[] - sources TriggerSource[] + projects Project[] + members OrgMember[] + invites OrgMemberInvite[] + externalAccounts ExternalAccount[] + integrations Integration[] + sources TriggerSource[] + organizationIntegrations OrganizationIntegration[] } model ExternalAccount { @@ -401,6 +402,7 @@ model RuntimeEnvironment { taskRunAttempts TaskRunAttempt[] CheckpointRestoreEvent CheckpointRestoreEvent[] taskScheduleInstances TaskScheduleInstance[] + alerts ProjectAlert[] sessions RuntimeEnvironmentSession[] currentSession RuntimeEnvironmentSession? @relation("currentSession", fields: [currentSessionId], references: [id], onDelete: SetNull, onUpdate: Cascade) @@ -452,6 +454,9 @@ model Project { WorkerDeployment WorkerDeployment[] CheckpointRestoreEvent CheckpointRestoreEvent[] taskSchedules TaskSchedule[] + alertChannels ProjectAlertChannel[] + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] } enum ProjectVersion { @@ -1129,8 +1134,9 @@ model SecretReference { httpEndpoints TriggerHttpEndpoint[] environmentVariableValues EnvironmentVariableValue[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + OrganizationIntegration OrganizationIntegration[] } enum SecretStoreProvider { @@ -1764,6 +1770,7 @@ model TaskRunAttempt { checkpoints Checkpoint[] batchTaskRunItems BatchTaskRunItem[] CheckpointRestoreEvent CheckpointRestoreEvent[] + alerts ProjectAlert[] @@unique([taskRunId, number]) } @@ -2105,6 +2112,7 @@ model WorkerDeployment { updatedAt DateTime @updatedAt promotions WorkerDeploymentPromotion[] + alerts ProjectAlert[] @@unique([projectId, shortCode]) @@unique([environmentId, version]) @@ -2218,3 +2226,122 @@ model RuntimeEnvironmentSession { currentEnvironments RuntimeEnvironment[] @relation("currentSession") } + +model ProjectAlertChannel { + id String @id @default(cuid()) + + friendlyId String @unique + + ///can be provided and we won't create another with the same key + deduplicationKey String @default(cuid()) + userProvidedDeduplicationKey Boolean @default(false) + + integration OrganizationIntegration? @relation(fields: [integrationId], references: [id], onDelete: SetNull, onUpdate: Cascade) + integrationId String? + + enabled Boolean @default(true) + + type ProjectAlertChannelType + name String + properties Json + alertTypes ProjectAlertType[] + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + + @@unique([projectId, deduplicationKey]) +} + +enum ProjectAlertChannelType { + EMAIL + SLACK + WEBHOOK +} + +model ProjectAlert { + id String @id @default(cuid()) + friendlyId String @unique + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + channel ProjectAlertChannel @relation(fields: [channelId], references: [id], onDelete: Cascade, onUpdate: Cascade) + channelId String + + status ProjectAlertStatus @default(PENDING) + + type ProjectAlertType + + taskRunAttempt TaskRunAttempt? @relation(fields: [taskRunAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskRunAttemptId String? + + workerDeployment WorkerDeployment? @relation(fields: [workerDeploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workerDeploymentId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum ProjectAlertType { + TASK_RUN_ATTEMPT + DEPLOYMENT_FAILURE + DEPLOYMENT_SUCCESS +} + +enum ProjectAlertStatus { + PENDING + SENT + FAILED +} + +model ProjectAlertStorage { + id String @id @default(cuid()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + alertChannel ProjectAlertChannel @relation(fields: [alertChannelId], references: [id], onDelete: Cascade, onUpdate: Cascade) + alertChannelId String + + alertType ProjectAlertType + + storageId String + storageData Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OrganizationIntegration { + id String @id @default(cuid()) + + friendlyId String @unique + + service IntegrationService + + integrationData Json + + tokenReference SecretReference @relation(fields: [tokenReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + tokenReferenceId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alertChannels ProjectAlertChannel[] +} + +enum IntegrationService { + SLACK +} diff --git a/packages/emails/emails/alert-attempt-failure.tsx b/packages/emails/emails/alert-attempt-failure.tsx new file mode 100644 index 000000000..3301d6e24 --- /dev/null +++ b/packages/emails/emails/alert-attempt-failure.tsx @@ -0,0 +1,87 @@ +import { + Body, + CodeBlock, + Container, + Head, + Html, + Link, + Preview, + Text, + dracula, +} from "@react-email/components"; +import { z } from "zod"; +import { Footer } from "./components/Footer"; +import { Image } from "./components/Image"; +import { anchor, container, h1, main, paragraphLight, paragraphTight } from "./components/styles"; + +export const AlertAttemptEmailSchema = z.object({ + email: z.literal("alert-attempt"), + taskIdentifier: z.string(), + fileName: z.string(), + exportName: z.string(), + version: z.string(), + environment: z.string(), + error: z.object({ + message: z.string(), + name: z.string().optional(), + stackTrace: z.string().optional(), + }), + attemptLink: z.string().url(), +}); + +const previewDefaults = { + taskIdentifier: "my-task", + fileName: "other.ts", + exportName: "myTask", + version: "20240101.1", + environment: "prod", + error: { + message: "Error message", + name: "Error name", + stackTrace: "Error stack trace", + }, + attemptLink: "https://trigger.dev", +}; + +export default function Email(props: z.infer) { + const { taskIdentifier, fileName, exportName, version, environment, error, attemptLink } = { + ...previewDefaults, + ...props, + }; + + return ( + + + {`[${version}.${environment} ${taskIdentifier}] ${error.message}`} + + + There's been an error on `{taskIdentifier}` + Task ID: {taskIdentifier} + Filename: {fileName} + Function: {exportName}() + Version: {version} + Environment: {environment} + + {error.message} + {error.stackTrace && ( + + )} + + Investigate this error + + + Trigger.dev +

- + - In your Slack app, create a new channel from the main menu by going to File{" "} - New Channel - - - - - - Name your channel, set its visibility and click 'Create'. - - - - - - Invite this email address to your channel:{" "} + Send us an email to this address from your Trigger.dev account email + address: + + + - As soon as we can, we'll accept your invitation and say hello! + As soon as we can, we'll setup a Slack Connect channel and say hello!
@@ -551,6 +542,8 @@ function V3ProjectSideMenu({ project: SideMenuProject; organization: MatchedOrganization; }) { + const { alertsEnabled } = useFeatures(); + return ( <> @@ -603,13 +596,15 @@ function V3ProjectSideMenu({ to={v3DeploymentsPath(organization, project)} data-action="deployments" /> - {/* */} + {alertsEnabled && ( + + )} ("root"); - return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false }; + return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false, alertsEnabled: false }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx index 4566144a9..60bce8751 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx @@ -1,6 +1,7 @@ import { useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { + ArrowUpRightIcon, BoltIcon, BoltSlashIcon, BookOpenIcon, @@ -15,12 +16,14 @@ import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server- import { SlackIcon } from "@trigger.dev/companyicons"; import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database"; import assertNever from "assert-never"; +import { ExternalLinkIcon } from "lucide-react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { DetailCell } from "~/components/primitives/DetailCell"; +import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { @@ -155,7 +158,7 @@ export default function Page() { Alerts docs @@ -164,16 +167,19 @@ export default function Page() {
-
- - New alert - -
+ {alertChannels.length > 0 && alertChannels.length < 10 && ( +
+ Project alerts + + New alert + +
+ )} @@ -214,14 +220,42 @@ export default function Page() { ) : ( -
- No alerts have been created +
+ + You haven't created any project alerts yet + + + Get alerted when runs or deployments fail, or when deployments succeed. + + + New alert +
)}
+
+ Platform alerts + + Get email notifications when Trigger.dev creates, updates or resolves a platform + incident. + + + Subscribe + +
diff --git a/apps/webapp/app/services/email.server.ts b/apps/webapp/app/services/email.server.ts index 5e37a085f..46f971505 100644 --- a/apps/webapp/app/services/email.server.ts +++ b/apps/webapp/app/services/email.server.ts @@ -20,6 +20,17 @@ const client = singleton( }) ); +const alertsClient = singleton( + "alerts-email-client", + () => + new EmailClient({ + apikey: env.ALERT_RESEND_API_KEY, + imagesBaseUrl: env.APP_ORIGIN, + from: env.ALERT_FROM_EMAIL ?? "noreply@alerts.trigger.dev", + replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev", + }) +); + export async function sendMagicLinkEmail(options: SendEmailOptions): Promise { // Auto redirect when in development mode if (env.NODE_ENV === "development") { @@ -67,3 +78,7 @@ export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: numbe export async function sendEmail(data: DeliverEmail) { return client.send(data); } + +export async function sendAlertEmail(data: DeliverEmail) { + return alertsClient.send(data); +} diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index 3b3ac7641..780af49a4 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -10,7 +10,7 @@ import { ProjectAlertWebhookProperties, } from "~/models/projectAlert.server"; import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; -import { sendEmail } from "~/services/email.server"; +import { sendAlertEmail, sendEmail } from "~/services/email.server"; import { logger } from "~/services/logger.server"; import { decryptSecret } from "~/services/secrets/secretStore.server"; import { workerQueue } from "~/services/worker.server"; @@ -144,7 +144,7 @@ export class DeliverAlertService extends BaseService { return; } - await sendEmail({ + await sendAlertEmail({ email: "alert-attempt", to: emailProperties.data.email, taskIdentifier: alert.taskRunAttempt.taskRun.taskIdentifier, @@ -177,7 +177,7 @@ export class DeliverAlertService extends BaseService { return; } - await sendEmail({ + await sendAlertEmail({ email: "alert-deployment-failure", to: emailProperties.data.email, version: alert.workerDeployment.version, @@ -197,7 +197,7 @@ export class DeliverAlertService extends BaseService { } case "DEPLOYMENT_SUCCESS": { if (alert.workerDeployment) { - await sendEmail({ + await sendAlertEmail({ email: "alert-deployment-success", to: emailProperties.data.email, version: alert.workerDeployment.version, diff --git a/docs/images/v3/troubleshooting-alerts-blank.png b/docs/images/v3/troubleshooting-alerts-blank.png new file mode 100644 index 0000000000000000000000000000000000000000..e7f96a03a23027a3d42a52d660c62c3affffe4e3 GIT binary patch literal 86090 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 literal 0 HcmV?d00001 diff --git a/docs/images/v3/troubleshooting-alerts-disable-delete.png b/docs/images/v3/troubleshooting-alerts-disable-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..bf64de5516d2cf8582e66a82eab76d10e7957d6c GIT binary patch literal 90838 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 literal 0 HcmV?d00001 diff --git a/docs/images/v3/troubleshooting-alerts-modal.png b/docs/images/v3/troubleshooting-alerts-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..357185ff509581701be3255053ae4663e7dce826 GIT binary patch literal 76844 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�dSE#L_sImlw-fEZK`7w$rje?*@h#OiKbHcm; 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 literal 0 HcmV?d00001 diff --git a/docs/mint.json b/docs/mint.json index 9ffb9dee2..13726189d 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -95,13 +95,7 @@ { "group": "Getting Started", "version": "v3 (Developer Preview)", - "pages": [ - "v3/quick-start", - "v3/upgrading-from-v2", - "v3/changelog", - "v3/feature-matrix", - "v3/limits-performance" - ] + "pages": ["v3/quick-start", "v3/upgrading-from-v2", "v3/changelog", "v3/feature-matrix"] }, { "group": "Fundamentals", @@ -113,7 +107,7 @@ "v3/apikeys", { "group": "Task types", - "pages": ["v3/tasks-regular", "v3/tasks-scheduled", "v3/tasks-zod", "v3/tasks-webhooks"] + "pages": ["v3/tasks-regular", "v3/tasks-scheduled"] }, "v3/trigger-config" ] @@ -158,12 +152,7 @@ "v3/machines", "v3/idempotency", "v3/reattempting-replaying", - "v3/trigger-filters", - "v3/notifications", - "v3/rollbacks", - "v3/using-apis", - "v3/middleware", - "v3/automated-tests" + "v3/notifications" ] }, { @@ -189,77 +178,29 @@ "v3/management-deactivate-schedule", "v3/management-activate-schedule" ] - }, - { - "group": "Functions", - "pages": [ - "v3/reference-task", - "v3/reference-cron-task", - "v3/reference-cron-dynamic", - "v3/reference-interval-task", - "v3/reference-interval-dynamic", - "v3/reference-zod-task", - "v3/reference-zod-catalog", - "v3/reference-task-trigger", - "v3/reference-task-trigger-and-wait", - "v3/reference-task-batch-trigger", - "v3/reference-task-batch-trigger-and-wait", - "v3/reference-wait-for", - "v3/reference-wait-until", - "v3/reference-wait-for-event", - "v3/reference-wait-for-request", - "v3/reference-retry-on-throw", - "v3/reference-retry-fetch", - "v3/reference-retry-intercept-fetch", - "v3/reference-notification-catalog", - "v3/reference-notify", - "v3/reference-queue" - ] - }, - { - "group": "Objects", - "pages": ["v3/reference-context"] - }, - { - "group": "CLI", - "pages": [ - "v3/reference-cli-init", - "v3/reference-cli-dev", - "v3/reference-cli-deploy", - "v3/reference-cli-login", - "v3/reference-cli-logout", - "v3/reference-cli-update", - "v3/reference-cli-build", - "v3/reference-cli-who-am-i" - ] } ] }, - { - "group": "Architecture", - "version": "v3 (Developer Preview)", - "pages": [ - "v3/architecture-how-it-works", - "v3/architecture-multi-tenant-queue", - "v3/architecture-reliability" - ] - }, { "group": "Open source", "version": "v3 (Developer Preview)", "pages": ["v3/github-repo", "v3/open-source-self-hosting", "v3/open-source-contributing"] }, { - "group": "Help", + "group": "Troubleshooting", "version": "v3 (Developer Preview)", "pages": [ - "v3/help-faqs", - "v3/community", - "v3/help-email", - "v3/help-slack", - "v3/help-uptime-status" + "v3/troubleshooting-alerts", + "v3/troubleshooting-uptime-status", + "v3/troubleshooting-github-issues", + "v3/troubleshooting-github-discussions" ] }, + { + "group": "Help", + "version": "v3 (Developer Preview)", + "pages": ["v3/community", "v3/help-slack", "v3/help-email"] + }, { "group": "Getting Started", "version": "v2", diff --git a/docs/v3/architecture-how-it-works.mdx b/docs/v3/architecture-how-it-works.mdx deleted file mode 100644 index 728e16707..000000000 --- a/docs/v3/architecture-how-it-works.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "Architecture: How it works" -sidebarTitle: "How it works" -description: "An overview of how Trigger.dev v3 works under the hood." ---- - - diff --git a/docs/v3/architecture-multi-tenant-queue.mdx b/docs/v3/architecture-multi-tenant-queue.mdx deleted file mode 100644 index b775b401c..000000000 --- a/docs/v3/architecture-multi-tenant-queue.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "Architecture: Multi-tenant queue" -sidebarTitle: "Multi-tenant queue" -description: "We built a reliable and fair multi-tenant queue that controls triggering all tasks." ---- - - diff --git a/docs/v3/architecture-reliability.mdx b/docs/v3/architecture-reliability.mdx deleted file mode 100644 index 24bf1474a..000000000 --- a/docs/v3/architecture-reliability.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "Architecture: Reliability" -sidebarTitle: "Reliability" -description: "How reliability is achieved with Trigger.dev." ---- - - diff --git a/docs/v3/automated-tests.mdx b/docs/v3/automated-tests.mdx deleted file mode 100644 index 8c6c6cee1..000000000 --- a/docs/v3/automated-tests.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Automated tests" -description: "Write automated tests of your tasks." ---- - - diff --git a/docs/v3/community.mdx b/docs/v3/community.mdx index 08c6e8242..7166c5b5b 100644 --- a/docs/v3/community.mdx +++ b/docs/v3/community.mdx @@ -1,5 +1,5 @@ --- -title: "Community" +title: "Discord Community" url: "https://trigger.dev/discord" --- diff --git a/docs/v3/feature-matrix.mdx b/docs/v3/feature-matrix.mdx index 67e9211b2..119fbcd96 100644 --- a/docs/v3/feature-matrix.mdx +++ b/docs/v3/feature-matrix.mdx @@ -17,7 +17,6 @@ description: "What features are currently available in the Developer Preview" | Alerts | Add alerts in the UI for errors and deploys | ✅ | | [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | ✅ | | [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | ⏳ | -| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ | | Full text search of runs | Find a run by searching the payload and output | ⏳ | | Logs view with search | All logs view with filtering and full text search | ⏳ | | Notifications | Send data to your web app from a run | ⏳ | diff --git a/docs/v3/help-faqs.mdx b/docs/v3/help-faqs.mdx deleted file mode 100644 index 27a327d3c..000000000 --- a/docs/v3/help-faqs.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Frequently Asked Questions" -sidebarTitle: "FAQs" ---- - - diff --git a/docs/v3/help-slack.mdx b/docs/v3/help-slack.mdx index 9edad7a02..6388cb66b 100644 --- a/docs/v3/help-slack.mdx +++ b/docs/v3/help-slack.mdx @@ -1,5 +1,5 @@ --- -title: "Slack" +title: "Slack support" --- If you have a paid Trigger.dev account, you can request a private Slack Connect channel. diff --git a/docs/v3/help-uptime-status.mdx b/docs/v3/help-uptime-status.mdx deleted file mode 100644 index 9774c655b..000000000 --- a/docs/v3/help-uptime-status.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Uptime status" -url: "https://trigger.openstatus.dev/" ---- - -View the [current Trigger.dev Cloud system status](https://trigger.openstatus.dev/). diff --git a/docs/v3/limits-performance.mdx b/docs/v3/limits-performance.mdx deleted file mode 100644 index cf985e957..000000000 --- a/docs/v3/limits-performance.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Limits & Performance" ---- - - diff --git a/docs/v3/middleware.mdx b/docs/v3/middleware.mdx deleted file mode 100644 index f1dee48ac..000000000 --- a/docs/v3/middleware.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Middleware" -description: "This function is called before the `run` function, it allows you to wrap the run function with custom code." ---- - - diff --git a/docs/v3/reference-cli-build.mdx b/docs/v3/reference-cli-build.mdx deleted file mode 100644 index 03876fecd..000000000 --- a/docs/v3/reference-cli-build.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev build" -sidebarTitle: "build" -description: "This command will build your tasks." ---- - - diff --git a/docs/v3/reference-cli-deploy.mdx b/docs/v3/reference-cli-deploy.mdx deleted file mode 100644 index d9fd782c4..000000000 --- a/docs/v3/reference-cli-deploy.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev deploy" -sidebarTitle: "deploy" -description: "This command will deploy your tasks." ---- - - diff --git a/docs/v3/reference-cli-dev.mdx b/docs/v3/reference-cli-dev.mdx deleted file mode 100644 index fc822b6dd..000000000 --- a/docs/v3/reference-cli-dev.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev dev" -sidebarTitle: "dev" -description: "This command runs your tasks locally." ---- - - diff --git a/docs/v3/reference-cli-init.mdx b/docs/v3/reference-cli-init.mdx deleted file mode 100644 index 65fc1840f..000000000 --- a/docs/v3/reference-cli-init.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev init" -sidebarTitle: "init" -description: "This command will setup your v3 project." ---- - - diff --git a/docs/v3/reference-cli-login.mdx b/docs/v3/reference-cli-login.mdx deleted file mode 100644 index d8c24f97c..000000000 --- a/docs/v3/reference-cli-login.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev login" -sidebarTitle: "login" -description: "This command will log you in to the CLI. Required to run any other command." ---- - - diff --git a/docs/v3/reference-cli-logout.mdx b/docs/v3/reference-cli-logout.mdx deleted file mode 100644 index 1ca1453f9..000000000 --- a/docs/v3/reference-cli-logout.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev logout" -sidebarTitle: "logout" -description: "This command will log you out of the CLI." ---- - - diff --git a/docs/v3/reference-cli-update.mdx b/docs/v3/reference-cli-update.mdx deleted file mode 100644 index f526a77a0..000000000 --- a/docs/v3/reference-cli-update.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev update" -sidebarTitle: "update" -description: "This command can be used to update all of your trigger.dev packages to the latest versions." ---- - - diff --git a/docs/v3/reference-cli-who-am-i.mdx b/docs/v3/reference-cli-who-am-i.mdx deleted file mode 100644 index c23acbdc9..000000000 --- a/docs/v3/reference-cli-who-am-i.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "trigger.dev whoami" -sidebarTitle: "whoami" -description: "This command will return information about you, the logged in user." ---- - - diff --git a/docs/v3/reference-context.mdx b/docs/v3/reference-context.mdx deleted file mode 100644 index 9b869e270..000000000 --- a/docs/v3/reference-context.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Context" -description: "The Context object is part of the `run` function parameter and provides information about the current run." ---- - - diff --git a/docs/v3/reference-cron-dynamic.mdx b/docs/v3/reference-cron-dynamic.mdx deleted file mode 100644 index e104b9165..000000000 --- a/docs/v3/reference-cron-dynamic.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "cron.dynamic()" -description: "Trigger a task with many different CRON schedules. For example you can use this to let your users select when they want a reminder." ---- - - diff --git a/docs/v3/reference-cron-task.mdx b/docs/v3/reference-cron-task.mdx deleted file mode 100644 index c8b8ef9f5..000000000 --- a/docs/v3/reference-cron-task.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "cron.task()" -description: "Trigger a task on a recurring schedule using a CRON expression." ---- - - diff --git a/docs/v3/reference-interval-dynamic.mdx b/docs/v3/reference-interval-dynamic.mdx deleted file mode 100644 index 140189dab..000000000 --- a/docs/v3/reference-interval-dynamic.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "interval.dynamic()" -description: "Trigger a task with many different interval schedules. For example you can use this to let your users select how often they want a reminder." ---- - - diff --git a/docs/v3/reference-interval-task.mdx b/docs/v3/reference-interval-task.mdx deleted file mode 100644 index edbb0496a..000000000 --- a/docs/v3/reference-interval-task.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "interval.task()" -description: "Trigger a task on a recurring schedule using the time interval you want between runs." ---- - - diff --git a/docs/v3/reference-notification-catalog.mdx b/docs/v3/reference-notification-catalog.mdx deleted file mode 100644 index 258d93737..000000000 --- a/docs/v3/reference-notification-catalog.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "notification.catalog()" -description: "Create a set of events that can be emitted from your tasks. These can be subscribed to from your application to provide real-time updates to your users." ---- - - diff --git a/docs/v3/reference-notify.mdx b/docs/v3/reference-notify.mdx deleted file mode 100644 index b905af74b..000000000 --- a/docs/v3/reference-notify.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "notify()" -description: "Send a notification from your tasks. These can be subscribed to from your application to provide real-time updates to your users." ---- - - diff --git a/docs/v3/reference-queue.mdx b/docs/v3/reference-queue.mdx deleted file mode 100644 index f6a565690..000000000 --- a/docs/v3/reference-queue.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "queue()" -description: "Create queue settings that can be used when triggering a task." ---- - - diff --git a/docs/v3/reference-retry-fetch.mdx b/docs/v3/reference-retry-fetch.mdx deleted file mode 100644 index ede4b00db..000000000 --- a/docs/v3/reference-retry-fetch.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "retry.fetch()" -description: "Inside a task, do a fetch request that will retry (you can specify the retry conditions)." ---- - - diff --git a/docs/v3/reference-retry-intercept-fetch.mdx b/docs/v3/reference-retry-intercept-fetch.mdx deleted file mode 100644 index 68ced1ba7..000000000 --- a/docs/v3/reference-retry-intercept-fetch.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "retry.interceptFetch()" -description: "Useful when writing automated tests – it will intercept matching HTTP requests and respond with what you provide." ---- - - diff --git a/docs/v3/reference-retry-on-throw.mdx b/docs/v3/reference-retry-on-throw.mdx deleted file mode 100644 index 4dd2f36c4..000000000 --- a/docs/v3/reference-retry-on-throw.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "retry.onThrow()" -description: "Inside a task, retry the wrapped code if it throws an error." ---- - - diff --git a/docs/v3/reference-task-batch-trigger-and-wait.mdx b/docs/v3/reference-task-batch-trigger-and-wait.mdx deleted file mode 100644 index e8402ba8e..000000000 --- a/docs/v3/reference-task-batch-trigger-and-wait.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "task.batchTriggerAndWait()" -description: "Trigger a task many times at once from inside another task, and wait for all the results." ---- - - diff --git a/docs/v3/reference-task-batch-trigger.mdx b/docs/v3/reference-task-batch-trigger.mdx deleted file mode 100644 index 209ac4701..000000000 --- a/docs/v3/reference-task-batch-trigger.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "task.batchTrigger()" -description: "Trigger a task many times at once from your code." ---- - - diff --git a/docs/v3/reference-task-trigger-and-wait.mdx b/docs/v3/reference-task-trigger-and-wait.mdx deleted file mode 100644 index ec98fda17..000000000 --- a/docs/v3/reference-task-trigger-and-wait.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "task.triggerAndWait()" -description: "Trigger a task from inside another task, and wait for the result." ---- - - diff --git a/docs/v3/reference-task-trigger.mdx b/docs/v3/reference-task-trigger.mdx deleted file mode 100644 index 09fe9b944..000000000 --- a/docs/v3/reference-task-trigger.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "task.trigger()" -description: "Trigger a task from your code." ---- - - diff --git a/docs/v3/reference-task.mdx b/docs/v3/reference-task.mdx deleted file mode 100644 index e88b32fb6..000000000 --- a/docs/v3/reference-task.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "task()" -description: "The task() function is the simplest way to create a long-running task." ---- - - diff --git a/docs/v3/reference-wait-for-event.mdx b/docs/v3/reference-wait-for-event.mdx deleted file mode 100644 index d2123c54d..000000000 --- a/docs/v3/reference-wait-for-event.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "wait.forEvent()" -description: "Inside a task, wait until a specific event is received before continuing." ---- - - diff --git a/docs/v3/reference-wait-for-request.mdx b/docs/v3/reference-wait-for-request.mdx deleted file mode 100644 index 63df35636..000000000 --- a/docs/v3/reference-wait-for-request.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "wait.forRequest()" -description: "Inside a task, wait until a specific request is received before continuing." ---- - - diff --git a/docs/v3/reference-wait-for.mdx b/docs/v3/reference-wait-for.mdx deleted file mode 100644 index a0ea793c9..000000000 --- a/docs/v3/reference-wait-for.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "wait.for()" -description: "Inside a task, wait for a period of time before continuing." ---- - - diff --git a/docs/v3/reference-wait-until.mdx b/docs/v3/reference-wait-until.mdx deleted file mode 100644 index fda6673cc..000000000 --- a/docs/v3/reference-wait-until.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "wait.until()" -description: "Inside a task, wait until the specified date before continuing." ---- - - diff --git a/docs/v3/reference-zod-catalog.mdx b/docs/v3/reference-zod-catalog.mdx deleted file mode 100644 index 17350b192..000000000 --- a/docs/v3/reference-zod-catalog.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "zod.catalog()" -description: "Create a set of events with names and payloads that are parsed using Zod schemas." ---- - - diff --git a/docs/v3/reference-zod-task.mdx b/docs/v3/reference-zod-task.mdx deleted file mode 100644 index 48aca5020..000000000 --- a/docs/v3/reference-zod-task.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "zod.task()" -description: "A task where the payload is parsed using a zod schema." ---- - - diff --git a/docs/v3/rollbacks.mdx b/docs/v3/rollbacks.mdx deleted file mode 100644 index 94600a7fc..000000000 --- a/docs/v3/rollbacks.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Rollbacks" -description: "You can rollback changes when errors happen, to give transactional guarantees to your operations." ---- - - diff --git a/docs/v3/tasks-overview.mdx b/docs/v3/tasks-overview.mdx index 09ec23ab7..24f09531a 100644 --- a/docs/v3/tasks-overview.mdx +++ b/docs/v3/tasks-overview.mdx @@ -59,7 +59,7 @@ This is used to identify your task so it can be triggered, managed, and you can Your custom code inside `run()` will be executed when your task is triggered. It’s an async function that has two arguments: 1. The run payload - the data that you pass to the task when you trigger it. -2. An object with `ctx` about the run ([Context](/v3/reference-context)), and any output from the optional `init` function that runs before every run attempt. +2. An object with `ctx` about the run (Context), and any output from the optional `init` function that runs before every run attempt. Anything you return from the `run` function will be the result of the task. Data you return must be JSON serializable: strings, numbers, booleans, arrays, objects, and null. @@ -85,7 +85,7 @@ export const taskWithRetries = task({ }); ``` -For more information read [the retrying guide](/v3/retrying), or see the [SDK reference](/v3/reference-task). +For more information read [the retrying guide](/v3/retrying). It's also worth mentioning that you can [retry a block of code](/v3/retrying) inside your tasks as well. diff --git a/docs/v3/tasks-regular.mdx b/docs/v3/tasks-regular.mdx index c9fb4d233..6f48a0fa3 100644 --- a/docs/v3/tasks-regular.mdx +++ b/docs/v3/tasks-regular.mdx @@ -19,7 +19,7 @@ Sometimes OpenAI calls can take a long time to complete, or they can fail. This This example uses Resend to send a sequence of emails over several days. -Each email is wrapped in [retry.onThrow](/v3/reference-retry-on-throw). This will retry the block of code if an error is thrown. This is useful when you don't want to retry the whole task, but just a part of it. The entire task will use the default retrying, so can also retry. +Each email is wrapped in `retry.onThrow`. This will retry the block of code if an error is thrown. This is useful when you don't want to retry the whole task, but just a part of it. The entire task will use the default retrying, so can also retry. Additionally this task uses `wait.for` to wait for a certain amount of time before sending the next email. During the waiting time, the task will be paused and will not consume any resources. diff --git a/docs/v3/tasks-webhooks.mdx b/docs/v3/tasks-webhooks.mdx deleted file mode 100644 index acd20108f..000000000 --- a/docs/v3/tasks-webhooks.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Webhook tasks" -description: "A task that is triggered when a webhook is received from an API." ---- - -## Built-in webhooks triggers - - - -## How to manually use Trigger.dev with webhooks - - diff --git a/docs/v3/tasks-zod.mdx b/docs/v3/tasks-zod.mdx deleted file mode 100644 index 0c80b6877..000000000 --- a/docs/v3/tasks-zod.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Zod tasks" -description: "You can use Zod to define a catalog of events and then attach those events to your tasks." ---- - - diff --git a/docs/v3/trigger-filters.mdx b/docs/v3/trigger-filters.mdx deleted file mode 100644 index 42ceb71a3..000000000 --- a/docs/v3/trigger-filters.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Trigger filters" -description: "You can add filters to your tasks so they're only triggered when certain conditions are met." ---- - - diff --git a/docs/v3/troubleshooting-alerts.mdx b/docs/v3/troubleshooting-alerts.mdx new file mode 100644 index 000000000..d7cc4635a --- /dev/null +++ b/docs/v3/troubleshooting-alerts.mdx @@ -0,0 +1,29 @@ +--- +title: "Alerts" +description: "Get alerted when runs or deployments fail, or when deployments succeed." +--- + + + + +Click on "Alerts" in the left hand side menu, then click on "New alert" to open the new alert modal. + ![Email alerts](/images/v3/troubleshooting-alerts-blank.png) + + + +Choose to be notified by email, Slack notification or webhook whenever: + +- a run fails +- a deployment fails +- a deployment succeeds + + ![Email alerts](/images/v3/troubleshooting-alerts-modal.png) + + + +Click on the triple dot menu on the right side of the table row and select "Disable" or "Delete". + + ![Disable and delete alerts](/images/v3/troubleshooting-alerts-disable-delete.png) + + + diff --git a/docs/v3/troubleshooting-github-discussions.mdx b/docs/v3/troubleshooting-github-discussions.mdx new file mode 100644 index 000000000..0b82ec0b5 --- /dev/null +++ b/docs/v3/troubleshooting-github-discussions.mdx @@ -0,0 +1,6 @@ +--- +title: "GitHub Discussions" +url: "https://github.com/triggerdotdev/trigger.dev/discussions" +--- + +Please [join our community on Discord](https://github.com/triggerdotdev/trigger.dev/discussions) to ask questions, share your projects, and get help from other developers. diff --git a/docs/v3/troubleshooting-github-issues.mdx b/docs/v3/troubleshooting-github-issues.mdx new file mode 100644 index 000000000..51052d4d5 --- /dev/null +++ b/docs/v3/troubleshooting-github-issues.mdx @@ -0,0 +1,6 @@ +--- +title: "GitHub Issues" +url: "https://github.com/triggerdotdev/trigger.dev/issues" +--- + +Please [join our community on Discord](https://github.com/triggerdotdev/trigger.dev/issues) to ask questions, share your projects, and get help from other developers. diff --git a/docs/v3/troubleshooting-uptime-status.mdx b/docs/v3/troubleshooting-uptime-status.mdx new file mode 100644 index 000000000..0890887f5 --- /dev/null +++ b/docs/v3/troubleshooting-uptime-status.mdx @@ -0,0 +1,6 @@ +--- +title: "Uptime Status" +--- + +Get email notifications when Trigger.dev creates, updates or resolves a platform incident. +[Subscribe](https://status.trigger.dev/) diff --git a/docs/v3/using-apis.mdx b/docs/v3/using-apis.mdx deleted file mode 100644 index f298e1c25..000000000 --- a/docs/v3/using-apis.mdx +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: "Using APIs" -description: "You can use any Node.js library inside the run function, or do HTTP requests." ---- - -## Using Node.js SDKs - - - -## Using fetch or axios - - - -## Using webhooks - - From ba61bfe3b96a30b8bbcff4974bec33edd4f80db9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 16 May 2024 17:30:21 +0100 Subject: [PATCH 51/61] Fixed feedback panel links (status page and docs) --- apps/webapp/app/components/Feedback.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx index 50af57e0e..6caec8308 100644 --- a/apps/webapp/app/components/Feedback.tsx +++ b/apps/webapp/app/components/Feedback.tsx @@ -115,14 +115,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) { Docs
- v3 Docs (Developer preview) - - From c9733f357fec68c767642b9f2ead7fc72ec7f973 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 16 May 2024 17:35:44 +0100 Subject: [PATCH 52/61] Fix issues with special characters in queue/task names causing runs to get stuck in queued --- .../webapp/app/v3/marqs/devQueueConsumer.server.ts | 14 ++++++++++++-- .../app/v3/marqs/sharedQueueConsumer.server.ts | 11 ++++++++--- references/v3-catalog/src/trigger/simple.ts | 9 +++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts index 7fc7224b7..90ffcd92d 100644 --- a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts @@ -12,7 +12,7 @@ import { prisma } from "~/db.server"; import { createNewSession, disconnectSession } from "~/models/runtimeEnvironment.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { marqs } from "~/v3/marqs/index.server"; +import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server"; import { EnvironmentVariablesRepository } from "../environmentVariables/environmentVariablesRepository.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { CancelAttemptService } from "../services/cancelAttempt.server"; @@ -452,11 +452,21 @@ export class DevQueueConsumer { const queue = await prisma.taskQueue.findUnique({ where: { - runtimeEnvironmentId_name: { runtimeEnvironmentId: this.env.id, name: lockedTaskRun.queue }, + runtimeEnvironmentId_name: { + runtimeEnvironmentId: this.env.id, + name: sanitizeQueueName(lockedTaskRun.queue), + }, }, }); if (!queue) { + logger.debug("[DevQueueConsumer] Failed to find queue", { + queueName: lockedTaskRun.queue, + sanitizedName: sanitizeQueueName(lockedTaskRun.queue), + taskRun: lockedTaskRun.id, + messageId: message.messageId, + }); + await marqs?.nackMessage(message.messageId); setTimeout(() => this.#doWork(), 1000); return; diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 399634ae9..d56af33aa 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -21,7 +21,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; -import { marqs } from "~/v3/marqs/index.server"; +import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server"; import { EnvironmentVariablesRepository } from "../environmentVariables/environmentVariablesRepository.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { socketIo } from "../handleSocketIo.server"; @@ -408,7 +408,7 @@ export class SharedQueueConsumer { where: { runtimeEnvironmentId_name: { runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, - name: lockedTaskRun.queue, + name: sanitizeQueueName(lockedTaskRun.queue), }, }, }); @@ -635,12 +635,17 @@ export class SharedQueueConsumer { where: { runtimeEnvironmentId_name: { runtimeEnvironmentId: resumableAttempt.runtimeEnvironmentId, - name: resumableRun.queue, + name: sanitizeQueueName(resumableRun.queue), }, }, }); if (!queue) { + logger.debug("SharedQueueConsumer queue not found, so nacking message", { + queueName: sanitizeQueueName(resumableRun.queue), + attempt: resumableAttempt, + }); + await this.#nackAndDoMoreWork(message.messageId, this._options.nextTickInterval); return; } diff --git a/references/v3-catalog/src/trigger/simple.ts b/references/v3-catalog/src/trigger/simple.ts index ff767e284..da727e750 100644 --- a/references/v3-catalog/src/trigger/simple.ts +++ b/references/v3-catalog/src/trigger/simple.ts @@ -17,6 +17,15 @@ export const simplestTask = task({ }, }); +export const taskWithSpecialCharacters = task({ + id: "admin:special-characters", + run: async (payload: { url: string }) => { + return { + message: "This task has special characters in its ID", + }; + }, +}); + export const createJsonHeroDoc = task({ id: "create-jsonhero-doc", run: async (payload: { title: string; content: any }, { ctx }) => { From a7ca1222d774d79d00998543a98a6a08edeeea76 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 May 2024 19:00:06 +0100 Subject: [PATCH 53/61] chore: Update version for release (beta) (#1104) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 2 ++ integrations/airtable/CHANGELOG.md | 7 +++++++ integrations/airtable/package.json | 6 +++--- integrations/github/CHANGELOG.md | 7 +++++++ integrations/github/package.json | 6 +++--- integrations/linear/CHANGELOG.md | 7 +++++++ integrations/linear/package.json | 6 +++--- integrations/openai/CHANGELOG.md | 7 +++++++ integrations/openai/package.json | 6 +++--- integrations/plain/CHANGELOG.md | 7 +++++++ integrations/plain/package.json | 6 +++--- integrations/replicate/CHANGELOG.md | 7 +++++++ integrations/replicate/package.json | 6 +++--- integrations/resend/CHANGELOG.md | 7 +++++++ integrations/resend/package.json | 6 +++--- integrations/sendgrid/CHANGELOG.md | 7 +++++++ integrations/sendgrid/package.json | 6 +++--- integrations/shopify/CHANGELOG.md | 7 +++++++ integrations/shopify/package.json | 6 +++--- integrations/slack/CHANGELOG.md | 6 ++++++ integrations/slack/package.json | 4 ++-- integrations/stripe/CHANGELOG.md | 7 +++++++ integrations/stripe/package.json | 6 +++--- integrations/supabase/CHANGELOG.md | 7 +++++++ integrations/supabase/package.json | 6 +++--- integrations/typeform/CHANGELOG.md | 7 +++++++ integrations/typeform/package.json | 6 +++--- packages/astro/CHANGELOG.md | 6 ++++++ packages/astro/package.json | 4 ++-- packages/cli-v3/CHANGELOG.md | 8 ++++++++ packages/cli-v3/package.json | 4 ++-- packages/cli/CHANGELOG.md | 8 ++++++++ packages/cli/package.json | 2 +- packages/core-apps/CHANGELOG.md | 2 ++ packages/core-apps/package.json | 2 +- packages/core-backend/CHANGELOG.md | 2 ++ packages/core-backend/package.json | 2 +- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- packages/eslint-plugin/CHANGELOG.md | 2 ++ packages/eslint-plugin/package.json | 2 +- packages/express/CHANGELOG.md | 6 ++++++ packages/express/package.json | 6 +++--- packages/hono/CHANGELOG.md | 6 ++++++ packages/hono/package.json | 4 ++-- packages/integration-kit/CHANGELOG.md | 7 +++++++ packages/integration-kit/package.json | 2 +- packages/nestjs/CHANGELOG.md | 6 ++++++ packages/nestjs/package.json | 6 +++--- packages/nextjs/CHANGELOG.md | 6 ++++++ packages/nextjs/package.json | 4 ++-- packages/otlp-importer/CHANGELOG.md | 2 ++ packages/otlp-importer/package.json | 2 +- packages/react/CHANGELOG.md | 7 +++++++ packages/react/package.json | 4 ++-- packages/remix/CHANGELOG.md | 6 ++++++ packages/remix/package.json | 4 ++-- packages/sveltekit/CHANGELOG.md | 6 ++++++ packages/sveltekit/package.json | 4 ++-- packages/testing/CHANGELOG.md | 8 ++++++++ packages/testing/package.json | 2 +- packages/trigger-sdk/CHANGELOG.md | 8 ++++++++ packages/trigger-sdk/package.json | 6 +++--- packages/yalt/CHANGELOG.md | 2 ++ packages/yalt/package.json | 2 +- 65 files changed, 266 insertions(+), 70 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 8a136b51c..dbb68a0c6 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -118,8 +118,10 @@ "tender-moose-tell", "tender-oranges-rhyme", "thin-parents-heal", + "thirty-islands-kiss", "tidy-balloons-suffer", "tidy-dryers-sleep", + "tidy-tomatoes-explain", "tiny-doors-type", "tiny-elephants-scream", "tricky-bulldogs-heal", diff --git a/integrations/airtable/CHANGELOG.md b/integrations/airtable/CHANGELOG.md index 8962c00a4..466ac9188 100644 --- a/integrations/airtable/CHANGELOG.md +++ b/integrations/airtable/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/airtable +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/airtable/package.json b/integrations/airtable/package.json index 71717362f..9ec82eb2b 100644 --- a/integrations/airtable/package.json +++ b/integrations/airtable/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/airtable", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for airtable", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,8 +25,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "airtable": "^0.12.1", "zod": "3.22.3" }, diff --git a/integrations/github/CHANGELOG.md b/integrations/github/CHANGELOG.md index a7c281992..eb4354c24 100644 --- a/integrations/github/CHANGELOG.md +++ b/integrations/github/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/github +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/github/package.json b/integrations/github/package.json index 6cd505d58..f2b38569d 100644 --- a/integrations/github/package.json +++ b/integrations/github/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/github", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The official GitHub integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -30,8 +30,8 @@ "@octokit/request-error": "^5.0.1", "@octokit/webhooks": "^12.0.10", "octokit": "^3.1.2", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "zod": "3.22.3" }, "engines": { diff --git a/integrations/linear/CHANGELOG.md b/integrations/linear/CHANGELOG.md index 99ef62cb9..db5c488e6 100644 --- a/integrations/linear/CHANGELOG.md +++ b/integrations/linear/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/linear +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/linear/package.json b/integrations/linear/package.json index 6077d8a42..d4a8d04b9 100644 --- a/integrations/linear/package.json +++ b/integrations/linear/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/linear", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for @linear/sdk", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@linear/sdk": "^8.0.0", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "zod": "3.22.3" }, "engines": { diff --git a/integrations/openai/CHANGELOG.md b/integrations/openai/CHANGELOG.md index 3a96ff94c..c2c7b97a7 100644 --- a/integrations/openai/CHANGELOG.md +++ b/integrations/openai/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/slack +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/openai/package.json b/integrations/openai/package.json index 1b71f86c3..ce448da0f 100644 --- a/integrations/openai/package.json +++ b/integrations/openai/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/openai", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The official OpenAI integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,8 +42,8 @@ }, "dependencies": { "openai": "^4.16.1", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33" }, "engines": { "node": ">=18.0.0" diff --git a/integrations/plain/CHANGELOG.md b/integrations/plain/CHANGELOG.md index 5cf1b2db2..9194e83d1 100644 --- a/integrations/plain/CHANGELOG.md +++ b/integrations/plain/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/plain +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/plain/package.json b/integrations/plain/package.json index 6d5a8d360..c3c51b5bb 100644 --- a/integrations/plain/package.json +++ b/integrations/plain/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/plain", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The official Plain.com integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,8 +24,8 @@ "build:tsup": "tsup" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "@team-plain/typescript-sdk": "^2.7.0" }, "engines": { diff --git a/integrations/replicate/CHANGELOG.md b/integrations/replicate/CHANGELOG.md index c65191235..7c641a12f 100644 --- a/integrations/replicate/CHANGELOG.md +++ b/integrations/replicate/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/replicate +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/replicate/package.json b/integrations/replicate/package.json index 82f870290..68ba6e34f 100644 --- a/integrations/replicate/package.json +++ b/integrations/replicate/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/replicate", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for replicate", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,8 +25,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "replicate": "^0.18.1", "zod": "3.22.3" }, diff --git a/integrations/resend/CHANGELOG.md b/integrations/resend/CHANGELOG.md index 3b8fb3668..ef2e919f7 100644 --- a/integrations/resend/CHANGELOG.md +++ b/integrations/resend/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/resend +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/resend/package.json b/integrations/resend/package.json index 76969eea3..5e1d0fe70 100644 --- a/integrations/resend/package.json +++ b/integrations/resend/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/resend", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The official Resend.com integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,8 +24,8 @@ "build:tsup": "tsup" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "resend": "^2.1.0" }, "engines": { diff --git a/integrations/sendgrid/CHANGELOG.md b/integrations/sendgrid/CHANGELOG.md index 1534aa5b3..2170ce650 100644 --- a/integrations/sendgrid/CHANGELOG.md +++ b/integrations/sendgrid/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sendgrid +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/sendgrid/package.json b/integrations/sendgrid/package.json index 4baf09ba9..d8cdb02dd 100644 --- a/integrations/sendgrid/package.json +++ b/integrations/sendgrid/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sendgrid", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for @sendgrid/mail", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@sendgrid/mail": "^7.7.0", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33" }, "engines": { "node": ">=16.8.0" diff --git a/integrations/shopify/CHANGELOG.md b/integrations/shopify/CHANGELOG.md index df89b1726..cf04bfd9e 100644 --- a/integrations/shopify/CHANGELOG.md +++ b/integrations/shopify/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/shopify +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/shopify/package.json b/integrations/shopify/package.json index 17d1374a6..b857d4e09 100644 --- a/integrations/shopify/package.json +++ b/integrations/shopify/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/shopify", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for @shopify/shopify-api", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@shopify/shopify-api": "^8.0.2", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", "zod": "3.22.3" }, "engines": { diff --git a/integrations/slack/CHANGELOG.md b/integrations/slack/CHANGELOG.md index 42fa2d4c7..c7f844eed 100644 --- a/integrations/slack/CHANGELOG.md +++ b/integrations/slack/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/slack +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/slack/package.json b/integrations/slack/package.json index 5575d87f0..395c4e46d 100644 --- a/integrations/slack/package.json +++ b/integrations/slack/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/slack", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The official Slack integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,7 +25,7 @@ }, "dependencies": { "@slack/web-api": "^6.8.1", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "zod": "3.22.3" }, "engines": { diff --git a/integrations/stripe/CHANGELOG.md b/integrations/stripe/CHANGELOG.md index a2472368f..d28826d5b 100644 --- a/integrations/stripe/CHANGELOG.md +++ b/integrations/stripe/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/stripe +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/stripe/package.json b/integrations/stripe/package.json index ffbe9fe65..2031bc5d8 100644 --- a/integrations/stripe/package.json +++ b/integrations/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/stripe", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for stripe", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,8 +25,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "stripe": "^12.14.0", "zod": "3.22.3" }, diff --git a/integrations/supabase/CHANGELOG.md b/integrations/supabase/CHANGELOG.md index 8ade0b8df..418cff6ac 100644 --- a/integrations/supabase/CHANGELOG.md +++ b/integrations/supabase/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/supabase +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/supabase/package.json b/integrations/supabase/package.json index 41a268d65..0d7ca33a5 100644 --- a/integrations/supabase/package.json +++ b/integrations/supabase/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/supabase", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev integration for @supabase/supabase-js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,8 +26,8 @@ }, "dependencies": { "@supabase/supabase-js": "^2.26.0", - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "supabase-management-js": "^1.0.0", "zod": "3.22.3" }, diff --git a/integrations/typeform/CHANGELOG.md b/integrations/typeform/CHANGELOG.md index aac82f45f..dcdbb0da9 100644 --- a/integrations/typeform/CHANGELOG.md +++ b/integrations/typeform/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/typeform +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/integration-kit@3.0.0-beta.33 +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/integrations/typeform/package.json b/integrations/typeform/package.json index 387ce104f..c247f1cba 100644 --- a/integrations/typeform/package.json +++ b/integrations/typeform/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/typeform", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The official Typeform integration for Trigger.dev", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,8 +24,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.32", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/integration-kit": "workspace:^3.0.0-beta.33", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "@typeform/api-client": "^1.8.0", "zod": "3.22.3" }, diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index fec0a9e4c..f17bcc8af 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/astro +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/astro/package.json b/packages/astro/package.json index 9035349ce..bcd533862 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,7 +1,7 @@ { "name": "@trigger.dev/astro", "description": "An Astro-native integration for Trigger.dev background jobs platform", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ @@ -20,7 +20,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33" }, "devDependencies": { "astro": "^3.0.12", diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index 91bade32c..4eaa4f5a9 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,13 @@ # trigger.dev +## 3.0.0-beta.33 + +### Patch Changes + +- 598906fc4: Fix for typo in v3 CLI login command +- Updated dependencies [6a379e4e9] + - @trigger.dev/core@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 452bafac6..183eefbb0 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "A Command-Line Interface for Trigger.dev (v3) projects", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -86,7 +86,7 @@ "@opentelemetry/sdk-trace-base": "^1.22.0", "@opentelemetry/sdk-trace-node": "^1.22.0", "@opentelemetry/semantic-conventions": "^1.22.0", - "@trigger.dev/core": "workspace:3.0.0-beta.32", + "@trigger.dev/core": "workspace:3.0.0-beta.33", "@types/degit": "^2.8.3", "chalk": "^5.2.0", "chokidar": "^3.5.3", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 178b7d7cd..993f23975 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # create-trigger +## 3.0.0-beta.33 + +### Patch Changes + +- Updated dependencies [6a379e4e9] + - @trigger.dev/core@3.0.0-beta.33 + - @trigger.dev/yalt@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 078a319e4..b15e57dfb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/cli", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "The Trigger.dev CLI", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/core-apps/CHANGELOG.md b/packages/core-apps/CHANGELOG.md index 97be47ca1..ea36d0845 100644 --- a/packages/core-apps/CHANGELOG.md +++ b/packages/core-apps/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/core-apps +## 3.0.0-beta.33 + ## 3.0.0-beta.32 ## 3.0.0-beta.31 diff --git a/packages/core-apps/package.json b/packages/core-apps/package.json index aaa81d74b..a8a70b7be 100644 --- a/packages/core-apps/package.json +++ b/packages/core-apps/package.json @@ -1,7 +1,7 @@ { "name": "@trigger.dev/core-apps", "description": "Backend core code used across apps", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "private": true, "license": "MIT", "main": "./dist/index.js", diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 79277944f..0408e0266 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/core-backend +## 3.0.0-beta.33 + ## 3.0.0-beta.32 ## 3.0.0-beta.31 diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index bb9e3f54b..bb1135202 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core-backend", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Core code used across `@trigger.dev/sdk` and Trigger.dev server", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2a0b910a9..0b87d195a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # internal-platform +## 3.0.0-beta.33 + +### Patch Changes + +- 6a379e4e9: Fix 3rd party otel propagation from breaking our Task Events data from being properly correlated to the correct trace + ## 3.0.0-beta.32 ## 3.0.0-beta.31 diff --git a/packages/core/package.json b/packages/core/package.json index 6ef23e2f6..6143edc5a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index 842560d0f..a4a327cc7 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/eslint-plugin +## 3.0.0-beta.33 + ## 3.0.0-beta.32 ## 3.0.0-beta.31 diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 79d0fab3b..e30e5e922 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/eslint-plugin", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "ESLint plugin with trigger.dev best practices", "keywords": [ "eslint", diff --git a/packages/express/CHANGELOG.md b/packages/express/CHANGELOG.md index 3df514beb..516669b91 100644 --- a/packages/express/CHANGELOG.md +++ b/packages/express/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/express +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/express/package.json b/packages/express/package.json index 3ba1f0f92..703a71fe6 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/express", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Official Express adapter for Trigger.dev", "license": "MIT", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "./package.json": "./package.json" }, "devDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "@trigger.dev/tsconfig": "workspace:*", "@types/debug": "^4.1.7", "@types/express": "^4.17.13", @@ -39,7 +39,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33" }, "dependencies": { "debug": "^4.3.4", diff --git a/packages/hono/CHANGELOG.md b/packages/hono/CHANGELOG.md index 026b7322c..a74cf31f3 100644 --- a/packages/hono/CHANGELOG.md +++ b/packages/hono/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/hono +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/hono/package.json b/packages/hono/package.json index 07efc45a3..9f061f611 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/hono", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "A Trigger.dev adapter for Hono.dev", "license": "MIT", "main": "./dist/index.js", @@ -32,7 +32,7 @@ }, "peerDependencies": { "hono": "3.x", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33" }, "devDependencies": { "@trigger.dev/tsconfig": "workspace:*", diff --git a/packages/integration-kit/CHANGELOG.md b/packages/integration-kit/CHANGELOG.md index 4c1a4ceaa..0980e0200 100644 --- a/packages/integration-kit/CHANGELOG.md +++ b/packages/integration-kit/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/integration-kit +## 3.0.0-beta.33 + +### Patch Changes + +- Updated dependencies [6a379e4e9] + - @trigger.dev/core@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/integration-kit/package.json b/packages/integration-kit/package.json index 197bab219..eebf73b62 100644 --- a/packages/integration-kit/package.json +++ b/packages/integration-kit/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/integration-kit", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev Integration Kit has helpers to make creating integrations easier", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/nestjs/CHANGELOG.md b/packages/nestjs/CHANGELOG.md index f0d832712..71d303e3f 100644 --- a/packages/nestjs/CHANGELOG.md +++ b/packages/nestjs/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/nestjs +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index a1fcdbc08..4d8e8b0f9 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/nestjs", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Official NestJS adapter for Trigger.dev", "license": "MIT", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "./package.json": "./package.json" }, "devDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "@trigger.dev/tsconfig": "workspace:*", "@types/debug": "^4.1.7", "@types/express": "^4.17.13", @@ -41,7 +41,7 @@ }, "peerDependencies": { "@nestjs/common": ">=10.0.0", - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33" }, "dependencies": { "@nestjs/common": "^10.2.4", diff --git a/packages/nextjs/CHANGELOG.md b/packages/nextjs/CHANGELOG.md index dbf101626..ae68c5e77 100644 --- a/packages/nextjs/CHANGELOG.md +++ b/packages/nextjs/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/nextjs +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 5c6651f18..f6ea6ffbd 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/nextjs", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev Next.js integration", "license": "MIT", "main": "./dist/index.js", @@ -41,7 +41,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "next": ">=12.0.0" }, "dependencies": { diff --git a/packages/otlp-importer/CHANGELOG.md b/packages/otlp-importer/CHANGELOG.md index d2077c8b0..9e1063f9f 100644 --- a/packages/otlp-importer/CHANGELOG.md +++ b/packages/otlp-importer/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/otlp-importer +## 3.0.0-beta.33 + ## 3.0.0-beta.32 ## 3.0.0-beta.31 diff --git a/packages/otlp-importer/package.json b/packages/otlp-importer/package.json index f1df7dcbd..b10a7b597 100644 --- a/packages/otlp-importer/package.json +++ b/packages/otlp-importer/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/otlp-importer", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "OpenTelemetry OTLP Importer for Node.js written in TypeScript", "license": "MIT", "main": "./dist/index.js", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index e21058634..4ce4823c1 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react +## 3.0.0-beta.33 + +### Patch Changes + +- Updated dependencies [6a379e4e9] + - @trigger.dev/core@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 10ebc1dc7..0a6e9e411 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev React SDK", "license": "MIT", "types": "dist/index.d.ts", @@ -27,7 +27,7 @@ }, "dependencies": { "@tanstack/react-query": "5.0.0-beta.2", - "@trigger.dev/core": "workspace:^3.0.0-beta.32", + "@trigger.dev/core": "workspace:^3.0.0-beta.33", "debug": "^4.3.4", "zod": "3.22.3" }, diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index ded90bf49..6e134e0ec 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/remix +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/remix/package.json b/packages/remix/package.json index 3d07cb70b..a4a9c5ce5 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/remix", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev Remix integration", "license": "MIT", "main": "./dist/index.js", @@ -39,7 +39,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32", + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33", "@remix-run/server-runtime": ">1.19.0" }, "dependencies": { diff --git a/packages/sveltekit/CHANGELOG.md b/packages/sveltekit/CHANGELOG.md index 0bf9fa338..e5c9dc29c 100644 --- a/packages/sveltekit/CHANGELOG.md +++ b/packages/sveltekit/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/sveltekit +## 3.0.0-beta.33 + +### Patch Changes + +- @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 186da8278..d9f33f963 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sveltekit", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "Trigger.dev svelteKit integration", "license": "MIT", "main": "./dist/index.js", @@ -39,7 +39,7 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^3.0.0-beta.32" + "@trigger.dev/sdk": "workspace:^3.0.0-beta.33" }, "dependencies": { "debug": "^4.3.4" diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md index 246f9a27e..ec9d0cdd6 100644 --- a/packages/testing/CHANGELOG.md +++ b/packages/testing/CHANGELOG.md @@ -1,5 +1,13 @@ # @trigger.dev/testing +## 3.0.0-beta.33 + +### Patch Changes + +- Updated dependencies [6a379e4e9] + - @trigger.dev/core@3.0.0-beta.33 + - @trigger.dev/sdk@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/testing/package.json b/packages/testing/package.json index 66d3755f9..dc799a2dc 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,7 +1,7 @@ { "name": "@trigger.dev/testing", "description": "A collection of useful tools to write tests for Trigger.dev.", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index e1f4ad5a9..835464115 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # @trigger.dev/sdk +## 3.0.0-beta.33 + +### Patch Changes + +- Updated dependencies [6a379e4e9] + - @trigger.dev/core@3.0.0-beta.33 + - @trigger.dev/core-backend@3.0.0-beta.33 + ## 3.0.0-beta.32 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index f9ec2bc48..f14f90f80 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "trigger.dev Node.JS SDK", "license": "MIT", "main": "./dist/index.js", @@ -48,8 +48,8 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/api-logs": "^0.48.0", "@opentelemetry/semantic-conventions": "^1.22.0", - "@trigger.dev/core": "workspace:3.0.0-beta.32", - "@trigger.dev/core-backend": "workspace:3.0.0-beta.32", + "@trigger.dev/core": "workspace:3.0.0-beta.33", + "@trigger.dev/core-backend": "workspace:3.0.0-beta.33", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/packages/yalt/CHANGELOG.md b/packages/yalt/CHANGELOG.md index 2d7e62b85..23e0742a6 100644 --- a/packages/yalt/CHANGELOG.md +++ b/packages/yalt/CHANGELOG.md @@ -1,5 +1,7 @@ # @trigger.dev/yalt +## 3.0.0-beta.33 + ## 3.0.0-beta.32 ## 3.0.0-beta.31 diff --git a/packages/yalt/package.json b/packages/yalt/package.json index a0b70ce56..a59cdfe91 100644 --- a/packages/yalt/package.json +++ b/packages/yalt/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/yalt", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "description": "yalt.dev client library", "license": "MIT", "main": "./dist/index.js", From a8db8ce4e43b11183cd7730591de2fb4c8e84107 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 16 May 2024 19:13:16 +0100 Subject: [PATCH 54/61] Latest lockfile --- pnpm-lock.yaml | 70 +++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c97648f0a..1e4333e77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -860,10 +860,10 @@ importers: integrations/airtable: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk airtable: specifier: ^0.12.1 @@ -903,10 +903,10 @@ importers: specifier: ^12.0.10 version: 12.0.10 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk octokit: specifier: ^3.1.2 @@ -946,10 +946,10 @@ importers: specifier: ^8.0.0 version: 8.0.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk zod: specifier: 3.22.3 @@ -977,10 +977,10 @@ importers: integrations/openai: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk openai: specifier: ^4.16.1 @@ -1020,10 +1020,10 @@ importers: specifier: ^2.7.0 version: 2.7.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk devDependencies: '@trigger.dev/tsconfig': @@ -1048,10 +1048,10 @@ importers: integrations/replicate: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk replicate: specifier: ^0.18.1 @@ -1082,10 +1082,10 @@ importers: integrations/resend: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk resend: specifier: ^2.1.0 @@ -1116,10 +1116,10 @@ importers: specifier: ^7.7.0 version: 7.7.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk devDependencies: '@trigger.dev/tsconfig': @@ -1147,10 +1147,10 @@ importers: specifier: ^8.0.2 version: 8.0.2 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk zod: specifier: 3.22.3 @@ -1181,7 +1181,7 @@ importers: specifier: ^6.8.1 version: 6.8.1 '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk zod: specifier: 3.22.3 @@ -1209,10 +1209,10 @@ importers: integrations/stripe: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk stripe: specifier: ^12.14.0 @@ -1246,10 +1246,10 @@ importers: specifier: ^2.26.0 version: 2.31.0 '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk supabase-management-js: specifier: ^1.0.0 @@ -1280,10 +1280,10 @@ importers: integrations/typeform: dependencies: '@trigger.dev/integration-kit': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/integration-kit '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../../packages/trigger-sdk '@typeform/api-client': specifier: ^1.8.0 @@ -1311,7 +1311,7 @@ importers: packages/astro: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -1529,7 +1529,7 @@ importers: specifier: ^1.22.0 version: 1.22.0 '@trigger.dev/core': - specifier: workspace:3.0.0-beta.32 + specifier: workspace:3.0.0-beta.33 version: link:../core '@types/degit': specifier: ^2.8.3 @@ -1941,7 +1941,7 @@ importers: version: 4.18.2 devDependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../trigger-sdk '@trigger.dev/tsconfig': specifier: workspace:* @@ -2036,7 +2036,7 @@ importers: version: 4.3.4(supports-color@8.1.1) devDependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../trigger-sdk '@trigger.dev/tsconfig': specifier: workspace:* @@ -2069,7 +2069,7 @@ importers: packages/nextjs: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -2155,7 +2155,7 @@ importers: specifier: 5.0.0-beta.2 version: 5.0.0-beta.2(react@18.2.0) '@trigger.dev/core': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../core debug: specifier: ^4.3.4 @@ -2198,7 +2198,7 @@ importers: packages/remix: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -2235,7 +2235,7 @@ importers: packages/sveltekit: dependencies: '@trigger.dev/sdk': - specifier: workspace:^3.0.0-beta.32 + specifier: workspace:^3.0.0-beta.33 version: link:../trigger-sdk debug: specifier: ^4.3.4 @@ -2309,10 +2309,10 @@ importers: specifier: ^1.22.0 version: 1.22.0 '@trigger.dev/core': - specifier: workspace:3.0.0-beta.32 + specifier: workspace:3.0.0-beta.33 version: link:../core '@trigger.dev/core-backend': - specifier: workspace:3.0.0-beta.32 + specifier: workspace:3.0.0-beta.33 version: link:../core-backend chalk: specifier: ^5.2.0 From 6fecf2dbc09f615efff63b4d4a0c801cd0c05eb4 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 17 May 2024 14:22:40 +0100 Subject: [PATCH 55/61] More info about environments on the alerts page and alerts modal (#1107) --- .../app/components/primitives/Dialog.tsx | 4 +- .../app/components/runs/v3/EnabledStatus.tsx | 6 +-- .../route.tsx | 51 ++++++++++++++----- .../route.tsx | 27 ++++++---- 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index 5f04b94b6..a6c0bf813 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -43,7 +43,7 @@ const DialogContent = React.forwardRef< ) => (
); diff --git a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx index 80e8803cc..2189ad247 100644 --- a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx +++ b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx @@ -1,18 +1,18 @@ -import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid"; +import { BellAlertIcon, BellSlashIcon } from "@heroicons/react/20/solid"; export function EnabledStatus({ enabled }: { enabled: boolean }) { switch (enabled) { case true: return (
- + Enabled
); case false: return (
- + Disabled
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx index 7b9b1a31a..f654df016 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx @@ -22,6 +22,7 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import SegmentedControl from "~/components/primitives/SegmentedControl"; import { Select, SelectItem } from "~/components/primitives/Select"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -324,24 +325,28 @@ export default function Page() { )} - - + + - +
+ + +
@@ -350,13 +355,33 @@ export default function Page() { id="DEPLOYMENT_SUCCESS" value="DEPLOYMENT_SUCCESS" variant="simple/small" - label="Deployment success" + label="Deployments succeed" defaultChecked /> {alertTypes.error}
- + + + + + {form.error}
Name - Alert Types + Alert types + Environments Channel Enabled Actions @@ -200,6 +201,12 @@ export default function Page() { {alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")} + + + + @@ -225,7 +232,8 @@ export default function Page() { You haven't created any project alerts yet - Get alerted when runs or deployments fail, or when deployments succeed. + Get alerted when runs or deployments fail, or when deployments succeed in + both Prod and Staging environments. Platform alerts - Get email notifications when Trigger.dev creates, updates or resolves a platform - incident. + Subscribe to get email notifications when Trigger.dev creates, updates or resolves a + platform incident. Subscribe @@ -328,7 +337,7 @@ function DisableAlertChannelButton(props: { id: string }) { value="disable" type="submit" variant="small-menu-item" - LeadingIcon={BoltSlashIcon} + LeadingIcon={BellSlashIcon} leadingIconClassName="text-dimmed" className="text-xs" > @@ -366,7 +375,7 @@ function EnableAlertChannelButton(props: { id: string }) { value="enable" type="submit" variant="small-menu-item" - LeadingIcon={BoltIcon} + LeadingIcon={BellAlertIcon} leadingIconClassName="text-success" className="text-xs" > From 8a263c8ab90607816cceb4cdb480a6ad873eaf11 Mon Sep 17 00:00:00 2001 From: Alec Dilanchian Date: Fri, 17 May 2024 10:11:29 -0400 Subject: [PATCH 56/61] Adding note for supabase integration (#1101) * Update management.mdx Adding note for custom domains and initializing db object. * Update management.mdx --------- Co-authored-by: Matt Aitken --- docs/integrations/apis/supabase/management.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/integrations/apis/supabase/management.mdx b/docs/integrations/apis/supabase/management.mdx index 286fc93e7..ceb27d758 100644 --- a/docs/integrations/apis/supabase/management.mdx +++ b/docs/integrations/apis/supabase/management.mdx @@ -109,6 +109,9 @@ You'll have to do this for each Supabase project you want to use webhooks with. To use this feature, you'll first initialize a `db` instance, passing in your Supabase project [ID](https://supabase.com/dashboard/project/_/settings/api) (or URL): +Note: If you're using a custom domain, you still need to use this format to intialize the db object: +`https://.supabase.co`. + ```ts import { SupabaseManagement } from "@trigger.dev/supabase"; From 68107565a4b90e316a92803097e4d27c041e82a8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 17 May 2024 15:24:47 +0100 Subject: [PATCH 57/61] v3: Fix issues with posting alerts to public slack channels (#1108) * Fix issues with posting alerts to public slack channels * Use the actual values in the new environmentTypes column to display the environment type labels in the alerts list * Implement environment alert options --- .../environments/EnvironmentLabel.tsx | 37 ++ .../app/models/orgIntegration.server.ts | 11 +- .../v3/ApiAlertChannelPresenter.server.ts | 5 + .../route.tsx | 71 ++-- .../route.tsx | 10 +- ...i.v1.projects.$projectRef.alertChannels.ts | 2 + .../alerts/createAlertChannel.server.ts | 14 +- .../v3/services/alerts/deliverAlert.server.ts | 334 ++++++++++-------- .../alerts/performDeploymentAlerts.server.ts | 6 + .../alerts/performTaskAttemptAlerts.server.ts | 7 +- .../migration.sql | 5 + .../migration.sql | 16 + packages/database/prisma/schema.prisma | 9 +- 13 files changed, 353 insertions(+), 174 deletions(-) create mode 100644 packages/database/prisma/migrations/20240517105021_add_environment_types_to_alert_channel/migration.sql create mode 100644 packages/database/prisma/migrations/20240517105224_remove_test_alert_type/migration.sql diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index e9e6737e5..2b7a3d28c 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -9,6 +9,30 @@ const variants = { large: "h-6 text-xs px-1.5 rounded", }; +export function EnvironmentTypeLabel({ + environment, + size = "small", + className, +}: { + environment: Environment; + size?: keyof typeof variants; + className?: string; +}) { + return ( + + {environmentTypeTitle(environment)} + + ); +} + export function EnvironmentLabel({ environment, size = "small", @@ -116,6 +140,19 @@ export function environmentTitle(environment: Environment, username?: string) { } } +export function environmentTypeTitle(environment: Environment) { + switch (environment.type) { + case "PRODUCTION": + return "Prod"; + case "STAGING": + return "Staging"; + case "DEVELOPMENT": + return "Dev"; + case "PREVIEW": + return "Preview"; + } +} + export function environmentColorClassName(environment: Environment) { switch (environment.type) { case "PRODUCTION": diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index b5ae32ad4..613a2eeb5 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -27,7 +27,7 @@ type SlackSecret = z.infer; const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth"; -type OrganizationIntegrationForService = Omit< +export type OrganizationIntegrationForService = Omit< AuthenticatableIntegration, "service" > & { @@ -83,7 +83,14 @@ export class OrgIntegrationRepository { static slackAuthorizationUrl( state: string, - scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"], + scopes: string[] = [ + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "chat:write", + "chat:write.public", + ], userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"] ) { return `https://slack.com/oauth/v2/authorize?client_id=${ diff --git a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts index 0693354fd..c6dd350f5 100644 --- a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts @@ -16,6 +16,10 @@ export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "de export type ApiAlertType = z.infer; +export const ApiAlertEnvironmentType = z.enum(["STAGING", "PRODUCTION"]); + +export type ApiAlertEnvironmentType = z.infer; + export const ApiAlertChannel = z.enum(["email", "webhook"]); export type ApiAlertChannel = z.infer; @@ -34,6 +38,7 @@ export const ApiCreateAlertChannel = z.object({ channel: ApiAlertChannel, channelData: ApiAlertChannelData, deduplicationKey: z.string().optional(), + environmentTypes: ApiAlertEnvironmentType.array().default(["STAGING", "PRODUCTION"]), }); export type ApiCreateAlertChannel = z.infer; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx index f654df016..915dfc69a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx @@ -41,6 +41,10 @@ const FormSchema = z .array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])) .min(1) .or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])), + environmentTypes: z + .array(z.enum(["STAGING", "PRODUCTION"])) + .min(1) + .or(z.enum(["STAGING", "PRODUCTION"])), type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"), channelValue: z.string().nonempty(), integrationId: z.string().optional(), @@ -82,6 +86,9 @@ function formDataToCreateAlertChannelOptions( alertTypes: Array.isArray(formData.alertTypes) ? formData.alertTypes : [formData.alertTypes], + environmentTypes: Array.isArray(formData.environmentTypes) + ? formData.environmentTypes + : [formData.environmentTypes], channel: { type: "WEBHOOK", url: formData.channelValue, @@ -94,6 +101,9 @@ function formDataToCreateAlertChannelOptions( alertTypes: Array.isArray(formData.alertTypes) ? formData.alertTypes : [formData.alertTypes], + environmentTypes: Array.isArray(formData.environmentTypes) + ? formData.environmentTypes + : [formData.environmentTypes], channel: { type: "EMAIL", email: formData.channelValue, @@ -108,6 +118,9 @@ function formDataToCreateAlertChannelOptions( alertTypes: Array.isArray(formData.alertTypes) ? formData.alertTypes : [formData.alertTypes], + environmentTypes: Array.isArray(formData.environmentTypes) + ? formData.environmentTypes + : [formData.environmentTypes], channel: { type: "SLACK", channelId, @@ -194,20 +207,27 @@ export default function Page() { const project = useProject(); const [currentAlertChannel, setCurrentAlertChannel] = useState(option ?? "EMAIL"); + const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState(); + + const selectedSlackChannel = slack.channels?.find( + (s) => selectedSlackChannelValue === `${s.id}/${s.name}` + ); + const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "create"; - const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({ - id: "create-alert", - // TODO: type this - lastSubmission: lastSubmission as any, - onValidate({ formData }) { - return parse(formData, { schema: FormSchema }); - }, - shouldRevalidate: "onSubmit", - }); + const [form, { channelValue: channelValue, alertTypes, environmentTypes, type, integrationId }] = + useForm({ + id: "create-alert", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: FormSchema }); + }, + shouldRevalidate: "onSubmit", + }); useEffect(() => { setIsOpen(true); @@ -272,6 +292,9 @@ export default function Page() { dropdownIcon variant="tertiary/medium" items={slack.channels} + setValue={(value) => { + typeof value === "string" && setSelectedSlackChannelValue(value); + }} filter={(channel, search) => channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false } @@ -291,10 +314,16 @@ export default function Page() { )} - - If selecting a private channel, you will need to invite the bot to the channel - using /invite @Trigger.dev - + {selectedSlackChannel && selectedSlackChannel.is_private && ( + + Heads up! To receive alerts in the{" "} + {selectedSlackChannel.name}{" "} + channel, you will need to invite the @Trigger.dev Slack Bot. You can do this + by visiting the channel in your Slack workspace issue the following command:{" "} + /invite @Trigger.dev. + + )} + {channelValue.error} @@ -364,23 +393,23 @@ export default function Page() { + + {environmentTypes.error} {form.error}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx index 055e22ea9..bf46d8d94 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx @@ -18,7 +18,7 @@ import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database import assertNever from "assert-never"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentTypeLabel } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; @@ -204,8 +204,12 @@ export default function Page() { - - + {alertChannel.environmentTypes.map((environmentType) => ( + + ))} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts index 0eb2dc11f..ebc5b1764 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts @@ -54,6 +54,7 @@ export async function action({ request, params }: ActionFunctionArgs) { email: body.data.channelData.email, }, deduplicationKey: body.data.deduplicationKey, + environmentTypes: body.data.environmentTypes, }); return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel)); @@ -75,6 +76,7 @@ export async function action({ request, params }: ActionFunctionArgs) { secret: body.data.channelData.secret, }, deduplicationKey: body.data.deduplicationKey, + environmentTypes: body.data.environmentTypes, }); return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel)); diff --git a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts index 8a2fb0b96..aca1fdd15 100644 --- a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts +++ b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts @@ -1,4 +1,8 @@ -import { ProjectAlertChannel, ProjectAlertType } from "@trigger.dev/database"; +import { + ProjectAlertChannel, + ProjectAlertType, + RuntimeEnvironmentType, +} from "@trigger.dev/database"; import { nanoid } from "nanoid"; import { env } from "~/env.server"; import { findProjectByRef } from "~/models/project.server"; @@ -9,6 +13,7 @@ import { BaseService, ServiceValidationError } from "../baseService.server"; export type CreateAlertChannelOptions = { name: string; alertTypes: ProjectAlertType[]; + environmentTypes: RuntimeEnvironmentType[]; deduplicationKey?: string; channel: | { @@ -40,6 +45,11 @@ export class CreateAlertChannelService extends BaseService { throw new ServiceValidationError("Project not found"); } + const environmentTypes = + options.environmentTypes.length === 0 + ? (["STAGING", "PRODUCTION"] satisfies RuntimeEnvironmentType[]) + : options.environmentTypes; + const existingAlertChannel = options.deduplicationKey ? await this._prisma.projectAlertChannel.findUnique({ where: { @@ -59,6 +69,7 @@ export class CreateAlertChannelService extends BaseService { alertTypes: options.alertTypes, type: options.channel.type, properties: await this.#createProperties(options.channel), + environmentTypes, }, }); } @@ -74,6 +85,7 @@ export class CreateAlertChannelService extends BaseService { enabled: true, deduplicationKey: options.deduplicationKey, userProvidedDeduplicationKey: options.deduplicationKey ? true : false, + environmentTypes, }, }); diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index 780af49a4..2e72696c0 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -1,8 +1,20 @@ +import { + ChatPostMessageArguments, + ErrorCode, + WebAPIHTTPError, + WebAPIPlatformError, + WebAPIRateLimitedError, + WebAPIRequestError, +} from "@slack/web-api"; import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3"; import assertNever from "assert-never"; import { subtle } from "crypto"; import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server"; import { env } from "~/env.server"; +import { + OrgIntegrationRepository, + OrganizationIntegrationForService, +} from "~/models/orgIntegration.server"; import { ProjectAlertEmailProperties, ProjectAlertSlackProperties, @@ -10,12 +22,11 @@ import { ProjectAlertWebhookProperties, } from "~/models/projectAlert.server"; import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; -import { sendAlertEmail, sendEmail } from "~/services/email.server"; +import { sendAlertEmail } from "~/services/email.server"; import { logger } from "~/services/logger.server"; import { decryptSecret } from "~/services/secrets/secretStore.server"; import { workerQueue } from "~/services/worker.server"; import { BaseService } from "../baseService.server"; -import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; type FoundAlert = Prisma.Result< typeof prisma.projectAlert, @@ -88,10 +99,6 @@ export class DeliverAlertService extends BaseService { return; } - if (alert.environment.type === "DEVELOPMENT") { - return; - } - switch (alert.channel.type) { case "EMAIL": { await this.#sendEmail(alert); @@ -439,12 +446,6 @@ export class DeliverAlertService extends BaseService { return; } - // Get the client - const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration( - integration, - { forceBotToken: true } - ); - switch (alert.type) { case "TASK_RUN_ATTEMPT": { if (alert.taskRunAttempt) { @@ -485,90 +486,81 @@ export class DeliverAlertService extends BaseService { const runId = alert.taskRunAttempt.taskRun.friendlyId; const attemptNumber = alert.taskRunAttempt.number; - try { - const message = await client.chat.postMessage({ - 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: { + 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: `\`\`\`${error.stackTrace ?? error.message}\`\`\``, + }, + }, + { + type: "context", + elements: [ + { type: "mrkdwn", - text: `:rotating_light: Error in *${exportName}* __`, + text: `${runId}.${attemptNumber} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`, }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `\`\`\`${error.stackTrace ?? error.message}\`\`\``, + ], + }, + { + 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}`, }, - }, - { - 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, + // Upsert the storage + if (message.ts) { + if (storage) { + await this._prisma.projectAlertStorage.update({ + where: { + id: storage.id, + }, + data: { + storageData: { + message_ts: message.ts, }, - 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, }, - }); - } 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, - }, - }); - } + projectId: alert.project.id, + }, + }); } - } catch (error) { - logger.error("[DeliverAlert] Failed to send slack message", { - error, - alert, - }); - - throw error; } } else { logger.error("[DeliverAlert] Task run attempt not found", { @@ -596,58 +588,49 @@ export class DeliverAlertService extends BaseService { const environment = alert.environment.slug; const timestamp = alert.workerDeployment.failedAt ?? new Date(); - try { - await client.chat.postMessage({ - channel: slackProperties.data.channelId, - blocks: [ - { - type: "section", - text: { + await this.#postSlackMessage(integration, { + channel: slackProperties.data.channelId, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:rotating_light: Deployment failed *${version}.${environment}* __`, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``, + }, + }, + { + type: "context", + elements: [ + { type: "mrkdwn", - text: `:rotating_light: Deployment failed *${version}.${environment}* __`, + text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Deployment", + }, + url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "View Deployment", - }, - url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, - }, - ], - }, - ], - }); - } catch (error) { - logger.error("[DeliverAlert] Failed to send slack message", { - error, - alert, - }); - - throw error; - } + ], + }, + ], + }); } else { logger.error("[DeliverAlert] Worker deployment not found", { alert, @@ -663,7 +646,7 @@ export class DeliverAlertService extends BaseService { const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0; const timestamp = alert.workerDeployment.deployedAt ?? new Date(); - await client.chat.postMessage({ + await this.#postSlackMessage(integration, { channel: slackProperties.data.channelId, text: `Deployment ${alert.workerDeployment.version} [${alert.environment.slug}] succeeded`, blocks: [ @@ -754,6 +737,63 @@ export class DeliverAlertService extends BaseService { } } + async #postSlackMessage( + integration: OrganizationIntegrationForService<"SLACK">, + message: ChatPostMessageArguments + ) { + const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration( + integration, + { forceBotToken: true } + ); + + try { + return await client.chat.postMessage(message); + } catch (error) { + if (isWebAPIRateLimitedError(error)) { + logger.error("[DeliverAlert] Slack rate limited", { + error, + message, + }); + + throw new Error("Slack rate limited"); + } + + if (isWebAPIHTTPError(error)) { + logger.error("[DeliverAlert] Slack HTTP error", { + error, + message, + }); + + throw new Error("Slack HTTP error"); + } + + if (isWebAPIRequestError(error)) { + logger.error("[DeliverAlert] Slack request error", { + error, + message, + }); + + throw new Error("Slack request error"); + } + + if (isWebAPIPlatformError(error)) { + logger.error("[DeliverAlert] Slack platform error", { + error, + message, + }); + + throw new Error("Slack platform error"); + } + + logger.error("[DeliverAlert] Failed to send slack message", { + error, + message, + }); + + throw error; + } + } + static async enqueue( alertId: string, tx: PrismaClientOrTransaction, @@ -773,3 +813,19 @@ export class DeliverAlertService extends BaseService { ); } } + +function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError { + return (error as WebAPIPlatformError).code === ErrorCode.PlatformError; +} + +function isWebAPIRequestError(error: unknown): error is WebAPIRequestError { + return (error as WebAPIRequestError).code === ErrorCode.RequestError; +} + +function isWebAPIHTTPError(error: unknown): error is WebAPIHTTPError { + return (error as WebAPIHTTPError).code === ErrorCode.HTTPError; +} + +function isWebAPIRateLimitedError(error: unknown): error is WebAPIRateLimitedError { + return (error as WebAPIRateLimitedError).code === ErrorCode.RateLimitedError; +} diff --git a/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts b/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts index df2f7017d..8f8b945e6 100644 --- a/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts +++ b/apps/webapp/app/v3/services/alerts/performDeploymentAlerts.server.ts @@ -9,6 +9,9 @@ export class PerformDeploymentAlertsService extends BaseService { public async call(deploymentId: string) { const deployment = await this._prisma.workerDeployment.findUnique({ where: { id: deploymentId }, + include: { + environment: true, + }, }); if (!deployment) { @@ -25,6 +28,9 @@ export class PerformDeploymentAlertsService extends BaseService { alertTypes: { has: alertType, }, + environmentTypes: { + has: deployment.environment.type, + }, enabled: true, }, }); diff --git a/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts b/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts index 1b1b78b20..b0599c613 100644 --- a/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts +++ b/apps/webapp/app/v3/services/alerts/performTaskAttemptAlerts.server.ts @@ -26,10 +26,6 @@ export class PerformTaskAttemptAlertsService extends BaseService { return; } - if (taskAttempt.runtimeEnvironment.type === "DEVELOPMENT") { - return; - } - // Find all the alert channels const alertChannels = await this._prisma.projectAlertChannel.findMany({ where: { @@ -37,6 +33,9 @@ export class PerformTaskAttemptAlertsService extends BaseService { alertTypes: { has: "TASK_RUN_ATTEMPT", }, + environmentTypes: { + has: taskAttempt.runtimeEnvironment.type, + }, enabled: true, }, }); diff --git a/packages/database/prisma/migrations/20240517105021_add_environment_types_to_alert_channel/migration.sql b/packages/database/prisma/migrations/20240517105021_add_environment_types_to_alert_channel/migration.sql new file mode 100644 index 000000000..a1b29617b --- /dev/null +++ b/packages/database/prisma/migrations/20240517105021_add_environment_types_to_alert_channel/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "ProjectAlertType" ADD VALUE 'TEST'; + +-- AlterTable +ALTER TABLE "ProjectAlertChannel" ADD COLUMN "environmentTypes" "RuntimeEnvironmentType"[] DEFAULT ARRAY['STAGING', 'PRODUCTION']::"RuntimeEnvironmentType"[]; diff --git a/packages/database/prisma/migrations/20240517105224_remove_test_alert_type/migration.sql b/packages/database/prisma/migrations/20240517105224_remove_test_alert_type/migration.sql new file mode 100644 index 000000000..3905e50a9 --- /dev/null +++ b/packages/database/prisma/migrations/20240517105224_remove_test_alert_type/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - The values [TEST] on the enum `ProjectAlertType` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "ProjectAlertType_new" AS ENUM ('TASK_RUN_ATTEMPT', 'DEPLOYMENT_FAILURE', 'DEPLOYMENT_SUCCESS'); +ALTER TABLE "ProjectAlertChannel" ALTER COLUMN "alertTypes" TYPE "ProjectAlertType_new"[] USING ("alertTypes"::text::"ProjectAlertType_new"[]); +ALTER TABLE "ProjectAlert" ALTER COLUMN "type" TYPE "ProjectAlertType_new" USING ("type"::text::"ProjectAlertType_new"); +ALTER TABLE "ProjectAlertStorage" ALTER COLUMN "alertType" TYPE "ProjectAlertType_new" USING ("alertType"::text::"ProjectAlertType_new"); +ALTER TYPE "ProjectAlertType" RENAME TO "ProjectAlertType_old"; +ALTER TYPE "ProjectAlertType_new" RENAME TO "ProjectAlertType"; +DROP TYPE "ProjectAlertType_old"; +COMMIT; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7b19c57c5..94ef58714 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -2241,10 +2241,11 @@ model ProjectAlertChannel { enabled Boolean @default(true) - type ProjectAlertChannelType - name String - properties Json - alertTypes ProjectAlertType[] + type ProjectAlertChannelType + name String + properties Json + alertTypes ProjectAlertType[] + environmentTypes RuntimeEnvironmentType[] @default([STAGING, PRODUCTION]) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String From 14cffd4e5c3cacf673bb6b7a86b0a1c110073d35 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 17 May 2024 16:09:59 +0100 Subject: [PATCH 58/61] Fix for the EnabledStatus component icons and improved the style of the private Slack channel callout --- .../app/components/runs/v3/EnabledStatus.tsx | 21 +++++++++++++++---- .../route.tsx | 15 ++++++++----- .../route.tsx | 6 +++++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx index 2189ad247..9e1f71632 100644 --- a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx +++ b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx @@ -1,18 +1,31 @@ -import { BellAlertIcon, BellSlashIcon } from "@heroicons/react/20/solid"; +import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid"; + +type EnabledStatusProps = { + enabled: boolean; + enabledIcon?: React.ComponentType; + disabledIcon?: React.ComponentType; +}; + +export function EnabledStatus({ + enabled, + enabledIcon = CheckCircleIcon, + disabledIcon = BoltSlashIcon, +}: EnabledStatusProps) { + const EnabledIcon = enabledIcon; + const DisabledIcon = disabledIcon; -export function EnabledStatus({ enabled }: { enabled: boolean }) { switch (enabled) { case true: return (
- + Enabled
); case false: return (
- + Disabled
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx index 915dfc69a..7b3defaf8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx @@ -10,7 +10,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; +import { Callout, variantClasses } from "~/components/primitives/Callout"; import { Checkbox } from "~/components/primitives/Checkbox"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -20,6 +20,7 @@ import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; import SegmentedControl from "~/components/primitives/SegmentedControl"; import { Select, SelectItem } from "~/components/primitives/Select"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; @@ -29,6 +30,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder"; import { CreateAlertChannelOptions, @@ -315,11 +317,14 @@ export default function Page() { )} {selectedSlackChannel && selectedSlackChannel.is_private && ( - - Heads up! To receive alerts in the{" "} + + To receive alerts in the{" "} {selectedSlackChannel.name}{" "} - channel, you will need to invite the @Trigger.dev Slack Bot. You can do this - by visiting the channel in your Slack workspace issue the following command:{" "} + channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in + Slack and type:{" "} /invite @Trigger.dev. )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx index bf46d8d94..fab85a894 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx @@ -215,7 +215,11 @@ export default function Page() {
- + {alertChannel.enabled ? ( From f4f87ad573054c90a94564c56d977b713d963fbb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 20 May 2024 14:27:20 +0100 Subject: [PATCH 59/61] Defer the enqueuing of events.deliverScheduled events, to keep the graphile_jobs table smaller --- .../schedules/deliverScheduledEvent.server.ts | 129 ++++++++++++++++++ .../schedules/disableScheduleSource.server.ts | 3 +- .../schedules/nextScheduledEvent.server.ts | 30 +--- apps/webapp/app/services/worker.server.ts | 6 + .../migration.sql | 17 +++ packages/database/prisma/schema.prisma | 16 ++- references/job-catalog/src/schedules.ts | 21 +++ 7 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 packages/database/prisma/migrations/20240520112812_create_deferred_scheduled_event_model/migration.sql diff --git a/apps/webapp/app/services/schedules/deliverScheduledEvent.server.ts b/apps/webapp/app/services/schedules/deliverScheduledEvent.server.ts index 3dff3a9d9..e9e6fa11d 100644 --- a/apps/webapp/app/services/schedules/deliverScheduledEvent.server.ts +++ b/apps/webapp/app/services/schedules/deliverScheduledEvent.server.ts @@ -4,6 +4,9 @@ import { NextScheduledEventService } from "./nextScheduledEvent.server"; import { IngestSendEvent } from "../events/ingestSendEvent.server"; import { InvokeDispatcherService } from "../events/invokeDispatcher.server"; import { logger } from "../logger.server"; +import { workerQueue } from "../worker.server"; + +const DEFERRED_ENQUEUE_THRESHOLD_IN_SECONDS = 60 * 60 * 2 - 1; // 2 hours - 1 second export class DeliverScheduledEventService { #prismaClient: PrismaClientOrTransaction; @@ -12,6 +15,132 @@ export class DeliverScheduledEventService { this.#prismaClient = prismaClient; } + // This runs every 10 minutes + public static async scheduleImminentDeferredEvents() { + // Find all deferred events that are due to be enqueued in the next hour + const deferredEvents = await prisma.deferredScheduledEventService.findMany({ + where: { + runAt: { + lte: new Date(Date.now() + (DEFERRED_ENQUEUE_THRESHOLD_IN_SECONDS / 2) * 1000), + }, + }, + }); + + for (const deferredEvent of deferredEvents) { + logger.debug("Enqueuing deferred scheduled event", { + scheduleSourceId: deferredEvent.scheduleSourceId, + runAt: deferredEvent.runAt, + }); + + try { + await DeliverScheduledEventService.enqueue( + deferredEvent.scheduleSourceId, + deferredEvent.runAt, + { + ts: deferredEvent.runAt, + lastTimestamp: deferredEvent.lastTimestamp ?? undefined, + } + ); + } catch (error) { + logger.error("Error enqueuing deferred scheduled event", { + scheduleSourceId: deferredEvent.scheduleSourceId, + runAt: deferredEvent.runAt, + error, + }); + } + } + } + + public static async dequeue(id: string, tx: PrismaClientOrTransaction = prisma) { + await tx.deferredScheduledEventService.deleteMany({ + where: { + scheduleSourceId: id, + }, + }); + + await workerQueue.dequeue(`scheduled:${id}`); + + await tx.scheduleSource.update({ + where: { + id, + }, + data: { + workerJobId: null, + }, + }); + } + + public static async enqueue( + id: string, + runAt: Date, + payload: ScheduledPayload, + tx: PrismaClientOrTransaction = prisma + ) { + if (runAt.getTime() - Date.now() > DEFERRED_ENQUEUE_THRESHOLD_IN_SECONDS * 1000) { + logger.debug("Deferring enqueueing events.deliverScheduled", { + id, + runAt, + payload, + }); + + await tx.deferredScheduledEventService.upsert({ + where: { + scheduleSourceId: id, + }, + create: { + scheduleSourceId: id, + runAt, + lastTimestamp: payload.lastTimestamp, + }, + update: { + runAt, + lastTimestamp: payload.lastTimestamp, + }, + }); + + await tx.scheduleSource.update({ + where: { + id, + }, + data: { + workerJobId: null, + nextEventTimestamp: runAt, + }, + }); + + await workerQueue.dequeue(`scheduled:${id}`); + } else { + await tx.deferredScheduledEventService.deleteMany({ + where: { + scheduleSourceId: id, + }, + }); + + const workerJob = await workerQueue.enqueue( + "events.deliverScheduled", + { + id: id, + payload, + }, + { + runAt, + tx, + jobKey: `scheduled:${id}`, + } + ); + + await tx.scheduleSource.update({ + where: { + id, + }, + data: { + workerJobId: workerJob.id, + nextEventTimestamp: runAt, + }, + }); + } + } + public async call(id: string, payload: ScheduledPayload) { return await $transaction( this.#prismaClient, diff --git a/apps/webapp/app/services/schedules/disableScheduleSource.server.ts b/apps/webapp/app/services/schedules/disableScheduleSource.server.ts index b8f00a528..b53d29719 100644 --- a/apps/webapp/app/services/schedules/disableScheduleSource.server.ts +++ b/apps/webapp/app/services/schedules/disableScheduleSource.server.ts @@ -1,6 +1,7 @@ import type { EventDispatcher } from "@trigger.dev/database"; import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server"; import { workerQueue } from "../worker.server"; +import { DeliverScheduledEventService } from "./deliverScheduledEvent.server"; export class DisableScheduleSourceService { #prismaClient: PrismaClientOrTransaction; @@ -36,7 +37,7 @@ export class DisableScheduleSourceService { }, }); - await workerQueue.dequeue(`scheduled:${scheduleSource.id}`, { tx }); + await DeliverScheduledEventService.dequeue(scheduleSource.id, tx); return scheduleSource; }); diff --git a/apps/webapp/app/services/schedules/nextScheduledEvent.server.ts b/apps/webapp/app/services/schedules/nextScheduledEvent.server.ts index 762227624..fb9ed1cf9 100644 --- a/apps/webapp/app/services/schedules/nextScheduledEvent.server.ts +++ b/apps/webapp/app/services/schedules/nextScheduledEvent.server.ts @@ -6,8 +6,8 @@ import { } from "@trigger.dev/core"; import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server"; import { parseExpression } from "cron-parser"; -import { workerQueue } from "../worker.server"; import { logger } from "../logger.server"; +import { DeliverScheduledEventService } from "./deliverScheduledEvent.server"; export class NextScheduledEventService { #prismaClient: PrismaClientOrTransaction; @@ -45,32 +45,16 @@ export class NextScheduledEventService { lastTimestamp: scheduleSource.lastEventTimestamp, }); - const workerJob = await workerQueue.enqueue( - "events.deliverScheduled", + await DeliverScheduledEventService.enqueue( + scheduleSource.id, + scheduleTime, { - id: scheduleSource.id, - payload: { - ts: scheduleTime, - lastTimestamp: scheduleSource.lastEventTimestamp ?? undefined, - }, + ts: scheduleTime, + lastTimestamp: scheduleSource.lastEventTimestamp ?? undefined, }, - { - runAt: scheduleTime, - tx, - jobKey: `scheduled:${scheduleSource.id}`, - } + tx ); - await this.#prismaClient.scheduleSource.update({ - where: { - id: scheduleSource.id, - }, - data: { - workerJobId: workerJob.id, - nextEventTimestamp: scheduleTime, - }, - }); - return scheduleSource; }); } diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index dff6164ce..efc7203a7 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -252,6 +252,12 @@ function getWorkerQueue() { await service.call(payload.ts); }, }, + scheduleImminentDeferredEvents: { + match: "*/10 * * * *", + handler: async (payload, job) => { + await DeliverScheduledEventService.scheduleImminentDeferredEvents(); + }, + }, // Run this every hour purgeOldIndexings: { match: "0 * * * *", diff --git a/packages/database/prisma/migrations/20240520112812_create_deferred_scheduled_event_model/migration.sql b/packages/database/prisma/migrations/20240520112812_create_deferred_scheduled_event_model/migration.sql new file mode 100644 index 000000000..b6f418208 --- /dev/null +++ b/packages/database/prisma/migrations/20240520112812_create_deferred_scheduled_event_model/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "DeferredScheduledEventService" ( + "id" TEXT NOT NULL, + "scheduleSourceId" TEXT NOT NULL, + "runAt" TIMESTAMP(3) NOT NULL, + "lastTimestamp" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DeferredScheduledEventService_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DeferredScheduledEventService_scheduleSourceId_key" ON "DeferredScheduledEventService"("scheduleSourceId"); + +-- AddForeignKey +ALTER TABLE "DeferredScheduledEventService" ADD CONSTRAINT "DeferredScheduledEventService_scheduleSourceId_fkey" FOREIGN KEY ("scheduleSourceId") REFERENCES "ScheduleSource"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 94ef58714..ad511c90b 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1389,8 +1389,9 @@ model ScheduleSource { dynamicTrigger DynamicTrigger? @relation(fields: [dynamicTriggerId], references: [id], onDelete: Cascade, onUpdate: Cascade) dynamicTriggerId String? - externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + externalAccount ExternalAccount? @relation(fields: [externalAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) externalAccountId String? + deferredEvent DeferredScheduledEventService? @@unique([key, environmentId]) } @@ -1515,6 +1516,19 @@ model DataMigration { completedAt DateTime? } +model DeferredScheduledEventService { + id String @id @default(cuid()) + + scheduleSource ScheduleSource @relation(fields: [scheduleSourceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + scheduleSourceId String @unique + + runAt DateTime + lastTimestamp DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // ==================================================== // v3 Models // ==================================================== diff --git a/references/job-catalog/src/schedules.ts b/references/job-catalog/src/schedules.ts index 89a687cb1..5f2cb1801 100644 --- a/references/job-catalog/src/schedules.ts +++ b/references/job-catalog/src/schedules.ts @@ -52,6 +52,27 @@ client.defineJob({ }, }); +client.defineJob({ + id: "deferred-schedule-example", + name: "Deferred Schedule Example", + version: "1.0.0", + enabled: true, + trigger: cronTrigger({ + cron: "0 */7 * * *", + }), + run: async (payload, io, ctx) => { + await io.runTask("task-example-1", async () => { + return { + message: "Hello World", + }; + }); + + await io.wait("wait-1", 1); + + await io.logger.info("Hello World", { ctx }); + }, +}); + const resend = new Resend({ id: "resend-client", apiKey: process.env.RESEND_API_KEY!, From c9ebe7f114107a42cbabdf6c3b7f38fed4853c9e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 20 May 2024 15:43:31 +0100 Subject: [PATCH 60/61] Update contributing guide for local v3 development --- .env.example | 5 ++ CONTRIBUTING.md | 173 ++++++++++++++++++++------------------ docker/docker-compose.yml | 17 ---- 3 files changed, 98 insertions(+), 97 deletions(-) diff --git a/.env.example b/.env.example index bd1d8e009..bf5be8f95 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,17 @@ REMIX_APP_PORT=3030 APP_ENV=development APP_ORIGIN=http://localhost:3030 NODE_ENV=development +CLOUD_ENV=development +V3_ENABLED=true # Redis is used for the v3 queuing and v2 concurrency control REDIS_HOST="localhost" REDIS_PORT="6379" REDIS_TLS_DISABLED="true" +DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:3030/otel" +DEV_OTEL_BATCH_PROCESSING_ENABLED="0" + # OPTIONAL VARIABLES # This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected. # WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 231cbdf1f..f4a8ad5f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,12 +10,12 @@ Thank you for helping us make Trigger.dev even better! 🤩 The development branch is `main`. This is the branch that all pull requests should be made against. The changes on the `main` -branch are tagged into a release monthly. +branch are tagged into a release periodically. ### Prerequisites -- [Node.js](https://nodejs.org/en) version >=16.x -- [pnpm package manager](https://pnpm.io/installation) version 7 +- [Node.js](https://nodejs.org/en) version 20.11.1 +- [pnpm package manager](https://pnpm.io/installation) version 8.15.5 - [Docker](https://www.docker.com/get-started/) ### Setup @@ -33,15 +33,19 @@ branch are tagged into a release monthly. ``` cd trigger.dev ``` -3. Install the required packages using pnpm. +3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository. + +4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file. + +5. Install the required packages using pnpm. ``` pnpm i ``` -4. Create your `.env` file +6. Create your `.env` file ``` cp .env.example .env ``` -5. Open it and generate a new value for `ENCRYPTION_KEY`: +7. Open it and generate a new value for `ENCRYPTION_KEY`: `ENCRYPTION_KEY` is used to two-way encrypt OAuth access tokens and so you'll probably want to actually generate a unique value, and it must be a random 16 byte hex string. You can generate one with the following command: @@ -51,7 +55,7 @@ branch are tagged into a release monthly. Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method. -6. Start Docker. This starts the required services like Postgres. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md) +8. Start Docker. This starts the required services like Postgres & Redis. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md) ``` pnpm run docker @@ -59,19 +63,15 @@ branch are tagged into a release monthly. This will also start and run a local instance of [pgAdmin](https://www.pgadmin.org/) on [localhost:5480](http://localhost:5480), preconfigured with email `admin@example.com` and pwd `admin`. Then use `postgres` as the password to the Trigger.dev server. -7. Migrate the database +9. Migrate the database ``` pnpm run db:migrate ``` -8. Build the app - ``` - pnpm run build --filter webapp - ``` -9. Run the seed script - ``` - pnpm run db:seed - ``` -10. Run the app. See the section below. +10. Build the server app + ``` + pnpm run build --filter webapp + ``` +11. Run the app. See the section below. ## Running @@ -83,99 +83,92 @@ branch are tagged into a release monthly. It should run on port `3030`: [http://localhost:3030](http://localhost:3030/) -2. Once the app is running click the magic link button and enter your email. -3. Check your terminal, the magic link email should have printed out as following: +2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard. - ```sh - webapp:dev: Log in to Trigger.dev - webapp:dev: - webapp:dev: Click here to log in with this magic link - webapp:dev: [http://localhost:3030/magic?token=U2FsdGVkX18OvB0JxgaswTLCSbaRz%2FY82TN0EZWhSzFyZYwgG%2BIzKVTkeiaOtWfotPw7F8RwFzCHh53aBpMEu%2B%2B%2FItb%2FcJYh89MSjc3Pz92bevoEjqxSQ%2Ff%2BZbks09JOpqlBbYC3FzGWC8vuSVFBlxqLXxteSDLthZSUaC%2BS2LaA%2BJgp%2BLO7hgjAaC2lXbCHrM7MTgTdXOFt7i0Dvvuwz6%2BWY25RnfomZOPqDsyH0xz8Q2rzPTz0Xu53WSXrZ1hd] - webapp:dev: - webapp:dev: If you didn't try to log in, you can safely ignore this email. - ``` +## Manual testing using v3-catalog - Paste the magic link shown in your terminal into your browser to login. +We use the `/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `packages/core`), the CLI (`trigger.dev` at `/packages/cli-v3`) and the platform (The remix app at `/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3). -## Adding and running migrations +### First-time setup -1. Modify packages/database/prisma/schema.prisma file -2. Change directory to the packages/database folder - ```sh - cd packages/database - ``` -3. Generate the Prisma client +First, make sure you are running the webapp according to the instructions above. Then: - ```sh - pnpm run generate - ``` +1. In Postgres go to the "Organizations" table and on your org set the `v3Enabled` column to `true`. - The above updates the prisma client generated into node_modules/.prisma/client folder. This helps with typing of relevant prisma models. It ensures typescript - recognizes fields added or removed from a model and type-checks appropriately. +2. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog". If you don't see an option for V3, you haven't set the `v3Enabled` flag to true. -4. Create and apply the migrations +3. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`. - ``` - pnpm run db:migrate:dev - ``` - - This creates a migration file and executes the migrations against your database and applies changes to the database schema(s) - -5. Commit generated migrations as well as changes to the schema.prisma file -6. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`. - -## Testing CLI changes - -To test CLI changes, follow the steps below: - -1. Build the CLI and watch for changes +4. Build the CLI ```sh -cd packages/cli -pnpm run dev +pnpm run build --filter trigger.dev ``` -2. Open a new Terminal window and run the webapp locally and then create a new project in the dashboard. Copy out the dev API key. - -3. Create a new temporary Next.js app in references directory +5. Change into the `/references/v3-catalog` directory and authorize the CLI to the local server: ```sh -cd ./references -pnpm create next-app@latest test-cli --ts --no-eslint --tailwind --app --src-dir --import-alias "@/*" +cd references/v3-catalog +pnpm exec triggerdev login -a http://localhost:3030 ``` -4. Then once that's finished, add the `@trigger.dev/cli` to the `devDependencies` of the newly created Next.js app's `package.json` file, like so: +This will open a new browser window and authorize the CLI against your local user account. -```json -{ - // other package.json properties - "devDependencies": { "@trigger.dev/cli": "workspace:*" } -} -``` - -5. Back in the terminal, navigate into the reference, and initialize the CLI. When prompted, select `self-hosted` and enter `localhost:3030` if you are testing against the local instance of Trigger.dev, or you can just use the Trigger.dev cloud. When asked for an API key, use the key you copied earlier. +You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development: ```sh -cd ./test-cli -pnpm i -pnpm exec trigger-cli init +cd references/v3-catalog +pnpm exec triggerdev login -a http://localhost:3030 --profile local +# later when you run the dev or deploy command: +pnpm exec triggerdev dev --profile local +pnpm exec triggerdev deploy --profile local ``` -6. If you are just testing the `init` command, you can stop here. If you'd like to test the `dev` command, first start the Next.js app on port 3000: +### Running + +The following steps should be followed any time you start working on a new feature you want to test in v3: + +1. Make sure the webapp is running on localhost:3030 + +2. Open a terminal window and build the CLI and watch for changes ```sh -pnpm run dev +pnpm run dev --filter trigger.dev ``` -7. Open a new terminal window, and then run the `dev` command like so: +2. Open a new terminal window, and anytime changes are made to the `@trigger.dev/core` package, you'll need to manually rebuild the CLI: ```sh -pnpm exec trigger-cli dev +pnpm run build --filter trigger.dev ``` -8. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR. +Note: You do not need to do the same for `@trigger.dev/sdk`, just core. -## Running end-to-end webapp tests +3. Open another terminal window, and change into the `/references/v3-catalog` directory. + +4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution: + +```sh +# in /references/v3-catalog +pnpm exec triggerdev dev +``` + +If you want additional debug logging, you can use the `--log-level debug` flag: + +```sh +# in /references/v3-catalog +pnpm exec triggerdev dev --log-level debug +``` + +5. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command. + +6. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks. + +7. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload. + +8. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases. + +## Running end-to-end webapp tests (deprecated) To run the end-to-end tests, follow the steps below: @@ -223,6 +216,26 @@ The end-to-end tests use a `setup` and `teardown` script to seed the database wi pnpm run db:studio ``` +## Adding and running migrations + +1. Modify packages/database/prisma/schema.prisma file +2. Change directory to the packages/database folder + + ```sh + cd packages/database + ``` + +3. Create and apply the migrations + + ``` + pnpm run db:migrate:dev + ``` + + This creates a migration file and executes the migrations against your database and applies changes to the database schema(s) + +4. Commit generated migrations as well as changes to the schema.prisma file +5. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`. + ## Add sample jobs The [references/job-catalog](./references/job-catalog/) project defines simple jobs you can get started with. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c7fa6ab16..1e4950452 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -53,20 +53,3 @@ services: - app_network ports: - 6379:6379 - - otel-collector: - container_name: otel-collector - image: otel/opentelemetry-collector-contrib:latest - restart: always - command: ["--config", "/etc/otel-collector-config.yaml"] - volumes: - - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ports: - - "55680:55680" - - "55681:55681" - - "4317:4317" # OTLP gRPC receiver - - "4318:4318" # OTLP http receiver - - "4319:4319" # OTLP internal http receiver - environment: - BASELIME_API_KEY: ${BASELIME_API_KEY} - BASELIME_SERVICE: ${BASELIME_SERVICE} From a5cba375ae7ee20345ae3d839ad30ca2f1491dee Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 20 May 2024 17:17:33 +0100 Subject: [PATCH 61/61] Bulk replaying and canceling from the runs list (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP on multi-select * WIP on simple checkbox * CheckboxWIthLabel and Checkbox * Multi-selection of runs across pages is working * Fix for selection on seconds page * Focus the run filter on page load * Don’t focus the checkbox * BulkActionBar now shows/hides and has buttons * Some state to stop escape clearing the selection when the modals are open * Delete unused formData util * Improvements to the page * Created the replay resource action. It doesn’t do anything useful yet. * Database schema created for BulkActionGroup/BulkActionItem * The BulkActionService is creating the right data, now we need to process it * WIP on bulk processing * Added failed state and made the sourceRun required * Bulk replaying is working * WIP on bulk action filtering * Fixed bulk filters displaying * Filtering by batch is working * Some fixes for the bulk id filtering * Style tweaks * Load the extra info in parallel * Bulk canceling working * Get the most recent 20 bulk actions to display in the filter menu * Even if the run isn’t cancelable add it to the final list * Maximum of 250 runs can be bulk actioned * Don’t let them select more than the maximum (250 currently) * Separate each bulk item action into it’s own separate graphile job to increase resiliency --------- Co-authored-by: Eric Allam --- .../integrations/ConnectToOAuthForm.tsx | 6 +- .../integrations/UpdateOAuthForm.tsx | 6 +- .../app/components/primitives/Checkbox.tsx | 26 +- .../primitives/SelectedItemsProvider.tsx | 146 ++++++++ .../app/components/primitives/Table.tsx | 6 +- .../app/components/runs/v3/BulkAction.tsx | 73 ++++ .../app/components/runs/v3/RunFilters.tsx | 128 ++++++- .../app/components/runs/v3/TaskRunsTable.tsx | 98 +++++- apps/webapp/app/consts.ts | 1 + .../presenters/v3/RunListPresenter.server.ts | 61 +++- .../route.tsx | 12 +- .../route.tsx | 4 +- .../route.tsx | 311 +++++++++++++++--- .../app/routes/account._index/route.tsx | 4 +- .../route.tsx | 4 +- .../routes/resources.taskruns.bulk.cancel.ts | 93 ++++++ .../routes/resources.taskruns.bulk.replay.ts | 93 ++++++ .../app/routes/storybook.checkboxes/route.tsx | 16 +- apps/webapp/app/services/worker.server.ts | 27 ++ apps/webapp/app/utils/formData.ts | 10 - apps/webapp/app/utils/numberFormatter.ts | 6 + .../services/bulk/createBulkAction.server.ts | 60 ++++ .../services/bulk/performBulkAction.server.ts | 126 +++++++ .../app/v3/services/cancelTaskRun.server.ts | 12 +- .../migration.sql | 54 +++ .../migration.sql | 8 + .../migration.sql | 5 + packages/database/prisma/schema.prisma | 68 ++++ .../v3-catalog/src/trigger/concurrency.ts | 2 +- 29 files changed, 1351 insertions(+), 115 deletions(-) create mode 100644 apps/webapp/app/components/primitives/SelectedItemsProvider.tsx create mode 100644 apps/webapp/app/components/runs/v3/BulkAction.tsx create mode 100644 apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts create mode 100644 apps/webapp/app/routes/resources.taskruns.bulk.replay.ts delete mode 100644 apps/webapp/app/utils/formData.ts create mode 100644 apps/webapp/app/v3/services/bulk/createBulkAction.server.ts create mode 100644 apps/webapp/app/v3/services/bulk/performBulkAction.server.ts create mode 100644 packages/database/prisma/migrations/20240517135206_created_bulk_action_group_and_bulk_action_item_for_canceling_and_replaying_in_bulk/migration.sql create mode 100644 packages/database/prisma/migrations/20240517164246_bulk_action_item_source_run_id_is_required/migration.sql create mode 100644 packages/database/prisma/migrations/20240517164924_bulk_action_item_added_failed_state_with_error/migration.sql diff --git a/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx b/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx index a6757a6db..3f4e6159a 100644 --- a/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx +++ b/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx @@ -12,7 +12,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex import { cn } from "~/utils/cn"; import { CodeBlock } from "../code/CodeBlock"; import { Button } from "../primitives/Buttons"; -import { Checkbox } from "../primitives/Checkbox"; +import { CheckboxWithLabel } from "../primitives/Checkbox"; import { Fieldset } from "../primitives/Fieldset"; import { FormError } from "../primitives/FormError"; import { Header2, Header3 } from "../primitives/Headers"; @@ -123,7 +123,7 @@ export function ConnectToOAuthForm({ To use your own OAuth app, check the option below and insert the details. - { return ( - To use your own OAuth app, check the option below and insert the details. - { return ( - , "checked" | "onChange" > & { - id: string; + id?: string; name?: string; value?: string; variant?: keyof typeof variants; - label?: React.ReactNode; + label: React.ReactNode; description?: string; badges?: string[]; className?: string; onChange?: (isChecked: boolean) => void; }; -export const Checkbox = React.forwardRef( +export const CheckboxWithLabel = React.forwardRef( ( { id, @@ -172,3 +172,21 @@ export const Checkbox = React.forwardRef( ); } ); + +type SimpleCheckboxProps = Omit, "type">; + +export const Checkbox = forwardRef( + ({ className, ...props }: SimpleCheckboxProps, ref) => { + return ( + + ); + } +); diff --git a/apps/webapp/app/components/primitives/SelectedItemsProvider.tsx b/apps/webapp/app/components/primitives/SelectedItemsProvider.tsx new file mode 100644 index 000000000..12f4b68f9 --- /dev/null +++ b/apps/webapp/app/components/primitives/SelectedItemsProvider.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { createContext, useCallback, useContext, useReducer } from "react"; + +type SelectedItemsContext = { + selectedItems: Set; + select: (items: string | string[]) => void; + deselect: (items: string | string[]) => void; + toggle: (items: string | string[]) => void; + deselectAll: () => void; + has: (item: string) => boolean; + hasAll: (items: string[]) => boolean; +}; + +const SelectedItemsContext = createContext({} as SelectedItemsContext); + +export function useSelectedItems(enabled = true) { + const context = useContext(SelectedItemsContext); + if (!context && enabled) { + throw new Error("useSelectedItems must be used within a SelectedItemsProvider"); + } + + return context; +} + +export function SelectedItemsProvider({ + initialSelectedItems, + maxSelectedItemCount, + children, +}: { + initialSelectedItems: string[]; + maxSelectedItemCount?: number; + children: React.ReactNode | ((context: SelectedItemsContext) => React.ReactNode); +}) { + const [state, dispatch] = useReducer(selectedItemsReducer, { + items: new Set(initialSelectedItems), + maxSelectedItemCount, + }); + + const select = useCallback((items: string | string[]) => { + dispatch({ type: "select", items: Array.isArray(items) ? items : [items] }); + }, []); + + const deselect = useCallback((items: string | string[]) => { + dispatch({ type: "deselect", items: Array.isArray(items) ? items : [items] }); + }, []); + + const toggle = useCallback((items: string | string[]) => { + dispatch({ type: "toggle", items: Array.isArray(items) ? items : [items] }); + }, []); + + const deselectAll = useCallback(() => { + dispatch({ type: "deselectAll" }); + }, []); + + const has = useCallback((item: string) => state.items.has(item), [state]); + + const hasAll = useCallback( + (items: string[]) => items.every((item) => state.items.has(item)), + [state] + ); + + return ( + + {typeof children === "function" + ? children({ + selectedItems: state.items, + select, + deselect, + toggle, + deselectAll, + has, + hasAll, + }) + : children} + + ); +} + +type SelectItemsAction = { + type: "select"; + items: string[]; +}; + +type DeSelectItemsAction = { + type: "deselect"; + items: string[]; +}; + +type DeselectAllItemsAction = { + type: "deselectAll"; +}; + +type ToggleItemsAction = { + type: "toggle"; + items: string[]; +}; + +type Action = SelectItemsAction | DeSelectItemsAction | ToggleItemsAction | DeselectAllItemsAction; + +function selectedItemsReducer( + state: { items: Set; maxSelectedItemCount?: number }, + action: Action +) { + switch (action.type) { + case "select": + const items = new Set([...state.items, ...action.items]); + return { ...state, items: cappedSet(items, state.maxSelectedItemCount) }; + case "deselect": + const newItems = new Set(state.items); + action.items.forEach((item) => { + newItems.delete(item); + }); + return { ...state, items: cappedSet(newItems, state.maxSelectedItemCount) }; + case "toggle": + let newSet = new Set(state.items); + action.items.forEach((item) => { + if (newSet.has(item)) { + newSet.delete(item); + } else { + newSet.add(item); + } + }); + return { ...state, items: cappedSet(newSet, state.maxSelectedItemCount) }; + case "deselectAll": + return { ...state, items: new Set() }; + default: + return state; + } +} + +function cappedSet(set: Set, max?: number) { + if (!max) { + return set; + } + + if (set.size <= max) { + return set; + } + + console.warn(`Selected items exceeded the maximum count of ${max}.`); + + return new Set([...set].slice(0, max)); +} diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 38603b167..0a618ec14 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef( } const flexClasses = cn( - "flex w-full whitespace-nowrap px-4 py-3 text-xs text-text-dimmed", + "flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed", alignment === "left" ? "justify-start text-left" : alignment === "center" @@ -170,7 +170,7 @@ export const TableCell = forwardRef( "text-xs text-charcoal-400", to || onClick || hasAction ? "cursor-pointer group-hover/table-row:bg-charcoal-900" - : "px-4 py-3 align-middle", + : "px-3 py-3 align-middle", !to && !onClick && alignmentClassName, isSticky && stickyStyles, className diff --git a/apps/webapp/app/components/runs/v3/BulkAction.tsx b/apps/webapp/app/components/runs/v3/BulkAction.tsx new file mode 100644 index 000000000..ab570b4fa --- /dev/null +++ b/apps/webapp/app/components/runs/v3/BulkAction.tsx @@ -0,0 +1,73 @@ +import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid"; +import { BulkActionType } from "@trigger.dev/database"; +import assertNever from "assert-never"; +import { cn } from "~/utils/cn"; + +export function BulkActionStatusCombo({ + type, + className, + iconClassName, +}: { + type: BulkActionType; + className?: string; + iconClassName?: string; +}) { + return ( + + + + + ); +} + +export function BulkActionLabel({ type }: { type: BulkActionType }) { + return {bulkActionTitle(type)}; +} + +export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) { + switch (type) { + case "REPLAY": + return ; + case "CANCEL": + return ; + default: { + assertNever(type); + } + } +} + +export function bulkActionClassName(type: BulkActionType): string { + switch (type) { + case "REPLAY": + return "text-indigo-500"; + case "CANCEL": + return "text-rose-500"; + default: { + assertNever(type); + } + } +} + +export function bulkActionTitle(type: BulkActionType): string { + switch (type) { + case "REPLAY": + return "Replay"; + case "CANCEL": + return "Cancel"; + default: { + assertNever(type); + } + } +} + +export function bulkActionVerb(type: BulkActionType): string { + switch (type) { + case "REPLAY": + return "Replaying"; + case "CANCEL": + return "Canceling"; + default: { + assertNever(type); + } + } +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 8eac8b5ec..49f90333c 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1,7 +1,18 @@ import * as Ariakit from "@ariakit/react"; -import { CalendarIcon, CpuChipIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { + ArrowPathIcon, + CalendarIcon, + CpuChipIcon, + InboxStackIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import { RuntimeEnvironment, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; +import { + BulkActionType, + RuntimeEnvironment, + TaskRunStatus, + TaskTriggerSource, +} from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-react"; import { ReactNode, startTransition, useCallback, useMemo, useState } from "react"; import { z } from "zod"; @@ -36,6 +47,8 @@ import { runStatusTitle, } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; +import { DateTime } from "~/components/primitives/DateTime"; +import { BulkActionStatusCombo } from "./BulkAction"; export const TaskAttemptStatus = z.nativeEnum(TaskRunStatus); @@ -59,6 +72,7 @@ export const TaskRunListSearchFilters = z.object({ TaskAttemptStatus.array().optional() ), period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), + bulkId: z.string().optional(), from: z.coerce.number().optional(), to: z.coerce.number().optional(), }); @@ -72,6 +86,11 @@ type DisplayableEnvironment = Pick & { type RunFiltersProps = { possibleEnvironments: DisplayableEnvironment[]; possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[]; + bulkActions: { + id: string; + type: BulkActionType; + createdAt: Date; + }[]; hasFilters: boolean; }; @@ -82,7 +101,8 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("statuses") || searchParams.has("environments") || searchParams.has("tasks") || - searchParams.has("period"); + searchParams.has("period") || + searchParams.has("bulkId"); return (
@@ -112,7 +132,8 @@ const filterTypes = [ { name: "environments", title: "Environment", icon: }, { name: "tasks", title: "Tasks", icon: }, { name: "created", title: "Created", icon: }, -]; + { name: "bulk", title: "Bulk action", icon: }, +] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -123,6 +144,7 @@ function FilterMenu(props: RunFiltersProps) { const filterTrigger = ( @@ -180,13 +202,14 @@ function FilterMenuProvider({ ); } -function AppliedFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) { +function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) { return ( <> + ); } @@ -211,6 +234,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "created": return props.setFilterType(undefined)} {...props} />; + case "bulk": + return props.setFilterType(undefined)} {...props} />; } } @@ -529,6 +554,99 @@ function AppliedTaskFilter({ possibleTasks }: Pick void; + searchValue: string; + onClose?: () => void; + bulkActions: RunFiltersProps["bulkActions"]; +}) { + const { value, replace } = useSearchParams(); + + const handleChange = (value: string) => { + clearSearchValue(); + replace({ bulkId: value, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return bulkActions.filter((item) => { + return ( + item.type.toLowerCase().includes(searchValue.toLowerCase()) || + item.createdAt.toISOString().includes(searchValue) + ); + }); + }, [searchValue, bulkActions]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + None + {filtered.map((item, index) => ( + +
+ + +
+
+ ))} +
+
+
+ ); +} + +function AppliedBulkActionsFilter({ bulkActions }: Pick) { + const { value, del } = useSearchParams(); + + const bulkId = value("bulkId"); + + if (!bulkId) { + return null; + } + + const action = bulkActions.find((action) => action.id === bulkId); + + return ( + + {(search, setSearch) => ( + }> + del(["bulkId", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + bulkActions={bulkActions} + /> + )} + + ); +} + const timePeriods = [ { label: "All periods", diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 3e30b6325..7d0e52659 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -28,6 +28,11 @@ import { CancelRunDialog } from "./CancelRunDialog"; import { ReplayRunDialog } from "./ReplayRunDialog"; import { TaskRunStatusCombo } from "./TaskRunStatus"; import { LiveTimer } from "./LiveTimer"; +import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider"; +import { Checkbox } from "~/components/primitives/Checkbox"; +import { useCallback, useRef } from "react"; +import { run } from "@remix-run/dev/dist/cli/run"; +import { formatNumber } from "~/utils/numberFormatter"; type RunsTableProps = { total: number; @@ -36,6 +41,7 @@ type RunsTableProps = { showJob?: boolean; runs: RunListItem[]; isLoading?: boolean; + allowSelection?: boolean; }; export function TaskRunsTable({ @@ -44,15 +50,66 @@ export function TaskRunsTable({ filters, runs, isLoading = false, + allowSelection = false, }: RunsTableProps) { const organization = useOrganization(); const project = useProject(); + const checkboxes = useRef<(HTMLInputElement | null)[]>([]); + const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); + + const navigateCheckboxes = useCallback( + (event: React.KeyboardEvent, index: number) => { + //indexes are out by one because of the header row + if (event.key === "ArrowUp" && index > 0) { + checkboxes.current[index - 1]?.focus(); + + if (event.shiftKey) { + const oldItem = runs.at(index - 1); + const newItem = runs.at(index - 2); + const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean); + select(itemsIds); + } + } else if (event.key === "ArrowDown" && index < checkboxes.current.length - 1) { + checkboxes.current[index + 1]?.focus(); + + if (event.shiftKey) { + const oldItem = runs.at(index - 1); + const newItem = runs.at(index); + const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean); + select(itemsIds); + } + } + }, + [checkboxes, runs] + ); return ( - Run + {allowSelection && ( + + {runs.length > 0 && ( + r.id))} + onChange={(element) => { + const ids = runs.map((r) => r.id); + const checked = element.currentTarget.checked; + if (checked) { + select(ids); + } else { + deselect(ids); + } + }} + ref={(r) => { + checkboxes.current[0] = r; + }} + onKeyDown={(event) => navigateCheckboxes(event, 0)} + /> + )} + + )} + Run # Task ID Version Env @@ -74,11 +131,27 @@ export function TaskRunsTable({ ) : runs.length === 0 ? ( ) : ( - runs.map((run) => { + runs.map((run, index) => { const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId }); return ( - #{run.number} + {allowSelection && ( + + { + toggle(run.id); + }} + ref={(r) => { + checkboxes.current[index + 1] = r; + }} + onKeyDown={(event) => navigateCheckboxes(event, index + 1)} + /> + + )} + + {formatNumber(run.number)} + {run.taskIdentifier} {run.version ?? "–"} @@ -194,7 +267,7 @@ function BlankState({ isLoading, filters }: Pick env.id === filters.environments[0]); return ( - +
There are no runs for {filters.tasks[0]} @@ -235,8 +308,21 @@ function BlankState({ isLoading, filters }: Pick - + +
+ + No runs currently match your filters. Try refreshing or modifying your filters. + + +
); } diff --git a/apps/webapp/app/consts.ts b/apps/webapp/app/consts.ts index 187e31e0f..30dba9c7f 100644 --- a/apps/webapp/app/consts.ts +++ b/apps/webapp/app/consts.ts @@ -12,3 +12,4 @@ export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504]; export const MAX_BATCH_TRIGGER_ITEMS = 100; export const MAX_TASK_RUN_ATTEMPTS = 250; +export const BULK_ACTION_RUN_LIMIT = 250; diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index d78b0c708..a020b8f7f 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -17,6 +17,7 @@ type RunListOptions = { environments?: string[]; scheduleId?: string; period?: string; + bulkId?: string; from?: number; to?: number; //pagination @@ -25,7 +26,7 @@ type RunListOptions = { pageSize?: number; }; -const DEFAULT_PAGE_SIZE = 20; +const DEFAULT_PAGE_SIZE = 25; export type RunList = Awaited>; export type RunListItem = RunList["runs"][0]; @@ -41,6 +42,7 @@ export class RunListPresenter extends BasePresenter { environments, scheduleId, period, + bulkId, from, to, direction = "forward", @@ -55,6 +57,7 @@ export class RunListPresenter extends BasePresenter { hasStatusFilters || (environments !== undefined && environments.length > 0) || (period !== undefined && period !== "all") || + (bulkId !== undefined && bulkId !== "") || from !== undefined || to !== undefined; @@ -87,13 +90,55 @@ export class RunListPresenter extends BasePresenter { }); //get all possible tasks - const possibleTasks = await this._replica.backgroundWorkerTask.findMany({ + const possibleTasksAsync = this._replica.backgroundWorkerTask.findMany({ distinct: ["slug"], where: { projectId: project.id, }, }); + //get possible bulk actions + const bulkActionsAsync = this._replica.bulkActionGroup.findMany({ + select: { + friendlyId: true, + type: true, + createdAt: true, + }, + where: { + projectId: project.id, + }, + orderBy: { + createdAt: "desc", + }, + take: 20, + }); + + const [possibleTasks, bulkActions] = await Promise.all([possibleTasksAsync, bulkActionsAsync]); + + //we can restrict to specific runs using bulkId, or batchId + let restrictToRunIds: undefined | string[] = undefined; + + //bulk id + if (bulkId) { + const bulkAction = await this._replica.bulkActionGroup.findUnique({ + select: { + items: { + select: { + destinationRunId: true, + }, + }, + }, + where: { + friendlyId: bulkId, + }, + }); + + if (bulkAction) { + const runIds = bulkAction.items.map((item) => item.destinationRunId).filter(Boolean); + restrictToRunIds = runIds; + } + } + const periodMs = period ? parse(period) : undefined; //get the runs @@ -142,6 +187,13 @@ export class RunListPresenter extends BasePresenter { : Prisma.empty } -- filters + ${ + restrictToRunIds + ? restrictToRunIds.length === 0 + ? Prisma.sql`AND tr.id = ''` + : Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})` + : Prisma.empty + } ${ tasks && tasks.length > 0 ? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})` @@ -240,6 +292,11 @@ export class RunListPresenter extends BasePresenter { .sort((a, b) => { return a.slug.localeCompare(b.slug); }), + bulkActions: bulkActions.map((bulkAction) => ({ + id: bulkAction.friendlyId, + type: bulkAction.type, + createdAt: bulkAction.createdAt, + })), filters: { tasks: tasks || [], versions: versions || [], diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx index 7b3defaf8..188f8fd19 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx @@ -11,7 +11,7 @@ import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout, variantClasses } from "~/components/primitives/Callout"; -import { Checkbox } from "~/components/primitives/Checkbox"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -363,7 +363,7 @@ export default function Page() {
-
- - - - Environments
{environments.map((environment) => ( - { const userId = await requireUserId(request); @@ -37,8 +52,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environments: url.searchParams.getAll("environments"), tasks: url.searchParams.getAll("tasks"), period: url.searchParams.get("period") ?? undefined, + bulkId: url.searchParams.get("bulkId") ?? undefined, }; - const { tasks, versions, statuses, environments, period, from, to, cursor, direction } = + const { tasks, versions, statuses, environments, period, bulkId, from, to, cursor, direction } = TaskRunListSearchFilters.parse(s); const presenter = new RunListPresenter(); @@ -50,6 +66,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { statuses, environments, period, + bulkId, from, to, direction: direction, @@ -73,60 +90,246 @@ export default function Page() { - - -
- - Loading runs -
-
- } + + - - {(list) => ( - <> - {list.runs.length === 0 && !list.hasFilters ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
-
- -
- -
+ {({ selectedItems }) => ( +
+
+ +
+ + Loading runs
- - -
-
- )} - - )} - - + } + > + + {(list) => ( + <> + {list.runs.length === 0 && !list.hasFilters ? ( + list.possibleTasks.length === 0 ? ( + + ) : ( + + ) + ) : ( +
+
+
+ +
+ +
+
+ + + +
+
+ )} + + )} +
+ +
+ +
+ )} + ); } +function BulkActionBar() { + const { selectedItems, deselectAll } = useSelectedItems(); + const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none"); + + const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT; + + return ( + + {selectedItems.size > 0 && ( + +
+ Bulk actions: + {hasSelectedMaximum ? ( + Maximum of {selectedItems.size} runs selected + ) : ( + {selectedItems.size} runs selected + )} +
+
+ { + if (o) { + setBarState("cancel"); + } else { + setBarState("none"); + } + }} + /> + { + if (o) { + setBarState("replay"); + } else { + setBarState("none"); + } + }} + /> + +
+
+ )} +
+ ); +} + +function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) { + const { selectedItems } = useSelectedItems(); + + const organization = useOrganization(); + const project = useProject(); + const failedRedirect = v3RunsPath(organization, project); + + const formAction = `/resources/taskruns/bulk/cancel`; + + const navigation = useNavigation(); + const isLoading = navigation.formAction === formAction; + + return ( + onOpen(o)}> + + + + + Cancel {selectedItems.size} runs? + + Canceling these runs will stop them from running. Only runs that are not already finished + will be canceled, the others will remain in their existing state. + + +
+ + + + {[...selectedItems].map((runId) => ( + + ))} + + +
+
+
+ ); +} + +function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) { + const { selectedItems } = useSelectedItems(); + + const organization = useOrganization(); + const project = useProject(); + const failedRedirect = v3RunsPath(organization, project); + + const formAction = `/resources/taskruns/bulk/replay`; + + const navigation = useNavigation(); + const isLoading = navigation.formAction === formAction; + + return ( + onOpen(o)}> + + + + + Replay runs? + + Replaying these runs will create a new run for each with the same payload and environment + as the original. It will use the latest version of the code for each task. + + +
+ + + + {[...selectedItems].map((runId) => ( + + ))} + + +
+
+
+ ); +} + function CreateFirstTaskInstructions() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 8a5c97412..4f31e9357 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -6,7 +6,7 @@ import { z } from "zod"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; -import { Checkbox } from "~/components/primitives/Checkbox"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; @@ -158,7 +158,7 @@ export default function Page() { - Environments
{possibleEnvironments.map((environment) => ( - {isDisabled ? "Enable checkboxes" : "Disable checkboxes"} - - - - -
- - { + const service = new PerformBulkActionService(); + + return await service.call(payload.bulkActionGroupId); + }, + }, + "v3.performBulkActionItem": { + priority: 0, + maxAttempts: 3, + handler: async (payload, job) => { + const service = new PerformBulkActionService(); + + await service.performBulkActionItem(payload.bulkActionItemId); + }, + }, }, }); } diff --git a/apps/webapp/app/utils/formData.ts b/apps/webapp/app/utils/formData.ts deleted file mode 100644 index 829036526..000000000 --- a/apps/webapp/app/utils/formData.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { z } from "zod"; - -export function formDataAsObject( - formData: FormData, - schema: z.ZodSchema -): TValues { - const object = Object.fromEntries(formData.entries()); - const parsed = schema.parse(object); - return parsed; -} diff --git a/apps/webapp/app/utils/numberFormatter.ts b/apps/webapp/app/utils/numberFormatter.ts index 4056bfec0..0103e9263 100644 --- a/apps/webapp/app/utils/numberFormatter.ts +++ b/apps/webapp/app/utils/numberFormatter.ts @@ -4,6 +4,12 @@ export const formatNumberCompact = (num: number): string => { return compactFormatter.format(num); }; +const formatter = Intl.NumberFormat("en"); + +export const formatNumber = (num: number): string => { + return formatter.format(num); +}; + const roundedCurrencyFormatter = Intl.NumberFormat("en-US", { style: "currency", currencyDisplay: "symbol", diff --git a/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts b/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts new file mode 100644 index 000000000..966297471 --- /dev/null +++ b/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts @@ -0,0 +1,60 @@ +import { BulkActionType } from "@trigger.dev/database"; +import { bulkActionVerb } from "~/components/runs/v3/BulkAction"; +import { logger } from "~/services/logger.server"; +import { generateFriendlyId } from "../../friendlyIdentifiers"; +import { BaseService } from "../baseService.server"; +import { PerformBulkActionService } from "./performBulkAction.server"; +import { BULK_ACTION_RUN_LIMIT } from "~/consts"; + +type BulkAction = { + projectId: string; + action: BulkActionType; + runIds: string[]; +}; + +export class CreateBulkActionService extends BaseService { + public async call({ projectId, action, runIds }: BulkAction) { + const group = await this._prisma.bulkActionGroup.create({ + data: { + friendlyId: generateFriendlyId("bulk"), + projectId, + type: action, + }, + }); + + //limit to the first X runs + const passedTooManyRuns = runIds.length > BULK_ACTION_RUN_LIMIT; + runIds = runIds.slice(0, BULK_ACTION_RUN_LIMIT); + + const items = await this._prisma.bulkActionItem.createMany({ + data: runIds.map((runId) => ({ + friendlyId: generateFriendlyId("bulkitem"), + type: action, + groupId: group.id, + sourceRunId: runId, + })), + }); + + logger.debug("Created bulk action group", { + groupId: group.id, + action, + runIds, + }); + + await PerformBulkActionService.enqueue(group.id, this._prisma); + + let message = bulkActionVerb(action); + if (passedTooManyRuns) { + message += ` the first ${BULK_ACTION_RUN_LIMIT} runs`; + } else { + message += ` ${runIds.length} runs`; + } + + return { + id: group.id, + friendlyId: group.friendlyId, + runCount: runIds.length, + message, + }; + } +} diff --git a/apps/webapp/app/v3/services/bulk/performBulkAction.server.ts b/apps/webapp/app/v3/services/bulk/performBulkAction.server.ts new file mode 100644 index 000000000..26d97a1db --- /dev/null +++ b/apps/webapp/app/v3/services/bulk/performBulkAction.server.ts @@ -0,0 +1,126 @@ +import assertNever from "assert-never"; +import { PrismaClientOrTransaction } from "~/db.server"; +import { workerQueue } from "~/services/worker.server"; +import { BaseService } from "../baseService.server"; +import { CancelTaskRunService } from "../cancelTaskRun.server"; +import { ReplayTaskRunService } from "../replayTaskRun.server"; + +export class PerformBulkActionService extends BaseService { + public async performBulkActionItem(bulkActionItemId: string) { + const item = await this._prisma.bulkActionItem.findUnique({ + where: { id: bulkActionItemId }, + include: { + group: true, + sourceRun: true, + destinationRun: true, + }, + }); + + if (!item) { + return; + } + + if (item.status !== "PENDING") { + return; + } + + switch (item.group.type) { + case "REPLAY": { + const service = new ReplayTaskRunService(this._prisma); + const result = await service.call(item.sourceRun); + + await this._prisma.bulkActionItem.update({ + where: { id: item.id }, + data: { + destinationRunId: result?.id, + status: result ? "COMPLETED" : "FAILED", + error: result ? undefined : "Failed to replay task run", + }, + }); + + break; + } + case "CANCEL": { + const service = new CancelTaskRunService(this._prisma); + + const result = await service.call(item.sourceRun); + + await this._prisma.bulkActionItem.update({ + where: { id: item.id }, + data: { + destinationRunId: item.sourceRun.id, + status: result ? "COMPLETED" : "FAILED", + error: result ? undefined : "Task wasn't cancelable", + }, + }); + + break; + } + default: { + assertNever(item.group.type); + } + } + + const groupItems = await this._prisma.bulkActionItem.findMany({ + where: { groupId: item.groupId }, + select: { + status: true, + }, + }); + + const isGroupCompleted = groupItems.every((item) => item.status !== "PENDING"); + + if (isGroupCompleted) { + await this._prisma.bulkActionItem.update({ + where: { id: item.id }, + data: { + status: "COMPLETED", + }, + }); + } + } + + public async enqueueBulkActionItem(bulkActionItemId: string, groupId: string) { + await workerQueue.enqueue( + "v3.performBulkActionItem", + { + bulkActionItemId, + }, + { + jobKey: `performBulkActionItem:${bulkActionItemId}`, + queueName: `bulkActionItem:${groupId}`, + } + ); + } + + public async call(bulkActionGroupId: string) { + const actionGroup = await this._prisma.bulkActionGroup.findUnique({ + include: { + items: true, + }, + where: { id: bulkActionGroupId }, + }); + + if (!actionGroup) { + return; + } + + for (const item of actionGroup.items) { + await this.enqueueBulkActionItem(item.id, bulkActionGroupId); + } + } + + static async enqueue(bulkActionGroupId: string, tx: PrismaClientOrTransaction, runAt?: Date) { + return await workerQueue.enqueue( + "v3.performBulkAction", + { + bulkActionGroupId, + }, + { + tx, + runAt, + jobKey: `performBulkAction:${bulkActionGroupId}`, + } + ); + } +} diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index 6e86af3d4..72179a0dc 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -1,12 +1,12 @@ import { Prisma, TaskRun, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database"; -import { eventRepository } from "../eventRepository.server"; +import assertNever from "assert-never"; +import { logger } from "~/services/logger.server"; import { marqs } from "~/v3/marqs/index.server"; +import { eventRepository } from "../eventRepository.server"; +import { socketIo } from "../handleSocketIo.server"; import { devPubSub } from "../marqs/devPubSub.server"; import { BaseService } from "./baseService.server"; -import { socketIo } from "../handleSocketIo.server"; import { CancelAttemptService } from "./cancelAttempt.server"; -import { logger } from "~/services/logger.server"; -import assertNever from "assert-never"; export const CANCELLABLE_STATUSES: Array = [ "PENDING", @@ -97,6 +97,10 @@ export class CancelTaskRunService extends BaseService { if (opts.cancelAttempts) { await this.#cancelPotentiallyRunningAttempts(cancelledTaskRun, cancelledTaskRun.attempts); } + + return { + id: cancelledTaskRun.id, + }; } async #cancelPotentiallyRunningAttempts(run: TaskRun, attempts: ExtendedTaskRunAttempt[]) { diff --git a/packages/database/prisma/migrations/20240517135206_created_bulk_action_group_and_bulk_action_item_for_canceling_and_replaying_in_bulk/migration.sql b/packages/database/prisma/migrations/20240517135206_created_bulk_action_group_and_bulk_action_item_for_canceling_and_replaying_in_bulk/migration.sql new file mode 100644 index 000000000..1cfaff8d9 --- /dev/null +++ b/packages/database/prisma/migrations/20240517135206_created_bulk_action_group_and_bulk_action_item_for_canceling_and_replaying_in_bulk/migration.sql @@ -0,0 +1,54 @@ +-- CreateEnum +CREATE TYPE "BulkActionType" AS ENUM ('CANCEL', 'REPLAY'); + +-- CreateEnum +CREATE TYPE "BulkActionStatus" AS ENUM ('PENDING', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "BulkActionItemStatus" AS ENUM ('PENDING', 'COMPLETED'); + +-- CreateTable +CREATE TABLE "BulkActionGroup" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "type" "BulkActionType" NOT NULL, + "status" "BulkActionStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BulkActionGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BulkActionItem" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "type" "BulkActionType" NOT NULL, + "status" "BulkActionItemStatus" NOT NULL DEFAULT 'PENDING', + "sourceRunId" TEXT, + "destinationRunId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BulkActionItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BulkActionGroup_friendlyId_key" ON "BulkActionGroup"("friendlyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "BulkActionItem_friendlyId_key" ON "BulkActionItem"("friendlyId"); + +-- AddForeignKey +ALTER TABLE "BulkActionGroup" ADD CONSTRAINT "BulkActionGroup_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BulkActionItem" ADD CONSTRAINT "BulkActionItem_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "BulkActionGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BulkActionItem" ADD CONSTRAINT "BulkActionItem_sourceRunId_fkey" FOREIGN KEY ("sourceRunId") REFERENCES "TaskRun"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BulkActionItem" ADD CONSTRAINT "BulkActionItem_destinationRunId_fkey" FOREIGN KEY ("destinationRunId") REFERENCES "TaskRun"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20240517164246_bulk_action_item_source_run_id_is_required/migration.sql b/packages/database/prisma/migrations/20240517164246_bulk_action_item_source_run_id_is_required/migration.sql new file mode 100644 index 000000000..bf93ab4a3 --- /dev/null +++ b/packages/database/prisma/migrations/20240517164246_bulk_action_item_source_run_id_is_required/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `sourceRunId` on table `BulkActionItem` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "BulkActionItem" ALTER COLUMN "sourceRunId" SET NOT NULL; diff --git a/packages/database/prisma/migrations/20240517164924_bulk_action_item_added_failed_state_with_error/migration.sql b/packages/database/prisma/migrations/20240517164924_bulk_action_item_added_failed_state_with_error/migration.sql new file mode 100644 index 000000000..9d5be8b97 --- /dev/null +++ b/packages/database/prisma/migrations/20240517164924_bulk_action_item_added_failed_state_with_error/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "BulkActionItemStatus" ADD VALUE 'FAILED'; + +-- AlterTable +ALTER TABLE "BulkActionItem" ADD COLUMN "error" TEXT; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index ad511c90b..75551d0cb 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -457,6 +457,7 @@ model Project { alertChannels ProjectAlertChannel[] alerts ProjectAlert[] alertStorages ProjectAlertStorage[] + bulkActionGroups BulkActionGroup[] } enum ProjectVersion { @@ -1655,6 +1656,9 @@ model TaskRun { schedule TaskSchedule? @relation(fields: [scheduleId], references: [id], onDelete: SetNull) scheduleId String? + sourceBulkActionItems BulkActionItem[] @relation("SourceActionItemRun") + destinationBulkActionItems BulkActionItem[] @relation("DestinationActionItemRun") + @@unique([runtimeEnvironmentId, idempotencyKey]) // Task activity graph @@index([projectId, createdAt, taskIdentifier]) @@ -2360,3 +2364,67 @@ model OrganizationIntegration { enum IntegrationService { SLACK } + +/// Bulk actions, like canceling and replaying runs +model BulkActionGroup { + id String @id @default(cuid()) + + friendlyId String @unique + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + type BulkActionType + items BulkActionItem[] + + /// When the group is created it's pending. After we've processed all the items it's completed. This does not mean the associated runs are completed. + status BulkActionStatus @default(PENDING) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum BulkActionType { + /// Cancels existing runs. This populates the destination runs. + CANCEL + /// Replays existing runs. The original runs go as source runs, and the new runs go as destination runs. + REPLAY +} + +enum BulkActionStatus { + PENDING + COMPLETED +} + +model BulkActionItem { + id String @id @default(cuid()) + + friendlyId String @unique + + group BulkActionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) + groupId String + + type BulkActionType + + /// When the item is created it's pending. After we've processed the item it's completed. This does not mean the associated runs are completed. + status BulkActionItemStatus @default(PENDING) + + /// The run that is the source of the action, e.g. when replaying this is the original run + sourceRun TaskRun @relation("SourceActionItemRun", fields: [sourceRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceRunId String + + /// The run that's a result of the action, this will be set when the run has been created + destinationRun TaskRun? @relation("DestinationActionItemRun", fields: [destinationRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + destinationRunId String? + + error String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum BulkActionItemStatus { + PENDING + COMPLETED + FAILED +} diff --git a/references/v3-catalog/src/trigger/concurrency.ts b/references/v3-catalog/src/trigger/concurrency.ts index d46802929..693b8d4c1 100644 --- a/references/v3-catalog/src/trigger/concurrency.ts +++ b/references/v3-catalog/src/trigger/concurrency.ts @@ -8,7 +8,7 @@ export const oneAtATime = task({ run: async (payload: { message: string }) => { logger.info("One at a time task payload", { payload }); - await wait.for({ seconds: 5 }); + await wait.for({ seconds: 10 }); return { finished: new Date().toISOString(),