From 62135c68bc580a2d566afb5ba5ef10fd8b7a8ecb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 May 2025 14:30:56 +0100 Subject: [PATCH 01/56] =?UTF-8?q?Initial=20commit=20with=20a=20plan=20for?= =?UTF-8?q?=20what=20we=E2=80=99re=20going=20to=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/trigger-sdk/src/v3/wait.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 4768a9e7ee..b97b7c65d7 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -309,6 +309,34 @@ async function completeToken( return apiClient.completeWaitpointToken(tokenId, { data }, $requestOptions); } +async function forHttpCallback( + callback: (successUrl: string, failureUrl: string) => Promise +) { + //TODO: + // Support a schema passed in, infer the type, or a generic supplied type + // 1. Create a span for the main call + // 2. Make an API call to api.trigger.dev/v1/http-callback/create + // 3. Return the successUrl and failureUrl and a waitpoint id (but don't block the run yet) + // 4. Set the successUrl, failureUrl and waitpoint entity type and id as attributes on the parent span + // 5. Create a span around the callback + // 6. Deal with errors thrown in the callback use `tryCatch()` + // 7. If that callback is successfully called, wait for the waitpoint with an API call to api.trigger.dev/v1/http-callback/wait + // 8. Wait for the waitpoint in the runtime + // 9. On the backend when the success/fail API is hit, complete the waitpoint with the result + // 10. Receive the result here and import the packet, then get the result in the right format + // 11. Make unwrap work + + const successUrl = "https://trigger.dev/wait/success"; + const failureUrl = "https://trigger.dev/wait/failure"; + + const result = await callback(successUrl, failureUrl); + + return { + ok: true, + output: result, + }; +} + export type CommonWaitOptions = { /** * An optional idempotency key for the waitpoint. From 78f534e1f5adcdb90e8fc16f339a07d0ef731396 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 May 2025 15:39:12 +0100 Subject: [PATCH 02/56] Some initial types and improved plan --- packages/core/src/v3/types/index.ts | 1 + packages/core/src/v3/types/waitpoints.ts | 14 +++++++++ packages/trigger-sdk/src/v3/wait.ts | 40 +++++++++++++++++------- 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/v3/types/waitpoints.ts diff --git a/packages/core/src/v3/types/index.ts b/packages/core/src/v3/types/index.ts index 55cc4d3a12..526943a1c5 100644 --- a/packages/core/src/v3/types/index.ts +++ b/packages/core/src/v3/types/index.ts @@ -7,6 +7,7 @@ export * from "./tasks.js"; export * from "./idempotencyKeys.js"; export * from "./tools.js"; export * from "./queues.js"; +export * from "./waitpoints.js"; type ResolveEnvironmentVariablesOptions = { variables: Record | Array<{ name: string; value: string }>; diff --git a/packages/core/src/v3/types/waitpoints.ts b/packages/core/src/v3/types/waitpoints.ts new file mode 100644 index 0000000000..ead87c0770 --- /dev/null +++ b/packages/core/src/v3/types/waitpoints.ts @@ -0,0 +1,14 @@ +import { AnySchemaParseFn, inferSchemaIn, inferSchemaOut, Schema } from "./schemas.js"; + +export type HttpCallbackSchema = Schema; +export type HttpCallbackResultTypeFromSchema = + inferSchemaOut; +export type HttpCallbackResult = + | { + ok: true; + result: TResult; + } + | { + ok: false; + error: Error; + }; diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index b97b7c65d7..8182576c58 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -20,6 +20,9 @@ import { WaitpointTokenStatus, WaitpointRetrieveTokenResponse, CreateWaitpointTokenResponse, + HttpCallbackSchema, + HttpCallbackResultTypeFromSchema, + HttpCallbackResult, } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; @@ -310,31 +313,44 @@ async function completeToken( } async function forHttpCallback( - callback: (successUrl: string, failureUrl: string) => Promise -) { + callback: (url: string) => Promise, + options?: { + timeout?: string | Date | undefined; + } +): Promise> { //TODO: // Support a schema passed in, infer the type, or a generic supplied type - // 1. Create a span for the main call - // 2. Make an API call to api.trigger.dev/v1/http-callback/create - // 3. Return the successUrl and failureUrl and a waitpoint id (but don't block the run yet) - // 4. Set the successUrl, failureUrl and waitpoint entity type and id as attributes on the parent span + // Support a timeout passed in + // 1. Make an API call to engine.trigger.dev/v1/waitpoints/http-callback/create. New Waitpoint type "HTTPCallback" + // 2. Return the url and a waitpoint id (but don't block the run yet) + // 3. Create a span for the main call + // 4. Set the url and waitpoint entity type and id as attributes on the parent span // 5. Create a span around the callback // 6. Deal with errors thrown in the callback use `tryCatch()` - // 7. If that callback is successfully called, wait for the waitpoint with an API call to api.trigger.dev/v1/http-callback/wait + // 7. If that callback is successfully called, wait for the waitpoint with an API call to engine.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/block // 8. Wait for the waitpoint in the runtime - // 9. On the backend when the success/fail API is hit, complete the waitpoint with the result + // 9. On the backend when the API is hit, complete the waitpoint with the result api.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/callback // 10. Receive the result here and import the packet, then get the result in the right format // 11. Make unwrap work - const successUrl = "https://trigger.dev/wait/success"; - const failureUrl = "https://trigger.dev/wait/failure"; + const url = "https://trigger.dev/wait/success"; - const result = await callback(successUrl, failureUrl); + const result = await callback(url); return { ok: true, output: result, - }; + } as any; +} + +async function forHttpCallbackWithSchema( + schema: TSchema, + callback: (successUrl: string, failureUrl: string) => Promise, + options?: { + timeout?: string | Date | undefined; + } +): Promise>> { + return {} as any; } export type CommonWaitOptions = { From d9aea071a3f8f3320028fc729ffdac28a2f6c140 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 17:43:13 +0100 Subject: [PATCH 03/56] Add Waitpoint resolver --- .../migration.sql | 6 ++++++ .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql b/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql new file mode 100644 index 0000000000..e32b4c0afd --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "WaitpointResolver" AS ENUM ('ENGINE', 'TOKEN', 'HTTP_CALLBACK'); + +-- AlterTable +ALTER TABLE "Waitpoint" +ADD COLUMN "resolver" "WaitpointResolver" NOT NULL DEFAULT 'ENGINE'; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql b/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql new file mode 100644 index 0000000000..ca3e18e806 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_createdAt_idx" ON "Waitpoint" ("environmentId", "resolver", "createdAt" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 5f83179943..b7ced219d3 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2100,6 +2100,9 @@ model Waitpoint { type WaitpointType status WaitpointStatus @default(PENDING) + /// This is what will resolve the waitpoint, used to filter waitpoints in the dashboard + resolver WaitpointResolver @default(ENGINE) + completedAt DateTime? /// If it's an Event type waitpoint, this is the event. It can also be provided for the DATETIME type @@ -2150,7 +2153,7 @@ model Waitpoint { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - /// Denormized column that holds the raw tags + /// Denormalized column that holds the raw tags tags String[] /// Quickly find an idempotent waitpoint @@ -2162,6 +2165,8 @@ model Waitpoint { @@index([environmentId, type, createdAt(sort: Desc)]) /// Status filtering @@index([environmentId, type, status]) + /// Dashboard filtering + @@index([environmentId, resolver, createdAt(sort: Desc)]) } enum WaitpointType { @@ -2176,6 +2181,15 @@ enum WaitpointStatus { COMPLETED } +enum WaitpointResolver { + /// The engine itself will resolve the waitpoint + ENGINE + /// A token will resolve the waitpoint, i.e. the user completes a token + TOKEN + /// A HTTP callback will resolve the waitpoint, i.e. the user uses our waitForHttpCallback function + HTTP_CALLBACK +} + model TaskRunWaitpoint { id String @id @default(cuid()) From 87e107dd30212f43847d0665af5135d457be22ce Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 17:46:28 +0100 Subject: [PATCH 04/56] Add resolver + status index --- .../migration.sql | 7 +++++++ internal-packages/database/prisma/schema.prisma | 1 + 2 files changed, 8 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql b/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql new file mode 100644 index 0000000000..75227b7672 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql @@ -0,0 +1,7 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_status_createdAt_idx" ON "Waitpoint" ( + "environmentId", + "resolver", + "status", + "createdAt" DESC +); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index b7ced219d3..717019d471 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2167,6 +2167,7 @@ model Waitpoint { @@index([environmentId, type, status]) /// Dashboard filtering @@index([environmentId, resolver, createdAt(sort: Desc)]) + @@index([environmentId, resolver, status, createdAt(sort: Desc)]) } enum WaitpointType { From 59b13f6d94ea038b5d1c2dc942ad76ae73a35545 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 17:49:53 +0100 Subject: [PATCH 05/56] Remove type + status index --- .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql b/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql new file mode 100644 index 0000000000..902741c4a7 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX CONCURRENTLY "Waitpoint_environmentId_type_status_idx"; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 717019d471..5f0d0781ef 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2163,8 +2163,6 @@ model Waitpoint { /// Used on the Waitpoint dashboard pages /// Time period filtering @@index([environmentId, type, createdAt(sort: Desc)]) - /// Status filtering - @@index([environmentId, type, status]) /// Dashboard filtering @@index([environmentId, resolver, createdAt(sort: Desc)]) @@index([environmentId, resolver, status, createdAt(sort: Desc)]) From 30a549789f7cc8b882bac0d83aa716b1fc095cac Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 17:53:44 +0100 Subject: [PATCH 06/56] Only drop if exists --- .../migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql b/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql index 902741c4a7..ba35249116 100644 --- a/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql +++ b/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql @@ -1,2 +1,2 @@ -- DropIndex -DROP INDEX CONCURRENTLY "Waitpoint_environmentId_type_status_idx"; \ No newline at end of file +DROP INDEX CONCURRENTLY IF EXISTS "Waitpoint_environmentId_type_status_idx"; \ No newline at end of file From b7218af97f6ce72ffbea3b24beb1baff72c80a48 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 17:56:09 +0100 Subject: [PATCH 07/56] Remove type index --- .../20250505165445_waitpoint_drop_type_index/migration.sql | 1 + internal-packages/database/prisma/schema.prisma | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql b/internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql new file mode 100644 index 0000000000..dda722e507 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS "Waitpoint_environmentId_type_createdAt_idx"; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 5f0d0781ef..eee41f9235 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2160,9 +2160,6 @@ model Waitpoint { @@unique([environmentId, idempotencyKey]) /// Quickly find a batch waitpoint @@index([completedByBatchId]) - /// Used on the Waitpoint dashboard pages - /// Time period filtering - @@index([environmentId, type, createdAt(sort: Desc)]) /// Dashboard filtering @@index([environmentId, resolver, createdAt(sort: Desc)]) @@index([environmentId, resolver, status, createdAt(sort: Desc)]) From 601002b53fb0dd74a999f0719124a2c337aca500 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 18:35:51 +0100 Subject: [PATCH 08/56] Update waitpoint list presenter to use resolver --- .../app/presenters/v3/WaitpointTokenListPresenter.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts index ff2578e07d..41147fa219 100644 --- a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts @@ -165,7 +165,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { ${sqlDatabaseSchema}."Waitpoint" w WHERE w."environmentId" = ${environment.id} - AND w.type = 'MANUAL' + AND w.resolver = 'TOKEN' -- cursor ${ cursor @@ -250,7 +250,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { const firstToken = await this._replica.waitpoint.findFirst({ where: { environmentId: environment.id, - type: "MANUAL", + resolver: "TOKEN", }, }); From 79e98c1398f5625744ad130e4652664ed4a457de Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 18:44:08 +0100 Subject: [PATCH 09/56] Added resolver to the engine --- apps/webapp/app/routes/api.v1.waitpoints.tokens.ts | 1 + internal-packages/run-engine/src/engine/index.ts | 3 +++ .../run-engine/src/engine/systems/waitpointSystem.ts | 3 +++ .../run-engine/src/engine/tests/checkpoints.test.ts | 5 +++++ .../src/engine/tests/releaseConcurrency.test.ts | 11 +++++++++++ .../run-engine/src/engine/tests/waitpoints.test.ts | 8 ++++++++ 6 files changed, 31 insertions(+) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index 7e49de8f40..e222ba80e9 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -75,6 +75,7 @@ const { action } = createActionApiRoute( idempotencyKey: body.idempotencyKey, idempotencyKeyExpiresAt, timeout, + resolver: "TOKEN", tags: bodyTags, }); diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index d21b1d6795..50fa0fcd7a 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -840,6 +840,7 @@ export class RunEngine { idempotencyKey, idempotencyKeyExpiresAt, timeout, + resolver, tags, }: { environmentId: string; @@ -847,6 +848,7 @@ export class RunEngine { idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; + resolver: "TOKEN" | "HTTP_CALLBACK"; tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { return this.waitpointSystem.createManualWaitpoint({ @@ -855,6 +857,7 @@ export class RunEngine { idempotencyKey, idempotencyKeyExpiresAt, timeout, + resolver, tags, }); } diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index d146edb084..96e8f76cf5 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -229,6 +229,7 @@ export class WaitpointSystem { idempotencyKey, idempotencyKeyExpiresAt, timeout, + resolver, tags, }: { environmentId: string; @@ -236,6 +237,7 @@ export class WaitpointSystem { idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; + resolver: "TOKEN" | "HTTP_CALLBACK"; tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { const existingWaitpoint = idempotencyKey @@ -285,6 +287,7 @@ export class WaitpointSystem { create: { ...WaitpointId.generate(), type: "MANUAL", + resolver, idempotencyKey: idempotencyKey ?? nanoid(24), idempotencyKeyExpiresAt, userProvidedIdempotencyKey: !!idempotencyKey, diff --git a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts index 88d116b0b2..6139a60627 100644 --- a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts @@ -91,6 +91,7 @@ describe("RunEngine checkpoints", () => { const waitpointResult = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); expect(waitpointResult.waitpoint.status).toBe("PENDING"); @@ -349,6 +350,7 @@ describe("RunEngine checkpoints", () => { const waitpoint1 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); const blocked1 = await engine.blockRunWithWaitpoint({ @@ -399,6 +401,7 @@ describe("RunEngine checkpoints", () => { const waitpoint2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); const blocked2 = await engine.blockRunWithWaitpoint({ @@ -551,6 +554,7 @@ describe("RunEngine checkpoints", () => { const waitpointResult = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); expect(waitpointResult.waitpoint.status).toBe("PENDING"); @@ -844,6 +848,7 @@ describe("RunEngine checkpoints", () => { const waitpoint = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); expect(waitpoint.waitpoint.status).toBe("PENDING"); diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts index 3602810e6a..298be0437a 100644 --- a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts @@ -102,6 +102,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -154,6 +155,7 @@ describe("RunEngine Releasing Concurrency", () => { const result2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ @@ -302,6 +304,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -355,6 +358,7 @@ describe("RunEngine Releasing Concurrency", () => { const result2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ @@ -490,6 +494,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -543,6 +548,7 @@ describe("RunEngine Releasing Concurrency", () => { const result2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ @@ -668,6 +674,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); await engine.releaseConcurrencySystem.consumeToken( @@ -830,6 +837,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); await engine.releaseConcurrencySystem.consumeToken( @@ -1012,6 +1020,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); await engine.releaseConcurrencySystem.consumeToken( @@ -1185,6 +1194,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -1339,6 +1349,7 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); // Block the run, specifying the release concurrency option as true diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index 3a8446edea..3e69ffbbcc 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -345,6 +345,7 @@ describe("RunEngine Waitpoints", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); @@ -485,6 +486,7 @@ describe("RunEngine Waitpoints", () => { projectId: authenticatedEnvironment.projectId, //fail after 200ms timeout: new Date(Date.now() + 200), + resolver: "TOKEN", }); //block the run @@ -607,6 +609,7 @@ describe("RunEngine Waitpoints", () => { engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }) ) ); @@ -751,6 +754,7 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, timeout, + resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.completedAfter).toStrictEqual(timeout); @@ -900,6 +904,7 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, + resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.idempotencyKey).toBe(idempotencyKey); @@ -1052,6 +1057,7 @@ describe("RunEngine Waitpoints", () => { projectId: authenticatedEnvironment.projectId, idempotencyKey, idempotencyKeyExpiresAt: new Date(Date.now() + 200), + resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.idempotencyKey).toBe(idempotencyKey); @@ -1062,6 +1068,7 @@ describe("RunEngine Waitpoints", () => { projectId: authenticatedEnvironment.projectId, idempotencyKey, idempotencyKeyExpiresAt: new Date(Date.now() + 200), + resolver: "TOKEN", }); expect(sameWaitpointResult.waitpoint.id).toBe(result.waitpoint.id); @@ -1217,6 +1224,7 @@ describe("RunEngine Waitpoints", () => { const waitpoint = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, + resolver: "TOKEN", }); expect(waitpoint.waitpoint.status).toBe("PENDING"); From 1c09fbe6e26699e8be5e4451fa7502193f049182 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 20:47:41 +0100 Subject: [PATCH 10/56] Made the existing waitpoint list presenter more flexible --- ...ts => ApiWaitpointListPresenter.server.ts} | 26 +++++++++---------- .../v3/ApiWaitpointPresenter.server.ts | 2 +- ...er.ts => WaitpointListPresenter.server.ts} | 13 ++++++---- .../v3/WaitpointPresenter.server.ts | 2 +- .../route.tsx | 5 ++-- .../app/routes/api.v1.waitpoints.tokens.ts | 14 +++++----- 6 files changed, 32 insertions(+), 30 deletions(-) rename apps/webapp/app/presenters/v3/{ApiWaitpointTokenListPresenter.server.ts => ApiWaitpointListPresenter.server.ts} (82%) rename apps/webapp/app/presenters/v3/{WaitpointTokenListPresenter.server.ts => WaitpointListPresenter.server.ts} (96%) diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts similarity index 82% rename from apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts rename to apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts index b4a181dfb7..aeecb67b8f 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts @@ -1,16 +1,12 @@ -import { RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3"; +import { type RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3"; +import { type RunEngineVersion, type WaitpointResolver } from "@trigger.dev/database"; import { z } from "zod"; -import { BasePresenter } from "./basePresenter.server"; import { CoercedDate } from "~/utils/zod"; -import { AuthenticatedEnvironment } from "@internal/run-engine"; -import { - WaitpointTokenListOptions, - WaitpointTokenListPresenter, -} from "./WaitpointTokenListPresenter.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; -import { RunEngineVersion } from "@trigger.dev/database"; +import { BasePresenter } from "./basePresenter.server"; +import { type WaitpointListOptions, WaitpointListPresenter } from "./WaitpointListPresenter.server"; -export const ApiWaitpointTokenListSearchParams = z.object({ +export const ApiWaitpointListSearchParams = z.object({ "page[size]": z.coerce.number().int().positive().min(1).max(100).optional(), "page[after]": z.string().optional(), "page[before]": z.string().optional(), @@ -61,9 +57,9 @@ export const ApiWaitpointTokenListSearchParams = z.object({ "filter[createdAt][to]": CoercedDate, }); -type ApiWaitpointTokenListSearchParams = z.infer; +type ApiWaitpointListSearchParams = z.infer; -export class ApiWaitpointTokenListPresenter extends BasePresenter { +export class ApiWaitpointListPresenter extends BasePresenter { public async call( environment: { id: string; @@ -73,11 +69,13 @@ export class ApiWaitpointTokenListPresenter extends BasePresenter { engine: RunEngineVersion; }; }, - searchParams: ApiWaitpointTokenListSearchParams + resolver: WaitpointResolver, + searchParams: ApiWaitpointListSearchParams ) { return this.trace("call", async (span) => { - const options: WaitpointTokenListOptions = { + const options: WaitpointListOptions = { environment, + resolver, }; if (searchParams["page[size]"]) { @@ -118,7 +116,7 @@ export class ApiWaitpointTokenListPresenter extends BasePresenter { options.to = searchParams["filter[createdAt][to]"].getTime(); } - const presenter = new WaitpointTokenListPresenter(); + const presenter = new WaitpointListPresenter(); const result = await presenter.call(options); if (!result.success) { diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts index b443568c14..19ba1dea13 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -3,7 +3,7 @@ import { type RunEngineVersion } from "@trigger.dev/database"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BasePresenter } from "./basePresenter.server"; import { WaitpointPresenter } from "./WaitpointPresenter.server"; -import { waitpointStatusToApiStatus } from "./WaitpointTokenListPresenter.server"; +import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server"; export class ApiWaitpointPresenter extends BasePresenter { public async call( diff --git a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts similarity index 96% rename from apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts rename to apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index 41147fa219..9ec6f2ffa8 100644 --- a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -1,6 +1,7 @@ import parse from "parse-duration"; import { Prisma, + type WaitpointResolver, type RunEngineVersion, type RuntimeEnvironmentType, type WaitpointStatus, @@ -14,7 +15,7 @@ import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev const DEFAULT_PAGE_SIZE = 25; -export type WaitpointTokenListOptions = { +export type WaitpointListOptions = { environment: { id: string; type: RuntimeEnvironmentType; @@ -23,6 +24,7 @@ export type WaitpointTokenListOptions = { engine: RunEngineVersion; }; }; + resolver: WaitpointResolver; // filters id?: string; statuses?: WaitpointTokenStatus[]; @@ -63,9 +65,10 @@ type Result = filters: undefined; }; -export class WaitpointTokenListPresenter extends BasePresenter { +export class WaitpointListPresenter extends BasePresenter { public async call({ environment, + resolver, id, statuses, idempotencyKey, @@ -76,7 +79,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { direction = "forward", cursor, pageSize = DEFAULT_PAGE_SIZE, - }: WaitpointTokenListOptions): Promise { + }: WaitpointListOptions): Promise { const engineVersion = await determineEngineVersion({ environment }); if (engineVersion === "V1") { return { @@ -165,7 +168,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { ${sqlDatabaseSchema}."Waitpoint" w WHERE w."environmentId" = ${environment.id} - AND w.resolver = 'TOKEN' + AND w.resolver = ${resolver}::"WaitpointResolver" -- cursor ${ cursor @@ -250,7 +253,7 @@ export class WaitpointTokenListPresenter extends BasePresenter { const firstToken = await this._replica.waitpoint.findFirst({ where: { environmentId: environment.id, - resolver: "TOKEN", + resolver, }, }); diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index f005f5a2dc..dd65b31dd6 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -2,7 +2,7 @@ import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v import { logger } from "~/services/logger.server"; import { BasePresenter } from "./basePresenter.server"; import { type RunListItem, RunListPresenter } from "./RunListPresenter.server"; -import { waitpointStatusToApiStatus } from "./WaitpointTokenListPresenter.server"; +import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server"; export type WaitpointDetail = NonNullable>>; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index cea5332692..fbb9a9aca2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -36,7 +36,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { WaitpointTokenListPresenter } from "~/presenters/v3/WaitpointTokenListPresenter.server"; +import { WaitpointListPresenter } from "~/presenters/v3/WaitpointListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder"; @@ -84,9 +84,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } try { - const presenter = new WaitpointTokenListPresenter(); + const presenter = new WaitpointListPresenter(); const result = await presenter.call({ environment, + resolver: "TOKEN", ...searchParams, }); diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index e222ba80e9..38bb1e6809 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -6,14 +6,14 @@ import { import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server"; import { - ApiWaitpointTokenListPresenter, - ApiWaitpointTokenListSearchParams, -} from "~/presenters/v3/ApiWaitpointTokenListPresenter.server"; + ApiWaitpointListPresenter, + ApiWaitpointListSearchParams, +} from "~/presenters/v3/ApiWaitpointListPresenter.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { parseDelay } from "~/utils/delays"; import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; import { engine } from "~/v3/runEngine.server"; @@ -21,12 +21,12 @@ import { ServiceValidationError } from "~/v3/services/baseService.server"; export const loader = createLoaderApiRoute( { - searchParams: ApiWaitpointTokenListSearchParams, + searchParams: ApiWaitpointListSearchParams, findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, async ({ searchParams, authentication }) => { - const presenter = new ApiWaitpointTokenListPresenter(); - const result = await presenter.call(authentication.environment, searchParams); + const presenter = new ApiWaitpointListPresenter(); + const result = await presenter.call(authentication.environment, "TOKEN", searchParams); return json(result); } From b9edd2e07a705a32e7bedaf208b69687dd2dd860 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 21:07:01 +0100 Subject: [PATCH 11/56] Initial implentation ofr wait.forHttpCallback() --- .../engine.v1.waitpoints.http-callback.ts | 107 ++++++++++++ packages/core/src/v3/apiClient/index.ts | 19 +++ packages/core/src/v3/schemas/api.ts | 9 ++ packages/core/src/v3/types/waitpoints.ts | 2 +- packages/trigger-sdk/src/v3/wait.ts | 152 +++++++++++++----- references/hello-world/src/trigger/waits.ts | 16 ++ 6 files changed, 263 insertions(+), 42 deletions(-) create mode 100644 apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts diff --git a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts new file mode 100644 index 0000000000..120e318d83 --- /dev/null +++ b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts @@ -0,0 +1,107 @@ +import { json } from "@remix-run/server-runtime"; +import { + type CreateWaitpointHttpCallbackResponseBody, + CreateWaitpointTokenRequestBody, +} from "@trigger.dev/core/v3"; +import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import { env } from "~/env.server"; +import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server"; +import { + ApiWaitpointListPresenter, + ApiWaitpointListSearchParams, +} from "~/presenters/v3/ApiWaitpointListPresenter.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { + createActionApiRoute, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; +import { parseDelay } from "~/utils/delays"; +import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; +import { engine } from "~/v3/runEngine.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; + +export const loader = createLoaderApiRoute( + { + searchParams: ApiWaitpointListSearchParams, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ searchParams, authentication }) => { + const presenter = new ApiWaitpointListPresenter(); + const result = await presenter.call(authentication.environment, "HTTP_CALLBACK", searchParams); + + return json(result); + } +); + +const { action } = createActionApiRoute( + { + body: CreateWaitpointTokenRequestBody, + maxContentLength: 1024 * 10, // 10KB + method: "POST", + }, + async ({ authentication, body }) => { + try { + const idempotencyKeyExpiresAt = body.idempotencyKeyTTL + ? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL) + : undefined; + + const timeout = await parseDelay(body.timeout); + + //upsert tags + let tags: { id: string; name: string }[] = []; + const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags; + + if (bodyTags && bodyTags.length > MAX_TAGS_PER_WAITPOINT) { + throw new ServiceValidationError( + `Waitpoints can only have ${MAX_TAGS_PER_WAITPOINT} tags, you're trying to set ${bodyTags.length}.` + ); + } + + if (bodyTags && bodyTags.length > 0) { + for (const tag of bodyTags) { + const tagRecord = await createWaitpointTag({ + tag, + environmentId: authentication.environment.id, + projectId: authentication.environment.projectId, + }); + if (tagRecord) { + tags.push(tagRecord); + } + } + } + + const result = await engine.createManualWaitpoint({ + environmentId: authentication.environment.id, + projectId: authentication.environment.projectId, + idempotencyKey: body.idempotencyKey, + idempotencyKeyExpiresAt, + timeout, + resolver: "HTTP_CALLBACK", + tags: bodyTags, + }); + + return json( + { + id: WaitpointId.toFriendlyId(result.waitpoint.id), + url: `${ + env.API_ORIGIN ?? env.APP_ORIGIN + }/api/v1/waitpoints/http-callback/${WaitpointId.toFriendlyId( + result.waitpoint.id + )}/callback`, + isCached: result.isCached, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 422 }); + } else if (error instanceof Error) { + return json({ error: error.message }, { status: 500 }); + } + + return json({ error: "Something went wrong" }, { status: 500 }); + } + } +); + +export { action }; diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index f86fef448f..f4ad3d6ebb 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -12,6 +12,7 @@ import { CreateEnvironmentVariableRequestBody, CreateScheduleOptions, CreateUploadPayloadUrlResponseBody, + CreateWaitpointHttpCallbackResponseBody, CreateWaitpointTokenRequestBody, CreateWaitpointTokenResponseBody, DeletedScheduleObject, @@ -790,6 +791,24 @@ export class ApiClient { ); } + createWaitpointHttpCallback( + options: CreateWaitpointTokenRequestBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + CreateWaitpointHttpCallbackResponseBody, + `${this.baseUrl}/engine/v1/waitpoints/http-callback`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(options), + }, + { + ...mergeRequestOptions(this.defaultRequestOptions, requestOptions), + } + ) as ApiPromise; + } + async waitForDuration( runId: string, body: WaitForDurationRequestBody, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ef8ff25baf..af2036f9f5 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1010,6 +1010,15 @@ export const WaitForWaitpointTokenResponseBody = z.object({ }); export type WaitForWaitpointTokenResponseBody = z.infer; +export const CreateWaitpointHttpCallbackResponseBody = z.object({ + id: z.string(), + url: z.string(), + isCached: z.boolean(), +}); +export type CreateWaitpointHttpCallbackResponseBody = z.infer< + typeof CreateWaitpointHttpCallbackResponseBody +>; + export const WaitForDurationRequestBody = z.object({ /** * An optional idempotency key for the waitpoint. diff --git a/packages/core/src/v3/types/waitpoints.ts b/packages/core/src/v3/types/waitpoints.ts index ead87c0770..902cf9da54 100644 --- a/packages/core/src/v3/types/waitpoints.ts +++ b/packages/core/src/v3/types/waitpoints.ts @@ -6,7 +6,7 @@ export type HttpCallbackResultTypeFromSchema export type HttpCallbackResult = | { ok: true; - result: TResult; + output: TResult; } | { ok: false; diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 8182576c58..603858c657 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -23,6 +23,7 @@ import { HttpCallbackSchema, HttpCallbackResultTypeFromSchema, HttpCallbackResult, + tryCatch, } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; @@ -312,47 +313,6 @@ async function completeToken( return apiClient.completeWaitpointToken(tokenId, { data }, $requestOptions); } -async function forHttpCallback( - callback: (url: string) => Promise, - options?: { - timeout?: string | Date | undefined; - } -): Promise> { - //TODO: - // Support a schema passed in, infer the type, or a generic supplied type - // Support a timeout passed in - // 1. Make an API call to engine.trigger.dev/v1/waitpoints/http-callback/create. New Waitpoint type "HTTPCallback" - // 2. Return the url and a waitpoint id (but don't block the run yet) - // 3. Create a span for the main call - // 4. Set the url and waitpoint entity type and id as attributes on the parent span - // 5. Create a span around the callback - // 6. Deal with errors thrown in the callback use `tryCatch()` - // 7. If that callback is successfully called, wait for the waitpoint with an API call to engine.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/block - // 8. Wait for the waitpoint in the runtime - // 9. On the backend when the API is hit, complete the waitpoint with the result api.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/callback - // 10. Receive the result here and import the packet, then get the result in the right format - // 11. Make unwrap work - - const url = "https://trigger.dev/wait/success"; - - const result = await callback(url); - - return { - ok: true, - output: result, - } as any; -} - -async function forHttpCallbackWithSchema( - schema: TSchema, - callback: (successUrl: string, failureUrl: string) => Promise, - options?: { - timeout?: string | Date | undefined; - } -): Promise>> { - return {} as any; -} - export type CommonWaitOptions = { /** * An optional idempotency key for the waitpoint. @@ -690,6 +650,116 @@ export const wait = { } ); }, + async forHttpCallback( + callback: (url: string) => Promise, + options?: CreateWaitpointTokenRequestBody & { + releaseConcurrency?: boolean; + }, + requestOptions?: ApiRequestOptions + ): Promise> { + const ctx = taskContext.ctx; + + if (!ctx) { + throw new Error("wait.forHttpCallback can only be used from inside a task.run()"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + const waitpoint = await apiClient.createWaitpointHttpCallback(options ?? {}, requestOptions); + + return tracer.startActiveSpan( + `wait.forHttpCallback()`, + async (span) => { + const [error] = await tryCatch(callback(waitpoint.url)); + + if (error) { + throw new Error(`You threw an error in your callback: ${error.message}`, { + cause: error, + }); + } + + const response = await apiClient.waitForWaitpointToken({ + runFriendlyId: ctx.run.id, + waitpointFriendlyId: waitpoint.id, + releaseConcurrency: options?.releaseConcurrency, + }); + + if (!response.success) { + throw new Error(`Failed to wait for wait for HTTP callback ${waitpoint.id}`); + } + + const result = await runtime.waitUntil(waitpoint.id); + + const data = result.output + ? await conditionallyImportAndParsePacket( + { data: result.output, dataType: result.outputType ?? "application/json" }, + apiClient + ) + : undefined; + + if (result.ok) { + return { + ok: result.ok, + output: data, + }; + } else { + const error = new WaitpointTimeoutError(data.message); + + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + + return { + ok: result.ok, + error, + }; + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "wait-http-callback", + [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", + [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, + ...accessoryAttributes({ + items: [ + { + text: waitpoint.id, + variant: "normal", + }, + ], + style: "codepath", + }), + id: waitpoint.id, + isCached: waitpoint.isCached, + idempotencyKey: options?.idempotencyKey, + idempotencyKeyTTL: options?.idempotencyKeyTTL, + timeout: options?.timeout + ? typeof options.timeout === "string" + ? options.timeout + : options.timeout.toISOString() + : undefined, + tags: options?.tags, + url: waitpoint.url, + }, + } + ); + + //TODO: + // Support a schema passed in, infer the type, or a generic supplied type + // Support a timeout passed in + // 1. Make an API call to engine.trigger.dev/v1/waitpoints/http-callback/create. New Waitpoint type "HTTPCallback" + // 2. Return the url and a waitpoint id (but don't block the run yet) + // 3. Create a span for the main call + // 4. Set the url and waitpoint entity type and id as attributes on the parent span + // 5. Create a span around the callback + // 6. Deal with errors thrown in the callback use `tryCatch()` + // 7. If that callback is successfully called, wait for the waitpoint with an API call to engine.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/block + // 8. Wait for the waitpoint in the runtime + // 9. On the backend when the API is hit, complete the waitpoint with the result api.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/callback + // 10. Receive the result here and import the packet, then get the result in the right format + // 11. Make unwrap work + }, }; function nameForWaitOptions(options: WaitForOptions): string { diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 675b852aa6..78fd4a080d 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -140,3 +140,19 @@ export const waitForDuration = task({ ); }, }); + +export const waitHttpCallback = task({ + id: "wait-http-callback", + run: async () => { + const result = await wait.forHttpCallback<{ foo: string }>( + async (url) => { + logger.log(`Wait for HTTP callback ${url}`); + }, + { + timeout: "60s", + } + ); + + logger.log("Wait for HTTP callback completed", result); + }, +}); From 93fbfb4f0937c17417e3008e36ad64289af0c0e7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 21:22:25 +0100 Subject: [PATCH 12/56] Added the callback endpoint (no API rate limit) --- ...-callback.$waitpointFriendlyId.callback.ts | 68 +++++++++++++++++++ .../app/services/apiRateLimit.server.ts | 1 + 2 files changed, 69 insertions(+) create mode 100644 apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts new file mode 100644 index 0000000000..09a7b9131a --- /dev/null +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts @@ -0,0 +1,68 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { + type CompleteWaitpointTokenResponseBody, + conditionallyExportPacket, + stringifyIO, +} from "@trigger.dev/core/v3"; +import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { engine } from "~/v3/runEngine.server"; + +const paramsSchema = z.object({ + waitpointFriendlyId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "POST" } }); + } + + const { waitpointFriendlyId } = paramsSchema.parse(params); + const waitpointId = WaitpointId.toId(waitpointFriendlyId); + + try { + //check permissions + const waitpoint = await $replica.waitpoint.findFirst({ + where: { + id: waitpointId, + }, + }); + + if (!waitpoint) { + throw json({ error: "Waitpoint not found" }, { status: 404 }); + } + + if (waitpoint.status === "COMPLETED") { + return json({ + success: true, + }); + } + + const body = await request.json(); + + const stringifiedData = await stringifyIO(body); + const finalData = await conditionallyExportPacket( + stringifiedData, + `${waitpointId}/waitpoint/http-callback` + ); + + const result = await engine.completeWaitpoint({ + id: waitpointId, + output: finalData.data + ? { type: finalData.dataType, value: finalData.data, isError: false } + : undefined, + }); + + return json( + { + success: true, + }, + { status: 200 } + ); + } catch (error) { + logger.error("Failed to complete waitpoint token", { error }); + throw json({ error: "Failed to complete waitpoint token" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 466aaa98b8..f795955247 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -59,6 +59,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/usage/ingest", "/api/v1/auth/jwt/claims", /^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts + /^\/api\/v1\/waitpoints\/http-callback\/[^\/]+\/callback$/, // /api/v1/waitpoints/http-callback/$waitpointFriendlyId/callback ], log: { rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", From 6df40846788b2121ad8e7dd9720c9783f0b19c9a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 21:40:53 +0100 Subject: [PATCH 13/56] schema version --- packages/trigger-sdk/src/v3/wait.ts | 121 +++++++++++++++++++- pnpm-lock.yaml | 3 + references/hello-world/package.json | 3 +- references/hello-world/src/trigger/waits.ts | 21 +++- 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 603858c657..c2740fbd25 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -24,6 +24,8 @@ import { HttpCallbackResultTypeFromSchema, HttpCallbackResult, tryCatch, + inferSchemaIn, + getSchemaParseFn, } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; @@ -718,7 +720,7 @@ export const wait = { }, { attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "wait-http-callback", + [SemanticInternalAttributes.STYLE_ICON]: "wait", [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, ...accessoryAttributes({ @@ -760,6 +762,123 @@ export const wait = { // 10. Receive the result here and import the packet, then get the result in the right format // 11. Make unwrap work }, + async forHttpCallbackWithSchema( + schema: TSchema, + callback: (url: string) => Promise, + options?: CreateWaitpointTokenRequestBody & { + releaseConcurrency?: boolean; + }, + requestOptions?: ApiRequestOptions + ): Promise>> { + const ctx = taskContext.ctx; + + if (!ctx) { + throw new Error("wait.forHttpCallback can only be used from inside a task.run()"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + const waitpoint = await apiClient.createWaitpointHttpCallback(options ?? {}, requestOptions); + + return tracer.startActiveSpan( + `wait.forHttpCallback()`, + async (span) => { + const [error] = await tryCatch(callback(waitpoint.url)); + + if (error) { + throw new Error(`You threw an error in your callback: ${error.message}`, { + cause: error, + }); + } + + const response = await apiClient.waitForWaitpointToken({ + runFriendlyId: ctx.run.id, + waitpointFriendlyId: waitpoint.id, + releaseConcurrency: options?.releaseConcurrency, + }); + + if (!response.success) { + throw new Error(`Failed to wait for wait for HTTP callback ${waitpoint.id}`); + } + + const result = await runtime.waitUntil(waitpoint.id); + + const data = result.output + ? await conditionallyImportAndParsePacket( + { data: result.output, dataType: result.outputType ?? "application/json" }, + apiClient + ) + : undefined; + + if (result.ok) { + try { + const parser = schema ? getSchemaParseFn>(schema) : undefined; + + if (!parser) { + throw new Error("No parser found for schema"); + } + + const parsedOutput = await parser(data); + + return { + ok: result.ok, + output: parsedOutput, + }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + + return { + ok: false, + error: err, + }; + } + } else { + const error = new WaitpointTimeoutError(data.message); + + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + + return { + ok: result.ok, + error, + }; + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "wait", + [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", + [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, + ...accessoryAttributes({ + items: [ + { + text: waitpoint.id, + variant: "normal", + }, + ], + style: "codepath", + }), + id: waitpoint.id, + isCached: waitpoint.isCached, + idempotencyKey: options?.idempotencyKey, + idempotencyKeyTTL: options?.idempotencyKeyTTL, + timeout: options?.timeout + ? typeof options.timeout === "string" + ? options.timeout + : options.timeout.toISOString() + : undefined, + tags: options?.tags, + url: waitpoint.url, + }, + } + ); + }, }; function nameForWaitOptions(options: WaitForOptions): string { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94bac59ab0..671cb44d4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1927,6 +1927,9 @@ importers: '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk + zod: + specifier: 3.23.8 + version: 3.23.8 devDependencies: trigger.dev: specifier: workspace:* diff --git a/references/hello-world/package.json b/references/hello-world/package.json index 9512898e21..a7138837a1 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -6,7 +6,8 @@ "trigger.dev": "workspace:*" }, "dependencies": { - "@trigger.dev/sdk": "workspace:*" + "@trigger.dev/sdk": "workspace:*", + "zod": "3.23.8" }, "scripts": { "dev": "trigger dev" diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 78fd4a080d..ae05fdc7d4 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -1,5 +1,5 @@ import { logger, wait, task, retry, idempotencyKeys, auth } from "@trigger.dev/sdk/v3"; - +import { z } from "zod"; type Token = { status: "approved" | "pending" | "rejected"; }; @@ -153,6 +153,23 @@ export const waitHttpCallback = task({ } ); - logger.log("Wait for HTTP callback completed", result); + if (!result.ok) { + logger.log("Wait for HTTP callback failed", { error: result.error }); + } else { + logger.log("Wait for HTTP callback completed", result); + } + + const result2 = await wait.forHttpCallbackWithSchema( + z.object({ bar: z.string() }), + async (url) => { + logger.log(`Wait for HTTP callback ${url}`); + } + ); + + if (!result2.ok) { + logger.log("Wait for HTTP callback failed", { error: result2.error }); + } else { + logger.log("Wait for HTTP callback completed", result2); + } }, }); From 676f6764fa68da2151b18479191c711a36f2a273 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 21:44:00 +0100 Subject: [PATCH 14/56] Added jsdocs, removed schema version because of errors --- packages/trigger-sdk/src/v3/wait.ts | 161 ++++++-------------- references/hello-world/src/trigger/waits.ts | 13 -- 2 files changed, 44 insertions(+), 130 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index c2740fbd25..a2c35b8ae3 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -652,9 +652,53 @@ export const wait = { } ); }, + /** + * This allows you to start some work on another API (or one of your own services) + * and continue the run when a callback URL we give you is hit with the result. + * + * You should send the callback URL to the other service, and then that service will + * make a request to the callback URL with the result. + * + * @example + * + * ```ts + * //wait for the prediction to complete + const prediction = await wait.forHttpCallback( + async (url) => { + //pass the provided URL to Replicate's webhook + await replicate.predictions.create({ + version: + "19deaef633fd44776c82edf39fd60e95a7250b8ececf11a725229dc75a81f9ca", + input: payload, + // pass the provided URL to Replicate's webhook, so they can "callback" + webhook: url, + webhook_events_filter: ["completed"], + }); + }, + { + timeout: "2m", + } + ); + + //the value of prediction is the body of the webook that Replicate sent + const result = prediction.output; + * ``` + * + * @param callback + * @param options + * @param requestOptions + * @returns + */ async forHttpCallback( callback: (url: string) => Promise, options?: CreateWaitpointTokenRequestBody & { + /** + * If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. + * + * This is useful if you want to allow other runs to execute while waiting + * + * @default false + */ releaseConcurrency?: boolean; }, requestOptions?: ApiRequestOptions @@ -762,123 +806,6 @@ export const wait = { // 10. Receive the result here and import the packet, then get the result in the right format // 11. Make unwrap work }, - async forHttpCallbackWithSchema( - schema: TSchema, - callback: (url: string) => Promise, - options?: CreateWaitpointTokenRequestBody & { - releaseConcurrency?: boolean; - }, - requestOptions?: ApiRequestOptions - ): Promise>> { - const ctx = taskContext.ctx; - - if (!ctx) { - throw new Error("wait.forHttpCallback can only be used from inside a task.run()"); - } - - const apiClient = apiClientManager.clientOrThrow(); - - const waitpoint = await apiClient.createWaitpointHttpCallback(options ?? {}, requestOptions); - - return tracer.startActiveSpan( - `wait.forHttpCallback()`, - async (span) => { - const [error] = await tryCatch(callback(waitpoint.url)); - - if (error) { - throw new Error(`You threw an error in your callback: ${error.message}`, { - cause: error, - }); - } - - const response = await apiClient.waitForWaitpointToken({ - runFriendlyId: ctx.run.id, - waitpointFriendlyId: waitpoint.id, - releaseConcurrency: options?.releaseConcurrency, - }); - - if (!response.success) { - throw new Error(`Failed to wait for wait for HTTP callback ${waitpoint.id}`); - } - - const result = await runtime.waitUntil(waitpoint.id); - - const data = result.output - ? await conditionallyImportAndParsePacket( - { data: result.output, dataType: result.outputType ?? "application/json" }, - apiClient - ) - : undefined; - - if (result.ok) { - try { - const parser = schema ? getSchemaParseFn>(schema) : undefined; - - if (!parser) { - throw new Error("No parser found for schema"); - } - - const parsedOutput = await parser(data); - - return { - ok: result.ok, - output: parsedOutput, - }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - span.recordException(err); - span.setStatus({ - code: SpanStatusCode.ERROR, - }); - - return { - ok: false, - error: err, - }; - } - } else { - const error = new WaitpointTimeoutError(data.message); - - span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - }); - - return { - ok: result.ok, - error, - }; - } - }, - { - attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "wait", - [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", - [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, - ...accessoryAttributes({ - items: [ - { - text: waitpoint.id, - variant: "normal", - }, - ], - style: "codepath", - }), - id: waitpoint.id, - isCached: waitpoint.isCached, - idempotencyKey: options?.idempotencyKey, - idempotencyKeyTTL: options?.idempotencyKeyTTL, - timeout: options?.timeout - ? typeof options.timeout === "string" - ? options.timeout - : options.timeout.toISOString() - : undefined, - tags: options?.tags, - url: waitpoint.url, - }, - } - ); - }, }; function nameForWaitOptions(options: WaitForOptions): string { diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index ae05fdc7d4..28a355a1d9 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -158,18 +158,5 @@ export const waitHttpCallback = task({ } else { logger.log("Wait for HTTP callback completed", result); } - - const result2 = await wait.forHttpCallbackWithSchema( - z.object({ bar: z.string() }), - async (url) => { - logger.log(`Wait for HTTP callback ${url}`); - } - ); - - if (!result2.ok) { - logger.log("Wait for HTTP callback failed", { error: result2.error }); - } else { - logger.log("Wait for HTTP callback completed", result2); - } }, }); From 27f2bdeff6959c9828cd6ed546b651141fd48ea3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 21:50:11 +0100 Subject: [PATCH 15/56] =?UTF-8?q?Show=20callback=20URL=20if=20it=E2=80=99s?= =?UTF-8?q?=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/runs/v3/WaitpointDetails.tsx | 10 ++++++++++ .../app/presenters/v3/WaitpointPresenter.server.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx index 95331c6e37..e74b8cfa13 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -11,6 +11,8 @@ import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder import { PacketDisplay } from "./PacketDisplay"; import { WaitpointStatusCombo } from "./WaitpointStatus"; import { RunTag } from "./RunTag"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; export function WaitpointDetailTable({ waitpoint, @@ -50,6 +52,14 @@ export function WaitpointDetailTable({ )} + {waitpoint.callbackUrl && ( + + Callback URL + + + + + )} Idempotency key diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index dd65b31dd6..5e86d8b7c3 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -3,6 +3,8 @@ import { logger } from "~/services/logger.server"; import { BasePresenter } from "./basePresenter.server"; import { type RunListItem, RunListPresenter } from "./RunListPresenter.server"; import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server"; +import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import { env } from "~/env.server"; export type WaitpointDetail = NonNullable>>; @@ -35,6 +37,7 @@ export class WaitpointPresenter extends BasePresenter { completedAfter: true, completedAt: true, createdAt: true, + resolver: true, connectedRuns: { select: { friendlyId: true, @@ -83,6 +86,11 @@ export class WaitpointPresenter extends BasePresenter { return { id: waitpoint.friendlyId, type: waitpoint.type, + resolver: waitpoint.resolver, + callbackUrl: + waitpoint.resolver === "HTTP_CALLBACK" + ? generateWaitpointCallbackUrl(waitpoint.friendlyId) + : undefined, status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), idempotencyKey: waitpoint.idempotencyKey, userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, @@ -100,3 +108,9 @@ export class WaitpointPresenter extends BasePresenter { }; } } + +export function generateWaitpointCallbackUrl(waitpointId: string) { + return `${ + env.API_ORIGIN ?? env.APP_ORIGIN + }/api/v1/waitpoints/http-callback/${WaitpointId.toFriendlyId(waitpointId)}/callback`; +} From 25448bda8c7e2ad5c3c5f9bf06595a62bf44bf05 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 22:07:08 +0100 Subject: [PATCH 16/56] Dashboard pages and panels --- .../app/assets/icons/HttpCallbackIcon.tsx | 12 + .../app/components/navigation/SideMenu.tsx | 8 + .../components/runs/v3/WaitpointDetails.tsx | 18 +- .../v3/WaitpointListPresenter.server.ts | 4 +- .../route.tsx | 146 ++++++++++ .../route.tsx | 264 ++++++++++++++++++ .../engine.v1.waitpoints.http-callback.ts | 7 +- apps/webapp/app/utils/pathBuilder.ts | 27 ++ 8 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 apps/webapp/app/assets/icons/HttpCallbackIcon.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx diff --git a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx new file mode 100644 index 0000000000..2994f2ba7b --- /dev/null +++ b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx @@ -0,0 +1,12 @@ +export function HttpCallbackIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 3efea32733..4c4d345acd 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -54,6 +54,7 @@ import { v3SchedulesPath, v3TestPath, v3UsagePath, + v3WaitpointHttpCallbacksPath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; import { useKapaWidget } from "../../hooks/useKapaWidget"; @@ -80,6 +81,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -245,6 +247,12 @@ export function SideMenu({ activeIconColor="text-sky-500" to={v3WaitpointTokensPath(organization, project, environment)} /> + diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx index e74b8cfa13..078e17d2c7 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -7,7 +7,11 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { type WaitpointDetail } from "~/presenters/v3/WaitpointPresenter.server"; import { ForceTimeout } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; -import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder"; +import { + v3WaitpointHttpCallbackPath, + v3WaitpointTokenPath, + v3WaitpointTokensPath, +} from "~/utils/pathBuilder"; import { PacketDisplay } from "./PacketDisplay"; import { WaitpointStatusCombo } from "./WaitpointStatus"; import { RunTag } from "./RunTag"; @@ -41,9 +45,15 @@ export function WaitpointDetailTable({ {linkToList ? ( {waitpoint.id} diff --git a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index 9ec6f2ffa8..b3ecebcf84 100644 --- a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -12,6 +12,7 @@ import { BasePresenter } from "./basePresenter.server"; import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters"; import { determineEngineVersion } from "~/v3/engineVersion.server"; import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev/core/v3"; +import { generateWaitpointCallbackUrl } from "./WaitpointPresenter.server"; const DEFAULT_PAGE_SIZE = 25; @@ -42,7 +43,7 @@ export type WaitpointListOptions = { type Result = | { success: true; - tokens: WaitpointTokenItem[]; + tokens: (WaitpointTokenItem & { callbackUrl: string })[]; pagination: { next: string | undefined; previous: string | undefined; @@ -266,6 +267,7 @@ export class WaitpointListPresenter extends BasePresenter { success: true, tokens: tokensToReturn.map((token) => ({ id: token.friendlyId, + callbackUrl: generateWaitpointCallbackUrl(token.id), status: waitpointStatusToApiStatus(token.status, token.outputIsError), completedAt: token.completedAt ?? undefined, timeoutAt: token.completedAfter ?? undefined, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx new file mode 100644 index 0000000000..d017262209 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx @@ -0,0 +1,146 @@ +import { useLocation } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { + EnvironmentParamSchema, + v3WaitpointHttpCallbacksPath, + v3WaitpointTokensPath, +} from "~/utils/pathBuilder"; +import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; +import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails"; +import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; +import { logger } from "~/services/logger.server"; + +const Params = EnvironmentParamSchema.extend({ + waitpointParam: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, waitpointParam } = Params.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + try { + const presenter = new WaitpointPresenter(); + const result = await presenter.call({ + friendlyId: waitpointParam, + environmentId: environment.id, + projectId: project.id, + }); + + if (!result) { + throw new Response(undefined, { + status: 404, + statusText: "Waitpoint not found", + }); + } + + return typedjson({ waitpoint: result }); + } catch (error) { + logger.error("Error loading waitpoint for inspector", { + error, + organizationSlug, + projectParam, + envParam, + waitpointParam, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { waitpoint } = useTypedLoaderData(); + + const location = useLocation(); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+
+ {waitpoint.id} + +
+
+
+ +
+
+
+ Related runs + +
+ +
+
+ {waitpoint.status === "WAITING" && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx new file mode 100644 index 0000000000..f8d2fc4008 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx @@ -0,0 +1,264 @@ +import { BookOpenIcon } from "@heroicons/react/20/solid"; +import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { NoWaitpointTokens } from "~/components/BlankStatePanels"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { ListPagination } from "~/components/ListPagination"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { RunTag } from "~/components/runs/v3/RunTag"; +import { WaitpointStatusCombo } from "~/components/runs/v3/WaitpointStatus"; +import { + WaitpointSearchParamsSchema, + WaitpointTokenFilters, +} from "~/components/runs/v3/WaitpointTokenFilters"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WaitpointListPresenter } from "~/presenters/v3/WaitpointListPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { + docsPath, + EnvironmentParamSchema, + v3WaitpointHttpCallbackPath, + v3WaitpointTokenPath, +} from "~/utils/pathBuilder"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Waitpoint tokens | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const url = new URL(request.url); + const s = { + id: url.searchParams.get("id") ?? undefined, + statuses: url.searchParams.getAll("statuses"), + idempotencyKey: url.searchParams.get("idempotencyKey") ?? undefined, + tags: url.searchParams.getAll("tags"), + period: url.searchParams.get("period") ?? undefined, + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + direction: url.searchParams.get("direction") ?? undefined, + }; + + const searchParams = WaitpointSearchParamsSchema.parse(s); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + try { + const presenter = new WaitpointListPresenter(); + const result = await presenter.call({ + environment, + resolver: "HTTP_CALLBACK", + ...searchParams, + }); + + return typedjson(result); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { success, tokens, pagination, hasFilters, hasAnyTokens, filters } = + useTypedLoaderData(); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const { waitpointParam } = useParams(); + const isShowingWaitpoint = !!waitpointParam; + + return ( + + + + + + + Waitpoints docs + + + + + {!hasAnyTokens ? ( + + + + ) : ( + + +
+
+ +
+ +
+
+
+ + + + Created + ID + URL + Status + Completed + Idempotency Key + Tags + + + + {tokens.length > 0 ? ( + tokens.map((token) => { + const ttlExpired = + token.idempotencyKeyExpiresAt && + token.idempotencyKeyExpiresAt < new Date(); + + const path = v3WaitpointHttpCallbackPath( + organization, + project, + environment, + token, + filters + ); + const rowIsSelected = waitpointParam === token.id; + + return ( + + + + + + + + + + + + + + + + + {token.completedAt ? : "–"} + + + {token.idempotencyKey ? ( + token.idempotencyKeyExpiresAt ? ( + + + {ttlExpired ? ( + (expired) + ) : null} + + } + buttonClassName={ttlExpired ? "opacity-50" : undefined} + button={token.idempotencyKey} + /> + ) : ( + token.idempotencyKey + ) + ) : ( + "–" + )} + + +
+ {token.tags.map((tag) => ) || "–"} +
+
+
+ ); + }) + ) : ( + + +
+ No waitpoint tokens found +
+
+
+ )} +
+
+ + {(pagination.next || pagination.previous) && ( +
+ +
+ )} +
+
+
+ {isShowingWaitpoint && ( + <> + + + + + + )} +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts index 120e318d83..14fb6e5f2e 100644 --- a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts +++ b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts @@ -10,6 +10,7 @@ import { ApiWaitpointListPresenter, ApiWaitpointListSearchParams, } from "~/presenters/v3/ApiWaitpointListPresenter.server"; +import { generateWaitpointCallbackUrl } from "~/presenters/v3/WaitpointPresenter.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { createActionApiRoute, @@ -83,11 +84,7 @@ const { action } = createActionApiRoute( return json( { id: WaitpointId.toFriendlyId(result.waitpoint.id), - url: `${ - env.API_ORIGIN ?? env.APP_ORIGIN - }/api/v1/waitpoints/http-callback/${WaitpointId.toFriendlyId( - result.waitpoint.id - )}/callback`, + url: generateWaitpointCallbackUrl(result.waitpoint.id), isCached: result.isCached, }, { status: 200 } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 6996eff9d7..bf8f6bd5fb 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -342,6 +342,33 @@ export function v3WaitpointTokenPath( return `${v3WaitpointTokensPath(organization, project, environment)}/${token.id}${query}`; } +export function v3WaitpointHttpCallbacksPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + filters?: WaitpointSearchParams +) { + const searchParams = objectToSearchParams(filters); + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3EnvironmentPath( + organization, + project, + environment + )}/waitpoints/http-callbacks${query}`; +} + +export function v3WaitpointHttpCallbackPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + token: { id: string }, + filters?: WaitpointSearchParams +) { + const searchParams = objectToSearchParams(filters); + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3WaitpointHttpCallbacksPath(organization, project, environment)}/${token.id}${query}`; +} + export function v3BatchesPath( organization: OrgForPath, project: ProjectForPath, From 8bfc515a19cd87e19195a5cec7457e7c2bfe0ae0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 22:12:41 +0100 Subject: [PATCH 17/56] Remove todos --- packages/trigger-sdk/src/v3/wait.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index a2c35b8ae3..e6ff0f9914 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -790,21 +790,6 @@ export const wait = { }, } ); - - //TODO: - // Support a schema passed in, infer the type, or a generic supplied type - // Support a timeout passed in - // 1. Make an API call to engine.trigger.dev/v1/waitpoints/http-callback/create. New Waitpoint type "HTTPCallback" - // 2. Return the url and a waitpoint id (but don't block the run yet) - // 3. Create a span for the main call - // 4. Set the url and waitpoint entity type and id as attributes on the parent span - // 5. Create a span around the callback - // 6. Deal with errors thrown in the callback use `tryCatch()` - // 7. If that callback is successfully called, wait for the waitpoint with an API call to engine.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/block - // 8. Wait for the waitpoint in the runtime - // 9. On the backend when the API is hit, complete the waitpoint with the result api.trigger.dev/v1/waitpoints/http-callback/{waitpointId}/callback - // 10. Receive the result here and import the packet, then get the result in the right format - // 11. Make unwrap work }, }; From 2b21ca33a539b28398bf394e0b67a5209237101a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 22:12:48 +0100 Subject: [PATCH 18/56] Added temporary icon --- apps/webapp/app/assets/icons/HttpCallbackIcon.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx index 2994f2ba7b..7c48bd0671 100644 --- a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx +++ b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx @@ -1,12 +1,5 @@ +import { PhoneArrowDownLeftIcon } from "@heroicons/react/20/solid"; + export function HttpCallbackIcon({ className }: { className?: string }) { - return ( - - - - ); + return ; } From f5d73f40315ad5d90706dd7ca4d495dce0936523 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 5 May 2025 22:18:12 +0100 Subject: [PATCH 19/56] Added a blank state --- .../app/components/BlankStatePanels.tsx | 28 +++++++++++++++++++ .../route.tsx | 4 +-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index ea7a20764d..8edaa0f369 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -36,6 +36,7 @@ import { TextLink } from "./primitives/TextLink"; import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; import { StepContentContainer } from "./StepContentContainer"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; export function HasNoTasksDev() { return ( @@ -431,6 +432,33 @@ export function NoWaitpointTokens() { ); } +export function NoHttpCallbacks() { + return ( + + Waitpoint docs + + } + > + + HTTP callbacks are used to pause runs until an HTTP request is made to a provided URL. + + + They are useful when using APIs that provide a callback URL. You can send the URL to them + and when they callback your run will continue. + + + ); +} function SwitcherPanel() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx index f8d2fc4008..36a6dcdb07 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx @@ -3,7 +3,7 @@ import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { NoWaitpointTokens } from "~/components/BlankStatePanels"; +import { NoHttpCallbacks } from "~/components/BlankStatePanels"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; @@ -132,7 +132,7 @@ export default function Page() { {!hasAnyTokens ? ( - + ) : ( From 1e5350a7a7f296be4d3a4d19c701d2cc41cbc3bb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 11:19:57 +0100 Subject: [PATCH 20/56] Some tweaks and added a Replicate example --- .../app/assets/icons/HttpCallbackIcon.tsx | 23 +++++++++-- .../components/runs/v3/WaitpointDetails.tsx | 28 +++++++++++++ pnpm-lock.yaml | 40 ++++++++++++++++++- references/hello-world/package.json | 2 + references/hello-world/src/trigger/waits.ts | 40 ++++++++++++++++++- 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx index 7c48bd0671..cae910cb5e 100644 --- a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx +++ b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx @@ -1,5 +1,22 @@ -import { PhoneArrowDownLeftIcon } from "@heroicons/react/20/solid"; - export function HttpCallbackIcon({ className }: { className?: string }) { - return ; + return ( + + + + + + + + + + ); } diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx index 078e17d2c7..ee15c175af 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -17,6 +17,9 @@ import { WaitpointStatusCombo } from "./WaitpointStatus"; import { RunTag } from "./RunTag"; import { CopyableText } from "~/components/primitives/CopyableText"; import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { WaitpointResolver } from "@trigger.dev/database"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; export function WaitpointDetailTable({ waitpoint, @@ -62,6 +65,12 @@ export function WaitpointDetailTable({ )}
+ + Type + + + + {waitpoint.callbackUrl && ( Callback URL @@ -148,3 +157,22 @@ export function WaitpointDetailTable({ ); } + +export function WaitpointResolverCombo({ resolver }: { resolver: WaitpointResolver }) { + switch (resolver) { + case "TOKEN": + return ( +
+ + Token +
+ ); + case "HTTP_CALLBACK": + return ( +
+ + HTTP Callback +
+ ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 671cb44d4e..b0f5b268dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1927,6 +1927,12 @@ importers: '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk + openai: + specifier: ^4.97.0 + version: 4.97.0(zod@3.23.8) + replicate: + specifier: ^1.0.1 + version: 1.0.1 zod: specifier: 3.23.8 version: 3.23.8 @@ -28833,6 +28839,30 @@ packages: - encoding dev: false + /openai@4.97.0(zod@3.23.8): + resolution: {integrity: sha512-LRoiy0zvEf819ZUEJhgfV8PfsE8G5WpQi4AwA1uCV8SKvvtXQkoWUFkepD6plqyJQRghy2+AEPQ07FrJFKHZ9Q==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + dependencies: + '@types/node': 18.19.20 + '@types/node-fetch': 2.6.12 + abort-controller: 3.0.0 + agentkeepalive: 4.5.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.6.12 + zod: 3.23.8 + transitivePeerDependencies: + - encoding + dev: false + /openapi-fetch@0.9.8: resolution: {integrity: sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==} dependencies: @@ -30125,7 +30155,7 @@ packages: /process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - dev: true + requiresBuild: true /progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} @@ -31096,7 +31126,6 @@ packages: events: 3.3.0 process: 0.11.10 string_decoder: 1.3.0 - dev: true /readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} @@ -31473,6 +31502,13 @@ packages: engines: {node: '>=8'} dev: true + /replicate@1.0.1: + resolution: {integrity: sha512-EY+rK1YR5bKHcM9pd6WyaIbv6m2aRIvHfHDh51j/LahlHTLKemTYXF6ptif2sLa+YospupAsIoxw8Ndt5nI3vg==} + engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'} + optionalDependencies: + readable-stream: 4.5.2 + dev: false + /request@2.88.2: resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} engines: {node: '>= 6'} diff --git a/references/hello-world/package.json b/references/hello-world/package.json index a7138837a1..67d93c4fdc 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -7,6 +7,8 @@ }, "dependencies": { "@trigger.dev/sdk": "workspace:*", + "openai": "^4.97.0", + "replicate": "^1.0.1", "zod": "3.23.8" }, "scripts": { diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 28a355a1d9..d3cc814ef8 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -1,5 +1,5 @@ -import { logger, wait, task, retry, idempotencyKeys, auth } from "@trigger.dev/sdk/v3"; -import { z } from "zod"; +import { auth, idempotencyKeys, logger, retry, task, wait } from "@trigger.dev/sdk/v3"; +import Replicate, { Prediction } from "replicate"; type Token = { status: "approved" | "pending" | "rejected"; }; @@ -143,7 +143,43 @@ export const waitForDuration = task({ export const waitHttpCallback = task({ id: "wait-http-callback", + retry: { + maxAttempts: 1, + }, run: async () => { + if (process.env.REPLICATE_API_KEY) { + const replicate = new Replicate({ + auth: process.env.REPLICATE_API_KEY, + }); + + const prediction = await wait.forHttpCallback( + async (url) => { + //pass the provided URL to Replicate's webhook + await replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Any Warhol", + }, + // pass the provided URL to Replicate's webhook, so they can "callback" + webhook: url, + webhook_events_filter: ["completed"], + }); + }, + { + timeout: "10m", + } + ); + + if (!prediction.ok) { + throw new Error("Failed to create prediction"); + } + + logger.log("Prediction", prediction); + + const imageUrl = prediction.output.output; + logger.log("Image URL", imageUrl); + } + const result = await wait.forHttpCallback<{ foo: string }>( async (url) => { logger.log(`Wait for HTTP callback ${url}`); From 71d88b9e7c9763115fa1edab05c02e971f2266ea Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 12:28:19 +0100 Subject: [PATCH 21/56] Implement unwrap() for httpCallback --- packages/trigger-sdk/src/v3/wait.ts | 223 +++++++++++--------- references/hello-world/src/trigger/waits.ts | 20 ++ 2 files changed, 146 insertions(+), 97 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index e6ff0f9914..58bb2e6472 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -1,35 +1,30 @@ +import { SpanStatusCode } from "@opentelemetry/api"; import { - SemanticInternalAttributes, accessoryAttributes, - runtime, apiClientManager, ApiPromise, ApiRequestOptions, + CompleteWaitpointTokenResponseBody, CreateWaitpointTokenRequestBody, + CreateWaitpointTokenResponse, CreateWaitpointTokenResponseBody, + CursorPagePromise, + flattenAttributes, + HttpCallbackResult, + ListWaitpointTokensQueryParams, mergeRequestOptions, - CompleteWaitpointTokenResponseBody, - WaitpointTokenTypedResult, Prettify, + runtime, + SemanticInternalAttributes, taskContext, - ListWaitpointTokensQueryParams, - CursorPagePromise, - WaitpointTokenItem, - flattenAttributes, + tryCatch, WaitpointListTokenItem, - WaitpointTokenStatus, WaitpointRetrieveTokenResponse, - CreateWaitpointTokenResponse, - HttpCallbackSchema, - HttpCallbackResultTypeFromSchema, - HttpCallbackResult, - tryCatch, - inferSchemaIn, - getSchemaParseFn, + WaitpointTokenStatus, + WaitpointTokenTypedResult, } from "@trigger.dev/core/v3"; -import { tracer } from "./tracer.js"; import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; -import { SpanStatusCode } from "@opentelemetry/api"; +import { tracer } from "./tracer.js"; /** * This creates a waitpoint token. @@ -383,6 +378,29 @@ function printWaitBelowThreshold() { ); } +class ManualWaitpointPromise extends Promise> { + constructor( + executor: ( + resolve: ( + value: WaitpointTokenTypedResult | PromiseLike> + ) => void, + reject: (reason?: any) => void + ) => void + ) { + super(executor); + } + + unwrap(): Promise { + return this.then((result) => { + if (result.ok) { + return result.output; + } else { + throw new WaitpointTimeoutError(result.error.message); + } + }); + } +} + export const wait = { for: async (options: WaitForOptions) => { const ctx = taskContext.ctx; @@ -689,7 +707,7 @@ export const wait = { * @param requestOptions * @returns */ - async forHttpCallback( + forHttpCallback( callback: (url: string) => Promise, options?: CreateWaitpointTokenRequestBody & { /** @@ -702,94 +720,105 @@ export const wait = { releaseConcurrency?: boolean; }, requestOptions?: ApiRequestOptions - ): Promise> { - const ctx = taskContext.ctx; + ): ManualWaitpointPromise { + return new ManualWaitpointPromise(async (resolve, reject) => { + try { + const ctx = taskContext.ctx; - if (!ctx) { - throw new Error("wait.forHttpCallback can only be used from inside a task.run()"); - } - - const apiClient = apiClientManager.clientOrThrow(); - - const waitpoint = await apiClient.createWaitpointHttpCallback(options ?? {}, requestOptions); - - return tracer.startActiveSpan( - `wait.forHttpCallback()`, - async (span) => { - const [error] = await tryCatch(callback(waitpoint.url)); - - if (error) { - throw new Error(`You threw an error in your callback: ${error.message}`, { - cause: error, - }); + if (!ctx) { + throw new Error("wait.forHttpCallback can only be used from inside a task.run()"); } - const response = await apiClient.waitForWaitpointToken({ - runFriendlyId: ctx.run.id, - waitpointFriendlyId: waitpoint.id, - releaseConcurrency: options?.releaseConcurrency, - }); + const apiClient = apiClientManager.clientOrThrow(); - if (!response.success) { - throw new Error(`Failed to wait for wait for HTTP callback ${waitpoint.id}`); - } + const waitpoint = await apiClient.createWaitpointHttpCallback( + options ?? {}, + requestOptions + ); - const result = await runtime.waitUntil(waitpoint.id); + const result = await tracer.startActiveSpan( + `wait.forHttpCallback()`, + async (span) => { + const [error] = await tryCatch(callback(waitpoint.url)); - const data = result.output - ? await conditionallyImportAndParsePacket( - { data: result.output, dataType: result.outputType ?? "application/json" }, - apiClient - ) - : undefined; + if (error) { + throw new Error(`You threw an error in your callback: ${error.message}`, { + cause: error, + }); + } - if (result.ok) { - return { - ok: result.ok, - output: data, - }; - } else { - const error = new WaitpointTimeoutError(data.message); + const response = await apiClient.waitForWaitpointToken({ + runFriendlyId: ctx.run.id, + waitpointFriendlyId: waitpoint.id, + releaseConcurrency: options?.releaseConcurrency, + }); - span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - }); + if (!response.success) { + throw new Error(`Failed to wait for wait for HTTP callback ${waitpoint.id}`); + } - return { - ok: result.ok, - error, - }; - } - }, - { - attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "wait", - [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", - [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, - ...accessoryAttributes({ - items: [ - { - text: waitpoint.id, - variant: "normal", - }, - ], - style: "codepath", - }), - id: waitpoint.id, - isCached: waitpoint.isCached, - idempotencyKey: options?.idempotencyKey, - idempotencyKeyTTL: options?.idempotencyKeyTTL, - timeout: options?.timeout - ? typeof options.timeout === "string" - ? options.timeout - : options.timeout.toISOString() - : undefined, - tags: options?.tags, - url: waitpoint.url, - }, + const result = await runtime.waitUntil(waitpoint.id); + + const data = result.output + ? await conditionallyImportAndParsePacket( + { data: result.output, dataType: result.outputType ?? "application/json" }, + apiClient + ) + : undefined; + + if (result.ok) { + return { + ok: result.ok, + output: data, + }; + } else { + const error = new WaitpointTimeoutError(data.message); + + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + + return { + ok: result.ok, + error, + }; + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "wait", + [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", + [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, + ...accessoryAttributes({ + items: [ + { + text: waitpoint.id, + variant: "normal", + }, + ], + style: "codepath", + }), + id: waitpoint.id, + isCached: waitpoint.isCached, + idempotencyKey: options?.idempotencyKey, + idempotencyKeyTTL: options?.idempotencyKeyTTL, + timeout: options?.timeout + ? typeof options.timeout === "string" + ? options.timeout + : options.timeout.toISOString() + : undefined, + tags: options?.tags, + url: waitpoint.url, + }, + } + ); + + resolve(result); + } catch (error) { + reject(error); } - ); + }); }, }; diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index d3cc814ef8..0cdf0f3bd1 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -178,6 +178,26 @@ export const waitHttpCallback = task({ const imageUrl = prediction.output.output; logger.log("Image URL", imageUrl); + + //same again but with unwrapping + const result2 = await wait + .forHttpCallback( + async (url) => { + await replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Any Warhol", + }, + webhook: url, + }); + }, + { + timeout: "60s", + } + ) + .unwrap(); + + logger.log("Result2", { result2 }); } const result = await wait.forHttpCallback<{ foo: string }>( From 7766c2fe6c6f46041cbd29be3d4601838557d5e9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 12:36:56 +0100 Subject: [PATCH 22/56] Added unwrap to wait.forToken() as well --- packages/trigger-sdk/src/v3/wait.ts | 136 +++++++++++++++------------- 1 file changed, 72 insertions(+), 64 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 58bb2e6472..184fe59066 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -580,7 +580,7 @@ export const wait = { * @param options - The options for the waitpoint token. * @returns The waitpoint token. */ - forToken: async ( + forToken: ( /** * The token to wait for. * This can be a string token ID or an object with an `id` property. @@ -599,76 +599,84 @@ export const wait = { */ releaseConcurrency?: boolean; } - ): Promise>> => { - const ctx = taskContext.ctx; + ): ManualWaitpointPromise => { + return new ManualWaitpointPromise(async (resolve, reject) => { + try { + const ctx = taskContext.ctx; - if (!ctx) { - throw new Error("wait.forToken can only be used from inside a task.run()"); - } + if (!ctx) { + throw new Error("wait.forToken can only be used from inside a task.run()"); + } - const apiClient = apiClientManager.clientOrThrow(); + const apiClient = apiClientManager.clientOrThrow(); - const tokenId = typeof token === "string" ? token : token.id; + const tokenId = typeof token === "string" ? token : token.id; - return tracer.startActiveSpan( - `wait.forToken()`, - async (span) => { - const response = await apiClient.waitForWaitpointToken({ - runFriendlyId: ctx.run.id, - waitpointFriendlyId: tokenId, - releaseConcurrency: options?.releaseConcurrency, - }); + const result = await tracer.startActiveSpan( + `wait.forToken()`, + async (span) => { + const response = await apiClient.waitForWaitpointToken({ + runFriendlyId: ctx.run.id, + waitpointFriendlyId: tokenId, + releaseConcurrency: options?.releaseConcurrency, + }); - if (!response.success) { - throw new Error(`Failed to wait for wait token ${tokenId}`); - } + if (!response.success) { + throw new Error(`Failed to wait for wait token ${tokenId}`); + } - const result = await runtime.waitUntil(tokenId); - - const data = result.output - ? await conditionallyImportAndParsePacket( - { data: result.output, dataType: result.outputType ?? "application/json" }, - apiClient - ) - : undefined; - - if (result.ok) { - return { - ok: result.ok, - output: data, - } as WaitpointTokenTypedResult; - } else { - const error = new WaitpointTimeoutError(data.message); - - span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - }); - - return { - ok: result.ok, - error, - } as WaitpointTokenTypedResult; - } - }, - { - attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "wait", - [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", - [SemanticInternalAttributes.ENTITY_ID]: tokenId, - id: tokenId, - ...accessoryAttributes({ - items: [ - { - text: tokenId, - variant: "normal", - }, - ], - style: "codepath", - }), - }, + const result = await runtime.waitUntil(tokenId); + + const data = result.output + ? await conditionallyImportAndParsePacket( + { data: result.output, dataType: result.outputType ?? "application/json" }, + apiClient + ) + : undefined; + + if (result.ok) { + return { + ok: result.ok, + output: data, + } as WaitpointTokenTypedResult; + } else { + const error = new WaitpointTimeoutError(data.message); + + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + + return { + ok: result.ok, + error, + } as WaitpointTokenTypedResult; + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "wait", + [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", + [SemanticInternalAttributes.ENTITY_ID]: tokenId, + id: tokenId, + ...accessoryAttributes({ + items: [ + { + text: tokenId, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + } + ); + + resolve(result); + } catch (error) { + reject(error); } - ); + }); }, /** * This allows you to start some work on another API (or one of your own services) From 54e76cefd9b84d557469767d6af7ef3e5871cd06 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 13:57:02 +0100 Subject: [PATCH 23/56] Improved jsdocs --- packages/trigger-sdk/src/v3/wait.ts | 47 ++++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 184fe59066..cbe08c6b43 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -578,7 +578,7 @@ export const wait = { * * @param token - The token to wait for. * @param options - The options for the waitpoint token. - * @returns The waitpoint token. + * @returns A promise that resolves to the result of the waitpoint. You can use `.unwrap()` to get the result and an error will throw. */ forToken: ( /** @@ -690,30 +690,35 @@ export const wait = { * ```ts * //wait for the prediction to complete const prediction = await wait.forHttpCallback( - async (url) => { - //pass the provided URL to Replicate's webhook - await replicate.predictions.create({ - version: - "19deaef633fd44776c82edf39fd60e95a7250b8ececf11a725229dc75a81f9ca", - input: payload, - // pass the provided URL to Replicate's webhook, so they can "callback" - webhook: url, - webhook_events_filter: ["completed"], - }); - }, - { - timeout: "2m", + async (url) => { + //pass the provided URL to Replicate's webhook + await replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Any Warhol", + }, + // pass the provided URL to Replicate's webhook, so they can "callback" + webhook: url, + webhook_events_filter: ["completed"], + }); + }, + { + timeout: "10m", + } + ); + + if (!prediction.ok) { + throw new Error("Failed to create prediction"); } - ); - //the value of prediction is the body of the webook that Replicate sent - const result = prediction.output; + //the value of prediction is the body of the webook that Replicate sent + const result = prediction.output; * ``` * - * @param callback - * @param options - * @param requestOptions - * @returns + * @param callback A function that gives you a URL you can use to send the result to. + * @param options - The options for the waitpoint. + * @param requestOptions - The request options for the waitpoint. + * @returns A promise that resolves to the result of the waitpoint. You can use `.unwrap()` to get the result and an error will throw. */ forHttpCallback( callback: (url: string) => Promise, From 6cd887047ef560c05f705b484d96ffd4229c2cef Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 14:14:33 +0100 Subject: [PATCH 24/56] Added docs --- docs/docs.json | 8 +- docs/wait-for-http-callback.mdx | 138 ++++++++++++++++++++++++++++++++ docs/wait.mdx | 15 ++-- 3 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 docs/wait-for-http-callback.mdx diff --git a/docs/docs.json b/docs/docs.json index cf7d0a3949..d6866560a4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -47,7 +47,13 @@ "errors-retrying", { "group": "Wait", - "pages": ["wait", "wait-for", "wait-until", "wait-for-token"] + "pages": [ + "wait", + "wait-for", + "wait-until", + "wait-for-token", + "wait-for-http-callback" + ] }, "queue-concurrency", "versioning", diff --git a/docs/wait-for-http-callback.mdx b/docs/wait-for-http-callback.mdx new file mode 100644 index 0000000000..6f76813c2c --- /dev/null +++ b/docs/wait-for-http-callback.mdx @@ -0,0 +1,138 @@ +--- +title: "Wait for HTTP callback" +description: "Pause runs until an HTTP callback is made to the provided URL." +--- + +import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; + +The `wait.forHttpCallback()` function gives you a callback URL, and then pauses the run until that callback is hit. This is most commonly used with 3rd party APIs that take a long time and that accept a callback (or webhook) URL. + +When the callback URL is requested the run will continue where it left off with the body of the request as the output available for you to use. + + + +## Usage + +In this example we create an image using Replicate. Their API accepts a "webhook", which is a callback. + +```ts +import { logger, task, wait } from "@trigger.dev/sdk"; + +const replicate = new Replicate({ + auth: process.env.REPLICATE_API_KEY, +}); + +export const replicate = task({ + id: "replicate", + run: async () => { + // This will pause the run and give you a URL + const result = await wait.forHttpCallback( + async (url) => { + // 👆 This URL continues your run when hit with a POST request + + // Make the call to Replicate, passing in the URL + await replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Any Warhol", + }, + // Make sure to pass the callback URL + // 👇 + webhook: url, + // We only want to call when it's completed + webhook_events_filter: ["completed"], + }); + }, + { + // We'll fail the waitpoint after 10m of inactivity + timeout: "10m", + } + ); + + if (!result.ok) { + throw new Error("Failed to create prediction"); + } + + logger.log("Result", result); + + const imageUrl = result.output.output; + logger.log("Image URL", imageUrl); + }, +}); +``` + +## unwrap() + +We provide a handy `.unwrap()` method that will throw an error if the result is not ok. This means your happy path is a lot cleaner. + +```ts +const prediction = await wait + .forHttpCallback( + async (url) => { + // ... + }, + { + timeout: "10m", + } + ) + .unwrap(); +// 👆 This will throw an error if the waitpoint times out + +// This is the actual data you sent to the callback now, not a result object +logger.log("Prediction", prediction); +``` + +### Options + +The `wait.forHttpCallback` function accepts an optional second parameter with the following properties: + + + The maximum amount of time to wait for the token to be completed. + + + + An idempotency key for the token. If provided, the token will be completed with the same output if + the same idempotency key is used again. + + + + The time to live for the idempotency key. After this time, the idempotency key will be ignored and + can be reused to create a new waitpoint. + + + + Tags to attach to the token. Tags can be used to filter waitpoints in the dashboard. + + + + If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. + +This is useful if you want to allow other runs to execute while waiting + +Note: It's possible that this run will not be able to resume when the waitpoint is complete if this is set to true. +It will go back in the queue and will resume once concurrency becomes available. + +The default is `false`. + + + +### returns + +The `forHttpCallback` function returns a result object with the following properties: + + + Whether the token was completed successfully. + + + + If `ok` is `true`, this will be the output of the token. + + + + If `ok` is `false`, this will be the error that occurred. The only error that can occur is a + timeout error. + + +### unwrap() returns + +If you use the `unwrap()` method, it will just return the output of the token. If an error occurs it will throw an error. diff --git a/docs/wait.mdx b/docs/wait.mdx index af80c8cf02..9cdf641047 100644 --- a/docs/wait.mdx +++ b/docs/wait.mdx @@ -4,14 +4,15 @@ sidebarTitle: "Overview" description: "During your run you can wait for a period of time or for something to happen." --- -import PausedExecutionFree from "/snippets/paused-execution-free.mdx" +import PausedExecutionFree from "/snippets/paused-execution-free.mdx"; -Waiting allows you to write complex tasks as a set of async code, without having to scheduled another task or poll for changes. +Waiting allows you to write complex tasks as a set of async code, without having to schedule another task or poll for changes. -| Function | What it does | -| :--------------------------------------| :---------------------------------------------------------------------------------------- | -| [wait.for()](/wait-for) | Waits for a specific period of time, e.g. 1 day. | -| [wait.until()](/wait-until) | Waits until the provided `Date`. | -| [wait.forToken()](/wait-for-token) | Pauses task runs until a token is completed. | +| Function | What it does | +| :------------------------------------------------ | :------------------------------------------------------------- | +| [wait.for()](/wait-for) | Waits for a specific period of time, e.g. 1 day. | +| [wait.until()](/wait-until) | Waits until the provided `Date`. | +| [wait.forToken()](/wait-for-token) | Pauses runs until a token is completed. | +| [wait.forHttpCallback()](/wait-for-http-callback) | Pause runs until an HTTP callback is made to the provided URL. | From 3f50577e5158fe41bb68ee4410aa179f6d64cb89 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 14:18:30 +0100 Subject: [PATCH 25/56] Added unwrap to the token docs --- docs/wait-for-token.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/wait-for-token.mdx b/docs/wait-for-token.mdx index 8b7a44438e..a786bd48f8 100644 --- a/docs/wait-for-token.mdx +++ b/docs/wait-for-token.mdx @@ -270,6 +270,18 @@ The `forToken` function returns a result object with the following properties: timeout error. +### unwrap() + +We provide a handy `.unwrap()` method that will throw an error if the result is not ok. This means your happy path is a lot cleaner. + +```ts +const approval = await wait.forToken(tokenId).unwrap(); +// unwrap means an error will throw if the waitpoint times out 👆 + +// This is the actual data you sent to the token now, not a result object +console.log("Approval", approval); +``` + ### Example ```ts From 455f5a25e57888ee5549a8d6784f264604efa441 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 18:24:46 +0100 Subject: [PATCH 26/56] Show a dash if there are no tags Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../route.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx index 36a6dcdb07..db350c823c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx @@ -222,7 +222,9 @@ export default function Page() {
- {token.tags.map((tag) => ) || "–"} + {token.tags.length > 0 + ? token.tags.map((tag) => ) + : "–"}
From d1c19fcc5aba8c487fd275f835b55b8e48c3a7f0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 18:34:58 +0100 Subject: [PATCH 27/56] Make the timeout error safer --- packages/trigger-sdk/src/v3/wait.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index cbe08c6b43..a3f0d0ba2e 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -785,7 +785,7 @@ export const wait = { output: data, }; } else { - const error = new WaitpointTimeoutError(data.message); + const error = new WaitpointTimeoutError(data?.message ?? "Timeout error"); span.recordException(error); span.setStatus({ From e2eb321ae49bfd12c4a9bc550585f8852a2eba0d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 18:44:47 +0100 Subject: [PATCH 28/56] =?UTF-8?q?Fixed=20migrations=E2=80=A6=20should=20us?= =?UTF-8?q?e=20id=20desc=20not=20createdAt=20desc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 2 +- .../migration.sql | 7 +------ internal-packages/database/prisma/schema.prisma | 4 ++-- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql b/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql index ca3e18e806..28e07d577e 100644 --- a/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql +++ b/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql @@ -1,2 +1,2 @@ -- CreateIndex -CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_createdAt_idx" ON "Waitpoint" ("environmentId", "resolver", "createdAt" DESC); \ No newline at end of file +CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_id_idx" ON "Waitpoint" ("environmentId", "resolver", "id" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql b/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql index 75227b7672..80916addcf 100644 --- a/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql +++ b/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql @@ -1,7 +1,2 @@ -- CreateIndex -CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_status_createdAt_idx" ON "Waitpoint" ( - "environmentId", - "resolver", - "status", - "createdAt" DESC -); \ No newline at end of file +CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_status_id_idx" ON "Waitpoint" ("environmentId", "resolver", "status", "id" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index eee41f9235..1fb04aa5b7 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2161,8 +2161,8 @@ model Waitpoint { /// Quickly find a batch waitpoint @@index([completedByBatchId]) /// Dashboard filtering - @@index([environmentId, resolver, createdAt(sort: Desc)]) - @@index([environmentId, resolver, status, createdAt(sort: Desc)]) + @@index([environmentId, resolver, id(sort: Desc)]) + @@index([environmentId, resolver, status, id(sort: Desc)]) } enum WaitpointType { From b218ff81b853cf62e8c94d09174d9808a43ac251 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 19:19:23 +0100 Subject: [PATCH 29/56] Fixed page title --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx index db350c823c..8cc765bf20 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx @@ -49,7 +49,7 @@ import { export const meta: MetaFunction = () => { return [ { - title: `Waitpoint tokens | Trigger.dev`, + title: `Waitpoint HTTP callbacks | Trigger.dev`, }, ]; }; From 8503ceaee3454efc1ff2870cb777a81038c1010c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 May 2025 19:37:32 +0100 Subject: [PATCH 30/56] =?UTF-8?q?Fixed=20migration=20so=20it=20only=20adds?= =?UTF-8?q?=20them=20if=20they=20don=E2=80=99t=20exist.=20This=20allows=20?= =?UTF-8?q?us=20to=20manuall=20run=20in=20cloud=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql b/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql index e32b4c0afd..00fb2be12f 100644 --- a/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql +++ b/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql @@ -1,6 +1,11 @@ -- CreateEnum -CREATE TYPE "WaitpointResolver" AS ENUM ('ENGINE', 'TOKEN', 'HTTP_CALLBACK'); +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'WaitpointResolver') THEN + CREATE TYPE "WaitpointResolver" AS ENUM ('ENGINE', 'TOKEN', 'HTTP_CALLBACK'); + END IF; +END$$; -- AlterTable ALTER TABLE "Waitpoint" -ADD COLUMN "resolver" "WaitpointResolver" NOT NULL DEFAULT 'ENGINE'; \ No newline at end of file +ADD COLUMN IF NOT EXISTS "resolver" "WaitpointResolver" NOT NULL DEFAULT 'ENGINE'; \ No newline at end of file From 799f8e15ad644820a75134d683d61ce3258d773f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 09:35:59 +0100 Subject: [PATCH 31/56] Respect the max content length by getting the length of the body --- ...-callback.$waitpointFriendlyId.callback.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts index 09a7b9131a..09a5c01be5 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts @@ -7,6 +7,7 @@ import { import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; +import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { engine } from "~/v3/runEngine.server"; @@ -19,6 +20,11 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "POST" } }); } + const contentLength = request.headers.get("content-length"); + if (contentLength && parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) { + return json({ error: "Request body too large" }, { status: 413 }); + } + const { waitpointFriendlyId } = paramsSchema.parse(params); const waitpointId = WaitpointId.toId(waitpointFriendlyId); @@ -40,7 +46,16 @@ export async function action({ request, params }: ActionFunctionArgs) { }); } - const body = await request.json(); + let body; + try { + body = await readJsonWithLimit(request, env.TASK_PAYLOAD_MAXIMUM_SIZE); + } catch (e) { + return json({ error: "Request body too large" }, { status: 413 }); + } + + if (!body) { + body = {}; + } const stringifiedData = await stringifyIO(body); const finalData = await conditionallyExportPacket( @@ -66,3 +81,27 @@ export async function action({ request, params }: ActionFunctionArgs) { throw json({ error: "Failed to complete waitpoint token" }, { status: 500 }); } } + +async function readJsonWithLimit(request: Request, maxSize: number) { + const reader = request.body?.getReader(); + if (!reader) throw new Error("No body"); + let received = 0; + let chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + if (received > maxSize) { + throw new Error("Request body too large"); + } + chunks.push(value); + } + const full = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + full.set(chunk, offset); + offset += chunk.length; + } + const text = new TextDecoder().decode(full); + return JSON.parse(text); +} From ec9fdf649b4544612af04c392280340a5635f5fa Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 09:46:49 +0100 Subject: [PATCH 32/56] Added more docs details about the callback format --- docs/wait-for-http-callback.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/wait-for-http-callback.mdx b/docs/wait-for-http-callback.mdx index 6f76813c2c..641c60b32d 100644 --- a/docs/wait-for-http-callback.mdx +++ b/docs/wait-for-http-callback.mdx @@ -5,7 +5,7 @@ description: "Pause runs until an HTTP callback is made to the provided URL." import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; -The `wait.forHttpCallback()` function gives you a callback URL, and then pauses the run until that callback is hit. This is most commonly used with 3rd party APIs that take a long time and that accept a callback (or webhook) URL. +The `wait.forHttpCallback()` function gives you a callback URL, and then pauses the run until that callback is hit with a POST request. This is most commonly used with 3rd party APIs that take a long time and that accept a callback (or webhook) URL. When the callback URL is requested the run will continue where it left off with the body of the request as the output available for you to use. @@ -136,3 +136,9 @@ The `forHttpCallback` function returns a result object with the following proper ### unwrap() returns If you use the `unwrap()` method, it will just return the output of the token. If an error occurs it will throw an error. + +## The format of the callback request + +If you want to offload work to one of your own services you will need to do the callback request yourself. + +The `wait.forHttpCallback()` function gives you a unique one-time use URL. You should do a `POST` request to this URL with a text body that is JSON parseable. From 49e7f152c176b6df3c7861b2745e167723b40991 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 10:12:42 +0100 Subject: [PATCH 33/56] Remove code comment --- ....v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts index 09a5c01be5..4f08ec7d96 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts @@ -29,7 +29,6 @@ export async function action({ request, params }: ActionFunctionArgs) { const waitpointId = WaitpointId.toId(waitpointFriendlyId); try { - //check permissions const waitpoint = await $replica.waitpoint.findFirst({ where: { id: waitpointId, From aad1278c2e01992990119dd3ac22691319cc0080 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 10:13:24 +0100 Subject: [PATCH 34/56] Improved the error --- ....waitpoints.http-callback.$waitpointFriendlyId.callback.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts index 4f08ec7d96..2c281ee6a2 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts @@ -76,8 +76,8 @@ export async function action({ request, params }: ActionFunctionArgs) { { status: 200 } ); } catch (error) { - logger.error("Failed to complete waitpoint token", { error }); - throw json({ error: "Failed to complete waitpoint token" }, { status: 500 }); + logger.error("Failed to complete HTTP callback", { error }); + throw json({ error: "Failed to complete HTTP callback" }, { status: 500 }); } } From 17bcce638883440c1e36f0a53473fe34b93b8060 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 10:56:50 +0100 Subject: [PATCH 35/56] Added a hash to the HTTP callback URLs --- .../v3/WaitpointListPresenter.server.ts | 5 ++-- .../v3/WaitpointPresenter.server.ts | 15 +++++----- ...ck.$waitpointFriendlyId.callback.$hash.ts} | 15 +++++++++- .../engine.v1.waitpoints.http-callback.ts | 6 ++-- .../app/services/apiRateLimit.server.ts | 2 +- .../app/services/httpCallback.server.ts | 30 +++++++++++++++++++ 6 files changed, 58 insertions(+), 15 deletions(-) rename apps/webapp/app/routes/{api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts => api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts} (86%) create mode 100644 apps/webapp/app/services/httpCallback.server.ts diff --git a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index b3ecebcf84..0b5add800c 100644 --- a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -12,7 +12,7 @@ import { BasePresenter } from "./basePresenter.server"; import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters"; import { determineEngineVersion } from "~/v3/engineVersion.server"; import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev/core/v3"; -import { generateWaitpointCallbackUrl } from "./WaitpointPresenter.server"; +import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; const DEFAULT_PAGE_SIZE = 25; @@ -24,6 +24,7 @@ export type WaitpointListOptions = { id: string; engine: RunEngineVersion; }; + apiKey: string; }; resolver: WaitpointResolver; // filters @@ -267,7 +268,7 @@ export class WaitpointListPresenter extends BasePresenter { success: true, tokens: tokensToReturn.map((token) => ({ id: token.friendlyId, - callbackUrl: generateWaitpointCallbackUrl(token.id), + callbackUrl: generateHttpCallbackUrl(token.id, environment.apiKey), status: waitpointStatusToApiStatus(token.status, token.outputIsError), completedAt: token.completedAt ?? undefined, timeoutAt: token.completedAfter ?? undefined, diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index 5e86d8b7c3..f5e22211b4 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -5,6 +5,7 @@ import { type RunListItem, RunListPresenter } from "./RunListPresenter.server"; import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { env } from "~/env.server"; +import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; export type WaitpointDetail = NonNullable>>; @@ -24,6 +25,7 @@ export class WaitpointPresenter extends BasePresenter { environmentId, }, select: { + id: true, friendlyId: true, type: true, status: true, @@ -45,6 +47,11 @@ export class WaitpointPresenter extends BasePresenter { take: 5, }, tags: true, + environment: { + select: { + apiKey: true, + }, + }, }, }); @@ -89,7 +96,7 @@ export class WaitpointPresenter extends BasePresenter { resolver: waitpoint.resolver, callbackUrl: waitpoint.resolver === "HTTP_CALLBACK" - ? generateWaitpointCallbackUrl(waitpoint.friendlyId) + ? generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey) : undefined, status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), idempotencyKey: waitpoint.idempotencyKey, @@ -108,9 +115,3 @@ export class WaitpointPresenter extends BasePresenter { }; } } - -export function generateWaitpointCallbackUrl(waitpointId: string) { - return `${ - env.API_ORIGIN ?? env.APP_ORIGIN - }/api/v1/waitpoints/http-callback/${WaitpointId.toFriendlyId(waitpointId)}/callback`; -} diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts similarity index 86% rename from apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts rename to apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts index 2c281ee6a2..5dd0a85f42 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts @@ -8,11 +8,13 @@ import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; +import { verifyHttpCallbackHash } from "~/services/httpCallback.server"; import { logger } from "~/services/logger.server"; import { engine } from "~/v3/runEngine.server"; const paramsSchema = z.object({ waitpointFriendlyId: z.string(), + hash: z.string(), }); export async function action({ request, params }: ActionFunctionArgs) { @@ -25,7 +27,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Request body too large" }, { status: 413 }); } - const { waitpointFriendlyId } = paramsSchema.parse(params); + const { waitpointFriendlyId, hash } = paramsSchema.parse(params); const waitpointId = WaitpointId.toId(waitpointFriendlyId); try { @@ -33,12 +35,23 @@ export async function action({ request, params }: ActionFunctionArgs) { where: { id: waitpointId, }, + include: { + environment: { + select: { + apiKey: true, + }, + }, + }, }); if (!waitpoint) { throw json({ error: "Waitpoint not found" }, { status: 404 }); } + if (!verifyHttpCallbackHash(waitpoint.id, hash, waitpoint.environment.apiKey)) { + throw json({ error: "Invalid URL, hash doesn't match" }, { status: 401 }); + } + if (waitpoint.status === "COMPLETED") { return json({ success: true, diff --git a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts index 14fb6e5f2e..51c522d5b4 100644 --- a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts +++ b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts @@ -4,14 +4,12 @@ import { CreateWaitpointTokenRequestBody, } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; -import { env } from "~/env.server"; import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server"; import { ApiWaitpointListPresenter, ApiWaitpointListSearchParams, } from "~/presenters/v3/ApiWaitpointListPresenter.server"; -import { generateWaitpointCallbackUrl } from "~/presenters/v3/WaitpointPresenter.server"; -import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; import { createActionApiRoute, createLoaderApiRoute, @@ -84,7 +82,7 @@ const { action } = createActionApiRoute( return json( { id: WaitpointId.toFriendlyId(result.waitpoint.id), - url: generateWaitpointCallbackUrl(result.waitpoint.id), + url: generateHttpCallbackUrl(result.waitpoint.id, authentication.environment.apiKey), isCached: result.isCached, }, { status: 200 } diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index f795955247..82427e5631 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -59,7 +59,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/usage/ingest", "/api/v1/auth/jwt/claims", /^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts - /^\/api\/v1\/waitpoints\/http-callback\/[^\/]+\/callback$/, // /api/v1/waitpoints/http-callback/$waitpointFriendlyId/callback + /^\/api\/v1\/waitpoints\/http-callback\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/http-callback/$waitpointFriendlyId/callback/$hash ], log: { rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", diff --git a/apps/webapp/app/services/httpCallback.server.ts b/apps/webapp/app/services/httpCallback.server.ts new file mode 100644 index 0000000000..7362220b55 --- /dev/null +++ b/apps/webapp/app/services/httpCallback.server.ts @@ -0,0 +1,30 @@ +import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import nodeCrypto from "node:crypto"; +import { env } from "~/env.server"; + +export function generateHttpCallbackUrl(waitpointId: string, apiKey: string) { + const hash = generateHttpCallbackHash(waitpointId, apiKey); + + return `${ + env.API_ORIGIN ?? env.APP_ORIGIN + }/api/v1/waitpoints/http-callback/${WaitpointId.toFriendlyId(waitpointId)}/callback/${hash}`; +} + +function generateHttpCallbackHash(waitpointId: string, apiKey: string) { + const hmac = nodeCrypto.createHmac("sha256", apiKey); + hmac.update(waitpointId); + return hmac.digest("hex"); +} + +export function verifyHttpCallbackHash(waitpointId: string, hash: string, apiKey: string) { + const expectedHash = generateHttpCallbackHash(waitpointId, apiKey); + + if ( + hash.length === expectedHash.length && + nodeCrypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex")) + ) { + return true; + } + + return false; +} From 46c34f37846f282964ac20e5791d99b7bde85a64 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 12:03:50 +0100 Subject: [PATCH 36/56] Add the apiKey to the API input type to fix TS error --- .../webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts index aeecb67b8f..943ef9d71c 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts @@ -68,6 +68,7 @@ export class ApiWaitpointListPresenter extends BasePresenter { id: string; engine: RunEngineVersion; }; + apiKey: string; }, resolver: WaitpointResolver, searchParams: ApiWaitpointListSearchParams From 344dfdead41ff2dab2fd4bd064ffa73ff3077c0d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 12:04:21 +0100 Subject: [PATCH 37/56] Return the error responses. They were being caught and not preserved --- ...oints.http-callback.$waitpointFriendlyId.callback.$hash.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts index 5dd0a85f42..ae3a96ab20 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts @@ -45,11 +45,11 @@ export async function action({ request, params }: ActionFunctionArgs) { }); if (!waitpoint) { - throw json({ error: "Waitpoint not found" }, { status: 404 }); + return json({ error: "Waitpoint not found" }, { status: 404 }); } if (!verifyHttpCallbackHash(waitpoint.id, hash, waitpoint.environment.apiKey)) { - throw json({ error: "Invalid URL, hash doesn't match" }, { status: 401 }); + return json({ error: "Invalid URL, hash doesn't match" }, { status: 401 }); } if (waitpoint.status === "COMPLETED") { From fcbc27572531fc17dec5d756f87f07b33501a82f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 12:15:37 +0100 Subject: [PATCH 38/56] The content-length header is required. Deal with an empty body --- ...ack.$waitpointFriendlyId.callback.$hash.ts | 42 ++++--------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts index ae3a96ab20..0dc5c26111 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts @@ -23,7 +23,11 @@ export async function action({ request, params }: ActionFunctionArgs) { } const contentLength = request.headers.get("content-length"); - if (contentLength && parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) { + if (!contentLength) { + return json({ error: "Content-Length header is required" }, { status: 411 }); + } + + if (parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) { return json({ error: "Request body too large" }, { status: 413 }); } @@ -58,16 +62,8 @@ export async function action({ request, params }: ActionFunctionArgs) { }); } - let body; - try { - body = await readJsonWithLimit(request, env.TASK_PAYLOAD_MAXIMUM_SIZE); - } catch (e) { - return json({ error: "Request body too large" }, { status: 413 }); - } - - if (!body) { - body = {}; - } + // If the request body is not valid JSON, return an empty object + const body = await request.json().catch(() => ({})); const stringifiedData = await stringifyIO(body); const finalData = await conditionallyExportPacket( @@ -93,27 +89,3 @@ export async function action({ request, params }: ActionFunctionArgs) { throw json({ error: "Failed to complete HTTP callback" }, { status: 500 }); } } - -async function readJsonWithLimit(request: Request, maxSize: number) { - const reader = request.body?.getReader(); - if (!reader) throw new Error("No body"); - let received = 0; - let chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - received += value.length; - if (received > maxSize) { - throw new Error("Request body too large"); - } - chunks.push(value); - } - const full = new Uint8Array(received); - let offset = 0; - for (const chunk of chunks) { - full.set(chunk, offset); - offset += chunk.length; - } - const text = new TextDecoder().decode(full); - return JSON.parse(text); -} From 4b790bd621242a15317f1c01c18ee375c05cbea4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 15:17:59 +0100 Subject: [PATCH 39/56] Removed unused types --- packages/core/src/v3/types/index.ts | 1 - packages/core/src/v3/types/waitpoints.ts | 14 -------------- 2 files changed, 15 deletions(-) delete mode 100644 packages/core/src/v3/types/waitpoints.ts diff --git a/packages/core/src/v3/types/index.ts b/packages/core/src/v3/types/index.ts index 526943a1c5..55cc4d3a12 100644 --- a/packages/core/src/v3/types/index.ts +++ b/packages/core/src/v3/types/index.ts @@ -7,7 +7,6 @@ export * from "./tasks.js"; export * from "./idempotencyKeys.js"; export * from "./tools.js"; export * from "./queues.js"; -export * from "./waitpoints.js"; type ResolveEnvironmentVariablesOptions = { variables: Record | Array<{ name: string; value: string }>; diff --git a/packages/core/src/v3/types/waitpoints.ts b/packages/core/src/v3/types/waitpoints.ts deleted file mode 100644 index 902cf9da54..0000000000 --- a/packages/core/src/v3/types/waitpoints.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AnySchemaParseFn, inferSchemaIn, inferSchemaOut, Schema } from "./schemas.js"; - -export type HttpCallbackSchema = Schema; -export type HttpCallbackResultTypeFromSchema = - inferSchemaOut; -export type HttpCallbackResult = - | { - ok: true; - output: TResult; - } - | { - ok: false; - error: Error; - }; From c0d01e43c45fad930620d3248d83d548c9667f7b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 15:18:10 +0100 Subject: [PATCH 40/56] Added some new span icons --- apps/webapp/app/components/runs/v3/RunIcon.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 8a1924b3ef..e37d1b6844 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -19,6 +19,8 @@ import { FunctionIcon } from "~/assets/icons/FunctionIcon"; import { TriggerIcon } from "~/assets/icons/TriggerIcon"; import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon"; import { TraceIcon } from "~/assets/icons/TraceIcon"; +import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; type TaskIconProps = { name: string | undefined; @@ -75,6 +77,12 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "python": return ; + case "wait-http-callback": + return ; + case "wait-token": + return ; + case "function": + return ; //log levels case "debug": case "log": From beca6ea7ca182beab628ce4ea58502d4a80fc8ed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 15:18:42 +0100 Subject: [PATCH 41/56] Reworked http callback to be a create call then just use wait.forToken() --- packages/trigger-sdk/src/v3/wait.ts | 264 ++++++++------------ references/hello-world/src/trigger/waits.ts | 40 +-- 2 files changed, 109 insertions(+), 195 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index a3f0d0ba2e..2fa840fb46 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -5,15 +5,14 @@ import { ApiPromise, ApiRequestOptions, CompleteWaitpointTokenResponseBody, + CreateWaitpointHttpCallbackResponseBody, CreateWaitpointTokenRequestBody, CreateWaitpointTokenResponse, CreateWaitpointTokenResponseBody, CursorPagePromise, flattenAttributes, - HttpCallbackResult, ListWaitpointTokensQueryParams, mergeRequestOptions, - Prettify, runtime, SemanticInternalAttributes, taskContext, @@ -82,6 +81,106 @@ function createToken( return apiClient.createWaitpointToken(options ?? {}, $requestOptions); } +/** + * This creates an HTTP callback that allows you to start some work on another API (or one of your own services) + * and continue the run when a callback URL we give you is hit with the result. + * + * You should send the callback URL to the other service, and then that service will + * make a request to the callback URL with the result. + * + * @example + * + * ```ts + * // Create a waitpoint and pass the callback URL to the other service + const { token, data } = await wait.createHttpCallback( + async (url) => { + //pass the provided URL to Replicate's webhook + return replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Any Warhol", + }, + // pass the provided URL to Replicate's webhook, so they can "callback" + webhook: url, + webhook_events_filter: ["completed"], + }); + }, + { + timeout: "10m", + } + ); + + // Now you can wait for the token to complete + // This will pause the run until the token is completed (by the other service calling the callback URL) + const prediction = await wait.forToken(token); + + if (!prediction.ok) { + throw new Error("Failed to create prediction"); + } + + //the value of prediction is the body of the webook that Replicate sent + const result = prediction.output; + * ``` + * + * @param callback A function that gives you a URL you should send to the other service (it will call back with the result) + * @param options - The options for the waitpoint. + * @param requestOptions - The request options for the waitpoint. + * @returns A promise that returns the token and anything you returned from your callback. + */ +async function createHttpCallback( + callback: (url: string) => Promise, + options?: CreateWaitpointTokenRequestBody, + requestOptions?: ApiRequestOptions +): Promise<{ + /** The token that you can use to wait for the callback */ + token: CreateWaitpointHttpCallbackResponseBody; + /** Whatever you returned from the function */ + data: TCallbackResult; +}> { + const apiClient = apiClientManager.clientOrThrow(); + + return tracer.startActiveSpan( + `wait.createHttpCallback()`, + async (span) => { + const waitpoint = await apiClient.createWaitpointHttpCallback(options ?? {}, requestOptions); + + span.setAttribute("id", waitpoint.id); + span.setAttribute("isCached", waitpoint.isCached); + span.setAttribute("url", waitpoint.url); + + const callbackResult = await tracer.startActiveSpan( + `callback()`, + async () => { + return callback(waitpoint.url); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "function", + id: waitpoint.id, + url: waitpoint.url, + isCached: waitpoint.isCached, + }, + } + ); + + return { token: waitpoint, data: callbackResult }; + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "wait-http-callback", + idempotencyKey: options?.idempotencyKey, + idempotencyKeyTTL: options?.idempotencyKeyTTL, + timeout: options?.timeout + ? typeof options.timeout === "string" + ? options.timeout + : options.timeout.toISOString() + : undefined, + tags: options?.tags, + }, + } + ); +} + /** * Lists waitpoint tokens with optional filtering and pagination. * You can iterate over all the items in the result using a for-await-of loop (you don't need to think about pagination). @@ -678,161 +777,7 @@ export const wait = { } }); }, - /** - * This allows you to start some work on another API (or one of your own services) - * and continue the run when a callback URL we give you is hit with the result. - * - * You should send the callback URL to the other service, and then that service will - * make a request to the callback URL with the result. - * - * @example - * - * ```ts - * //wait for the prediction to complete - const prediction = await wait.forHttpCallback( - async (url) => { - //pass the provided URL to Replicate's webhook - await replicate.predictions.create({ - version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", - input: { - prompt: "A painting of a cat by Any Warhol", - }, - // pass the provided URL to Replicate's webhook, so they can "callback" - webhook: url, - webhook_events_filter: ["completed"], - }); - }, - { - timeout: "10m", - } - ); - - if (!prediction.ok) { - throw new Error("Failed to create prediction"); - } - - //the value of prediction is the body of the webook that Replicate sent - const result = prediction.output; - * ``` - * - * @param callback A function that gives you a URL you can use to send the result to. - * @param options - The options for the waitpoint. - * @param requestOptions - The request options for the waitpoint. - * @returns A promise that resolves to the result of the waitpoint. You can use `.unwrap()` to get the result and an error will throw. - */ - forHttpCallback( - callback: (url: string) => Promise, - options?: CreateWaitpointTokenRequestBody & { - /** - * If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. - * - * This is useful if you want to allow other runs to execute while waiting - * - * @default false - */ - releaseConcurrency?: boolean; - }, - requestOptions?: ApiRequestOptions - ): ManualWaitpointPromise { - return new ManualWaitpointPromise(async (resolve, reject) => { - try { - const ctx = taskContext.ctx; - - if (!ctx) { - throw new Error("wait.forHttpCallback can only be used from inside a task.run()"); - } - - const apiClient = apiClientManager.clientOrThrow(); - - const waitpoint = await apiClient.createWaitpointHttpCallback( - options ?? {}, - requestOptions - ); - - const result = await tracer.startActiveSpan( - `wait.forHttpCallback()`, - async (span) => { - const [error] = await tryCatch(callback(waitpoint.url)); - - if (error) { - throw new Error(`You threw an error in your callback: ${error.message}`, { - cause: error, - }); - } - - const response = await apiClient.waitForWaitpointToken({ - runFriendlyId: ctx.run.id, - waitpointFriendlyId: waitpoint.id, - releaseConcurrency: options?.releaseConcurrency, - }); - - if (!response.success) { - throw new Error(`Failed to wait for wait for HTTP callback ${waitpoint.id}`); - } - - const result = await runtime.waitUntil(waitpoint.id); - - const data = result.output - ? await conditionallyImportAndParsePacket( - { data: result.output, dataType: result.outputType ?? "application/json" }, - apiClient - ) - : undefined; - - if (result.ok) { - return { - ok: result.ok, - output: data, - }; - } else { - const error = new WaitpointTimeoutError(data?.message ?? "Timeout error"); - - span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - }); - - return { - ok: result.ok, - error, - }; - } - }, - { - attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "wait", - [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", - [SemanticInternalAttributes.ENTITY_ID]: waitpoint.id, - ...accessoryAttributes({ - items: [ - { - text: waitpoint.id, - variant: "normal", - }, - ], - style: "codepath", - }), - id: waitpoint.id, - isCached: waitpoint.isCached, - idempotencyKey: options?.idempotencyKey, - idempotencyKeyTTL: options?.idempotencyKeyTTL, - timeout: options?.timeout - ? typeof options.timeout === "string" - ? options.timeout - : options.timeout.toISOString() - : undefined, - tags: options?.tags, - url: waitpoint.url, - }, - } - ); - - resolve(result); - } catch (error) { - reject(error); - } - }); - }, + createHttpCallback, }; function nameForWaitOptions(options: WaitForOptions): string { @@ -898,8 +843,3 @@ function calculateDurationInMs(options: WaitForOptions): number { throw new Error("Invalid options"); } - -type RequestOptions = { - to: (url: string) => Promise; - timeout: WaitForOptions; -}; diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 0cdf0f3bd1..673f25ec09 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -152,10 +152,10 @@ export const waitHttpCallback = task({ auth: process.env.REPLICATE_API_KEY, }); - const prediction = await wait.forHttpCallback( + const { token, data } = await wait.createHttpCallback( async (url) => { //pass the provided URL to Replicate's webhook - await replicate.predictions.create({ + return replicate.predictions.create({ version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", input: { prompt: "A painting of a cat by Any Warhol", @@ -167,8 +167,12 @@ export const waitHttpCallback = task({ }, { timeout: "10m", + tags: ["replicate"], } ); + logger.log("Create result", { token, data }); + + const prediction = await wait.forToken(token); if (!prediction.ok) { throw new Error("Failed to create prediction"); @@ -180,39 +184,9 @@ export const waitHttpCallback = task({ logger.log("Image URL", imageUrl); //same again but with unwrapping - const result2 = await wait - .forHttpCallback( - async (url) => { - await replicate.predictions.create({ - version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", - input: { - prompt: "A painting of a cat by Any Warhol", - }, - webhook: url, - }); - }, - { - timeout: "60s", - } - ) - .unwrap(); + const result2 = await wait.forToken(token).unwrap(); logger.log("Result2", { result2 }); } - - const result = await wait.forHttpCallback<{ foo: string }>( - async (url) => { - logger.log(`Wait for HTTP callback ${url}`); - }, - { - timeout: "60s", - } - ); - - if (!result.ok) { - logger.log("Wait for HTTP callback failed", { error: result.error }); - } else { - logger.log("Wait for HTTP callback completed", result); - } }, }); From 4e9c2ba303f4127bce9ff16c894c6c9f7ae43c6a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 15:20:43 +0100 Subject: [PATCH 42/56] Added a changeset --- .changeset/curvy-dogs-share.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curvy-dogs-share.md diff --git a/.changeset/curvy-dogs-share.md b/.changeset/curvy-dogs-share.md new file mode 100644 index 0000000000..8c8b54bcf0 --- /dev/null +++ b/.changeset/curvy-dogs-share.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Added wait.createHttpCallback() to allow offloading work to 3rd party APIs From b87d98f0b6a9ff3be097e456cfd18a95e14a84bf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 15:51:35 +0100 Subject: [PATCH 43/56] Updated the docs --- docs/wait-for-http-callback.mdx | 109 +++++++++++++++----------------- 1 file changed, 50 insertions(+), 59 deletions(-) diff --git a/docs/wait-for-http-callback.mdx b/docs/wait-for-http-callback.mdx index 641c60b32d..81adbbbd28 100644 --- a/docs/wait-for-http-callback.mdx +++ b/docs/wait-for-http-callback.mdx @@ -5,57 +5,62 @@ description: "Pause runs until an HTTP callback is made to the provided URL." import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; -The `wait.forHttpCallback()` function gives you a callback URL, and then pauses the run until that callback is hit with a POST request. This is most commonly used with 3rd party APIs that take a long time and that accept a callback (or webhook) URL. +The `wait.createHttpCallback()` function gives you a callback URL and returns a token representing the waitpoint. You should call a third-party API and provide it with the callback URL so they can notify you when their work is done. -When the callback URL is requested the run will continue where it left off with the body of the request as the output available for you to use. +Then you can use `wait.forToken()` to pause the run until the callback URL is hit with a `POST` request. ## Usage -In this example we create an image using Replicate. Their API accepts a "webhook", which is a callback. +In this example we create an image using Replicate. Their API accepts a “webhook”, which is an HTTP POST callback. ```ts import { logger, task, wait } from "@trigger.dev/sdk"; - -const replicate = new Replicate({ - auth: process.env.REPLICATE_API_KEY, -}); +import Replicate, { Prediction } from "replicate"; export const replicate = task({ id: "replicate", run: async () => { - // This will pause the run and give you a URL - const result = await wait.forHttpCallback( + const replicate = new Replicate({ + auth: process.env.REPLICATE_API_KEY, + }); + + // 1️⃣ Create the callback URL and token + const { token, data } = await wait.createHttpCallback( async (url) => { // 👆 This URL continues your run when hit with a POST request - // Make the call to Replicate, passing in the URL - await replicate.predictions.create({ + // 2️⃣ Kick off the long-running job, passing in the URL + return replicate.predictions.create({ version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", input: { prompt: "A painting of a cat by Any Warhol", }, - // Make sure to pass the callback URL - // 👇 - webhook: url, - // We only want to call when it's completed + webhook: url, // 👈 pass the callback URL webhook_events_filter: ["completed"], }); }, { // We'll fail the waitpoint after 10m of inactivity timeout: "10m", + // Tags can be used to filter waitpoints in the dashboard + tags: ["replicate"], } ); - if (!result.ok) { + logger.log("Create result", { token, data }); + // What you returned from the callback 👆 + + // 3️⃣ Wait for the callback to be received + const prediction = await wait.forToken(token); + if (!prediction.ok) { throw new Error("Failed to create prediction"); } - logger.log("Result", result); + logger.log("Prediction", prediction); - const imageUrl = result.output.output; + const imageUrl = prediction.output.output; logger.log("Image URL", imageUrl); }, }); @@ -63,28 +68,19 @@ export const replicate = task({ ## unwrap() -We provide a handy `.unwrap()` method that will throw an error if the result is not ok. This means your happy path is a lot cleaner. +`wait.forToken()` also supports `.unwrap()` which throws if the waitpoint times out, +keeping the happy path clean: ```ts -const prediction = await wait - .forHttpCallback( - async (url) => { - // ... - }, - { - timeout: "10m", - } - ) - .unwrap(); -// 👆 This will throw an error if the waitpoint times out +const prediction = await wait.forToken(token).unwrap(); -// This is the actual data you sent to the callback now, not a result object +// This is the result data that Replicate sent back (via HTTP POST) logger.log("Prediction", prediction); ``` ### Options -The `wait.forHttpCallback` function accepts an optional second parameter with the following properties: +The `wait.createHttpCallback()` function accepts an optional second parameter with the following properties: The maximum amount of time to wait for the token to be completed. @@ -104,41 +100,36 @@ The `wait.forHttpCallback` function accepts an optional second parameter with th Tags to attach to the token. Tags can be used to filter waitpoints in the dashboard. - - If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. - -This is useful if you want to allow other runs to execute while waiting - -Note: It's possible that this run will not be able to resume when the waitpoint is complete if this is set to true. -It will go back in the queue and will resume once concurrency becomes available. - -The default is `false`. - - - ### returns -The `forHttpCallback` function returns a result object with the following properties: +`wait.createHttpCallback()` returns an object with: - + Whether the token was completed successfully. - - - If `ok` is `true`, this will be the output of the token. + + + The ID of the token. Starts with `waitpoint_`. + + + Whether the token is cached. Will return true if the token was created with an idempotency key and + the same idempotency key was used again. + + + + The URL that was created for the waitpoint. Call this via an HTTP POST request to complete the + waitpoint and continue the run with the JSON body of the request. + + + - - If `ok` is `false`, this will be the error that occurred. The only error that can occur is a - timeout error. + + If you returned anything from the function, it will be here. -### unwrap() returns - -If you use the `unwrap()` method, it will just return the output of the token. If an error occurs it will throw an error. - -## The format of the callback request +## Calling the callback URL yourself -If you want to offload work to one of your own services you will need to do the callback request yourself. +`wait.createHttpCallback()` returns a unique one-time-use URL. -The `wait.forHttpCallback()` function gives you a unique one-time use URL. You should do a `POST` request to this URL with a text body that is JSON parseable. +You should do a `POST` request to this URL with a text body that is JSON-parseable. If there's no body it will use an empty object `{}`. From 2536c10228840346b6a843f6f784c2918aa0ce0e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 15:52:33 +0100 Subject: [PATCH 44/56] Updated the wait overview docs --- docs/wait.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/wait.mdx b/docs/wait.mdx index 9cdf641047..9c3f4c7643 100644 --- a/docs/wait.mdx +++ b/docs/wait.mdx @@ -10,9 +10,9 @@ Waiting allows you to write complex tasks as a set of async code, without having -| Function | What it does | -| :------------------------------------------------ | :------------------------------------------------------------- | -| [wait.for()](/wait-for) | Waits for a specific period of time, e.g. 1 day. | -| [wait.until()](/wait-until) | Waits until the provided `Date`. | -| [wait.forToken()](/wait-for-token) | Pauses runs until a token is completed. | -| [wait.forHttpCallback()](/wait-for-http-callback) | Pause runs until an HTTP callback is made to the provided URL. | +| Function | What it does | +| :--------------------------------------------------- | :------------------------------------------------------------- | +| [wait.for()](/wait-for) | Waits for a specific period of time, e.g. 1 day. | +| [wait.until()](/wait-until) | Waits until the provided `Date`. | +| [wait.forToken()](/wait-for-token) | Pauses runs until a token is completed. | +| [wait.createHttpCallback()](/wait-for-http-callback) | Pause runs until an HTTP callback is made to the provided URL. | From d8c76ee79c0e2c3fd849d2a30d4fd6de003a0824 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:13:28 +0100 Subject: [PATCH 45/56] Simplify to just a call --- packages/trigger-sdk/src/v3/wait.ts | 51 ++++++--------------- references/hello-world/src/trigger/waits.ts | 32 ++++++------- 2 files changed, 29 insertions(+), 54 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 2fa840fb46..b5dcfeda6c 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -127,47 +127,18 @@ function createToken( * @param requestOptions - The request options for the waitpoint. * @returns A promise that returns the token and anything you returned from your callback. */ -async function createHttpCallback( - callback: (url: string) => Promise, +async function createHttpCallback( options?: CreateWaitpointTokenRequestBody, requestOptions?: ApiRequestOptions -): Promise<{ - /** The token that you can use to wait for the callback */ - token: CreateWaitpointHttpCallbackResponseBody; - /** Whatever you returned from the function */ - data: TCallbackResult; -}> { +): Promise { const apiClient = apiClientManager.clientOrThrow(); - return tracer.startActiveSpan( - `wait.createHttpCallback()`, - async (span) => { - const waitpoint = await apiClient.createWaitpointHttpCallback(options ?? {}, requestOptions); - - span.setAttribute("id", waitpoint.id); - span.setAttribute("isCached", waitpoint.isCached); - span.setAttribute("url", waitpoint.url); - - const callbackResult = await tracer.startActiveSpan( - `callback()`, - async () => { - return callback(waitpoint.url); - }, - { - attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "function", - id: waitpoint.id, - url: waitpoint.url, - isCached: waitpoint.isCached, - }, - } - ); - - return { token: waitpoint, data: callbackResult }; - }, + const $requestOptions = mergeRequestOptions( { + tracer, + name: "wait.createHttpCallback()", + icon: "wait-http-callback", attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "wait-http-callback", idempotencyKey: options?.idempotencyKey, idempotencyKeyTTL: options?.idempotencyKeyTTL, timeout: options?.timeout @@ -177,8 +148,16 @@ async function createHttpCallback( : undefined, tags: options?.tags, }, - } + onResponseBody: (body: CreateWaitpointHttpCallbackResponseBody, span) => { + span.setAttribute("id", body.id); + span.setAttribute("isCached", body.isCached); + span.setAttribute("url", body.url); + }, + }, + requestOptions ); + + return apiClient.createWaitpointHttpCallback(options ?? {}, $requestOptions); } /** diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 673f25ec09..708d8598c9 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -152,25 +152,21 @@ export const waitHttpCallback = task({ auth: process.env.REPLICATE_API_KEY, }); - const { token, data } = await wait.createHttpCallback( - async (url) => { - //pass the provided URL to Replicate's webhook - return replicate.predictions.create({ - version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", - input: { - prompt: "A painting of a cat by Any Warhol", - }, - // pass the provided URL to Replicate's webhook, so they can "callback" - webhook: url, - webhook_events_filter: ["completed"], - }); + const token = await wait.createHttpCallback({ + timeout: "10m", + tags: ["replicate"], + }); + logger.log("Create result", { token }); + + const call = await replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Any Warhol", }, - { - timeout: "10m", - tags: ["replicate"], - } - ); - logger.log("Create result", { token, data }); + // pass the provided URL to Replicate's webhook, so they can "callback" + webhook: token.url, + webhook_events_filter: ["completed"], + }); const prediction = await wait.forToken(token); From 9d01c889bb2cafe521a93e080b86b9d1356f8ae9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:31:24 +0100 Subject: [PATCH 46/56] =?UTF-8?q?WIP=20stripping=20right=20back=20to=20wai?= =?UTF-8?q?tpoints=20just=20having=20a=20URL=20associated=20with=20them?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/assets/icons/HttpCallbackIcon.tsx | 22 -- .../app/components/BlankStatePanels.tsx | 28 -- .../app/components/navigation/SideMenu.tsx | 7 - .../webapp/app/components/runs/v3/RunIcon.tsx | 3 - .../components/runs/v3/WaitpointDetails.tsx | 55 +--- .../v3/ApiWaitpointListPresenter.server.ts | 2 - .../v3/WaitpointListPresenter.server.ts | 8 +- .../v3/WaitpointPresenter.server.ts | 7 +- .../route.tsx | 146 ---------- .../route.tsx | 266 ------------------ .../route.tsx | 9 +- ...ns.$waitpointFriendlyId.callback.$hash.ts} | 0 .../app/routes/api.v1.waitpoints.tokens.ts | 5 +- .../engine.v1.waitpoints.http-callback.ts | 102 ------- .../app/services/apiRateLimit.server.ts | 2 +- .../app/services/httpCallback.server.ts | 6 +- apps/webapp/app/utils/pathBuilder.ts | 27 -- docs/wait.mdx | 11 +- .../run-engine/src/engine/index.ts | 3 - .../src/engine/systems/waitpointSystem.ts | 3 - packages/core/src/v3/schemas/api.ts | 1 + packages/trigger-sdk/src/v3/wait.ts | 107 ++----- references/hello-world/src/trigger/waits.ts | 2 +- 23 files changed, 60 insertions(+), 762 deletions(-) delete mode 100644 apps/webapp/app/assets/icons/HttpCallbackIcon.tsx delete mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx delete mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx rename apps/webapp/app/routes/{api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts => api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts} (100%) delete mode 100644 apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts diff --git a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx b/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx deleted file mode 100644 index cae910cb5e..0000000000 --- a/apps/webapp/app/assets/icons/HttpCallbackIcon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export function HttpCallbackIcon({ className }: { className?: string }) { - return ( - - - - - - - - - - ); -} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 8edaa0f369..ea7a20764d 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -36,7 +36,6 @@ import { TextLink } from "./primitives/TextLink"; import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; import { StepContentContainer } from "./StepContentContainer"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; -import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; export function HasNoTasksDev() { return ( @@ -432,33 +431,6 @@ export function NoWaitpointTokens() { ); } -export function NoHttpCallbacks() { - return ( - - Waitpoint docs - - } - > - - HTTP callbacks are used to pause runs until an HTTP request is made to a provided URL. - - - They are useful when using APIs that provide a callback URL. You can send the URL to them - and when they callback your run will continue. - - - ); -} function SwitcherPanel() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4c4d345acd..44fdf70270 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -54,7 +54,6 @@ import { v3SchedulesPath, v3TestPath, v3UsagePath, - v3WaitpointHttpCallbacksPath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; import { useKapaWidget } from "../../hooks/useKapaWidget"; @@ -247,12 +246,6 @@ export function SideMenu({ activeIconColor="text-sky-500" to={v3WaitpointTokensPath(organization, project, environment)} /> - diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index e37d1b6844..fd277997af 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -19,7 +19,6 @@ import { FunctionIcon } from "~/assets/icons/FunctionIcon"; import { TriggerIcon } from "~/assets/icons/TriggerIcon"; import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon"; import { TraceIcon } from "~/assets/icons/TraceIcon"; -import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; type TaskIconProps = { @@ -77,8 +76,6 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "python": return ; - case "wait-http-callback": - return ; case "wait-token": return ; case "function": diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx index ee15c175af..29f11d80e9 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -7,19 +7,11 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { type WaitpointDetail } from "~/presenters/v3/WaitpointPresenter.server"; import { ForceTimeout } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; -import { - v3WaitpointHttpCallbackPath, - v3WaitpointTokenPath, - v3WaitpointTokensPath, -} from "~/utils/pathBuilder"; +import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder"; import { PacketDisplay } from "./PacketDisplay"; import { WaitpointStatusCombo } from "./WaitpointStatus"; import { RunTag } from "./RunTag"; -import { CopyableText } from "~/components/primitives/CopyableText"; import { ClipboardField } from "~/components/primitives/ClipboardField"; -import { WaitpointResolver } from "@trigger.dev/database"; -import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; -import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; export function WaitpointDetailTable({ waitpoint, @@ -48,15 +40,9 @@ export function WaitpointDetailTable({ {linkToList ? ( {waitpoint.id} @@ -66,19 +52,11 @@ export function WaitpointDetailTable({
- Type - - + Callback URL + + - {waitpoint.callbackUrl && ( - - Callback URL - - - - - )} Idempotency key @@ -157,22 +135,3 @@ export function WaitpointDetailTable({ ); } - -export function WaitpointResolverCombo({ resolver }: { resolver: WaitpointResolver }) { - switch (resolver) { - case "TOKEN": - return ( -
- - Token -
- ); - case "HTTP_CALLBACK": - return ( -
- - HTTP Callback -
- ); - } -} diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts index 943ef9d71c..6390f637f3 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts @@ -70,13 +70,11 @@ export class ApiWaitpointListPresenter extends BasePresenter { }; apiKey: string; }, - resolver: WaitpointResolver, searchParams: ApiWaitpointListSearchParams ) { return this.trace("call", async (span) => { const options: WaitpointListOptions = { environment, - resolver, }; if (searchParams["page[size]"]) { diff --git a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index 0b5add800c..13b5f7e787 100644 --- a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -26,7 +26,6 @@ export type WaitpointListOptions = { }; apiKey: string; }; - resolver: WaitpointResolver; // filters id?: string; statuses?: WaitpointTokenStatus[]; @@ -70,7 +69,6 @@ type Result = export class WaitpointListPresenter extends BasePresenter { public async call({ environment, - resolver, id, statuses, idempotencyKey, @@ -170,8 +168,8 @@ export class WaitpointListPresenter extends BasePresenter { ${sqlDatabaseSchema}."Waitpoint" w WHERE w."environmentId" = ${environment.id} - AND w.resolver = ${resolver}::"WaitpointResolver" - -- cursor + AND w.type = 'MANUAL' + -- cursor ${ cursor ? direction === "forward" @@ -255,7 +253,7 @@ export class WaitpointListPresenter extends BasePresenter { const firstToken = await this._replica.waitpoint.findFirst({ where: { environmentId: environment.id, - resolver, + type: "MANUAL", }, }); diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index f5e22211b4..a1a658736e 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -39,7 +39,6 @@ export class WaitpointPresenter extends BasePresenter { completedAfter: true, completedAt: true, createdAt: true, - resolver: true, connectedRuns: { select: { friendlyId: true, @@ -93,11 +92,7 @@ export class WaitpointPresenter extends BasePresenter { return { id: waitpoint.friendlyId, type: waitpoint.type, - resolver: waitpoint.resolver, - callbackUrl: - waitpoint.resolver === "HTTP_CALLBACK" - ? generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey) - : undefined, + callbackUrl: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey), status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), idempotencyKey: waitpoint.idempotencyKey, userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx deleted file mode 100644 index d017262209..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks.$waitpointParam/route.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useLocation } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { z } from "zod"; -import { ExitIcon } from "~/assets/icons/ExitIcon"; -import { LinkButton } from "~/components/primitives/Buttons"; -import { Header2, Header3 } from "~/components/primitives/Headers"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { findProjectBySlug } from "~/models/project.server"; -import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { cn } from "~/utils/cn"; -import { - EnvironmentParamSchema, - v3WaitpointHttpCallbacksPath, - v3WaitpointTokensPath, -} from "~/utils/pathBuilder"; -import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; -import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails"; -import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; -import { InfoIconTooltip } from "~/components/primitives/Tooltip"; -import { logger } from "~/services/logger.server"; - -const Params = EnvironmentParamSchema.extend({ - waitpointParam: z.string(), -}); - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, waitpointParam } = Params.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response(undefined, { - status: 404, - statusText: "Project not found", - }); - } - - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response(undefined, { - status: 404, - statusText: "Environment not found", - }); - } - - try { - const presenter = new WaitpointPresenter(); - const result = await presenter.call({ - friendlyId: waitpointParam, - environmentId: environment.id, - projectId: project.id, - }); - - if (!result) { - throw new Response(undefined, { - status: 404, - statusText: "Waitpoint not found", - }); - } - - return typedjson({ waitpoint: result }); - } catch (error) { - logger.error("Error loading waitpoint for inspector", { - error, - organizationSlug, - projectParam, - envParam, - waitpointParam, - }); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); - } -}; - -export default function Page() { - const { waitpoint } = useTypedLoaderData(); - - const location = useLocation(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - - return ( -
-
- {waitpoint.id} - -
-
-
- -
-
-
- Related runs - -
- -
-
- {waitpoint.status === "WAITING" && ( -
- -
- )} -
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx deleted file mode 100644 index 8cc765bf20..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.http-callbacks/route.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { BookOpenIcon } from "@heroicons/react/20/solid"; -import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { NoHttpCallbacks } from "~/components/BlankStatePanels"; -import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { ListPagination } from "~/components/ListPagination"; -import { LinkButton } from "~/components/primitives/Buttons"; -import { ClipboardField } from "~/components/primitives/ClipboardField"; -import { CopyableText } from "~/components/primitives/CopyableText"; -import { DateTime } from "~/components/primitives/DateTime"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/primitives/Resizable"; -import { - Table, - TableBody, - TableCell, - TableHeader, - TableHeaderCell, - TableRow, -} from "~/components/primitives/Table"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { RunTag } from "~/components/runs/v3/RunTag"; -import { WaitpointStatusCombo } from "~/components/runs/v3/WaitpointStatus"; -import { - WaitpointSearchParamsSchema, - WaitpointTokenFilters, -} from "~/components/runs/v3/WaitpointTokenFilters"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { findProjectBySlug } from "~/models/project.server"; -import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { WaitpointListPresenter } from "~/presenters/v3/WaitpointListPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { - docsPath, - EnvironmentParamSchema, - v3WaitpointHttpCallbackPath, - v3WaitpointTokenPath, -} from "~/utils/pathBuilder"; - -export const meta: MetaFunction = () => { - return [ - { - title: `Waitpoint HTTP callbacks | Trigger.dev`, - }, - ]; -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const url = new URL(request.url); - const s = { - id: url.searchParams.get("id") ?? undefined, - statuses: url.searchParams.getAll("statuses"), - idempotencyKey: url.searchParams.get("idempotencyKey") ?? undefined, - tags: url.searchParams.getAll("tags"), - period: url.searchParams.get("period") ?? undefined, - from: url.searchParams.get("from") ?? undefined, - to: url.searchParams.get("to") ?? undefined, - cursor: url.searchParams.get("cursor") ?? undefined, - direction: url.searchParams.get("direction") ?? undefined, - }; - - const searchParams = WaitpointSearchParamsSchema.parse(s); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response(undefined, { - status: 404, - statusText: "Project not found", - }); - } - - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response(undefined, { - status: 404, - statusText: "Environment not found", - }); - } - - try { - const presenter = new WaitpointListPresenter(); - const result = await presenter.call({ - environment, - resolver: "HTTP_CALLBACK", - ...searchParams, - }); - - return typedjson(result); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); - } -}; - -export default function Page() { - const { success, tokens, pagination, hasFilters, hasAnyTokens, filters } = - useTypedLoaderData(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - - const { waitpointParam } = useParams(); - const isShowingWaitpoint = !!waitpointParam; - - return ( - - - - - - - Waitpoints docs - - - - - {!hasAnyTokens ? ( - - - - ) : ( - - -
-
- -
- -
-
-
- - - - Created - ID - URL - Status - Completed - Idempotency Key - Tags - - - - {tokens.length > 0 ? ( - tokens.map((token) => { - const ttlExpired = - token.idempotencyKeyExpiresAt && - token.idempotencyKeyExpiresAt < new Date(); - - const path = v3WaitpointHttpCallbackPath( - organization, - project, - environment, - token, - filters - ); - const rowIsSelected = waitpointParam === token.id; - - return ( - - - - - - - - - - - - - - - - - {token.completedAt ? : "–"} - - - {token.idempotencyKey ? ( - token.idempotencyKeyExpiresAt ? ( - - - {ttlExpired ? ( - (expired) - ) : null} - - } - buttonClassName={ttlExpired ? "opacity-50" : undefined} - button={token.idempotencyKey} - /> - ) : ( - token.idempotencyKey - ) - ) : ( - "–" - )} - - -
- {token.tags.length > 0 - ? token.tags.map((tag) => ) - : "–"} -
-
-
- ); - }) - ) : ( - - -
- No waitpoint tokens found -
-
-
- )} -
-
- - {(pagination.next || pagination.previous) && ( -
- -
- )} -
-
-
- {isShowingWaitpoint && ( - <> - - - - - - )} -
- )} -
-
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index fbb9a9aca2..09d229ab38 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -7,6 +7,7 @@ import { NoWaitpointTokens } from "~/components/BlankStatePanels"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; @@ -87,7 +88,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const presenter = new WaitpointListPresenter(); const result = await presenter.call({ environment, - resolver: "TOKEN", ...searchParams, }); @@ -144,6 +144,7 @@ export default function Page() { Created ID + Callback URL Status Completed Idempotency Key @@ -179,6 +180,12 @@ export default function Page() { + + + diff --git a/apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts similarity index 100% rename from apps/webapp/app/routes/api.v1.waitpoints.http-callback.$waitpointFriendlyId.callback.$hash.ts rename to apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index 38bb1e6809..4542236d48 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -10,6 +10,7 @@ import { ApiWaitpointListSearchParams, } from "~/presenters/v3/ApiWaitpointListPresenter.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; import { createActionApiRoute, createLoaderApiRoute, @@ -26,7 +27,7 @@ export const loader = createLoaderApiRoute( }, async ({ searchParams, authentication }) => { const presenter = new ApiWaitpointListPresenter(); - const result = await presenter.call(authentication.environment, "TOKEN", searchParams); + const result = await presenter.call(authentication.environment, searchParams); return json(result); } @@ -75,7 +76,6 @@ const { action } = createActionApiRoute( idempotencyKey: body.idempotencyKey, idempotencyKeyExpiresAt, timeout, - resolver: "TOKEN", tags: bodyTags, }); @@ -85,6 +85,7 @@ const { action } = createActionApiRoute( { id: WaitpointId.toFriendlyId(result.waitpoint.id), isCached: result.isCached, + url: generateHttpCallbackUrl(result.waitpoint.id, authentication.environment.apiKey), }, { status: 200, headers: $responseHeaders } ); diff --git a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts b/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts deleted file mode 100644 index 51c522d5b4..0000000000 --- a/apps/webapp/app/routes/engine.v1.waitpoints.http-callback.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { json } from "@remix-run/server-runtime"; -import { - type CreateWaitpointHttpCallbackResponseBody, - CreateWaitpointTokenRequestBody, -} from "@trigger.dev/core/v3"; -import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; -import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server"; -import { - ApiWaitpointListPresenter, - ApiWaitpointListSearchParams, -} from "~/presenters/v3/ApiWaitpointListPresenter.server"; -import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; -import { - createActionApiRoute, - createLoaderApiRoute, -} from "~/services/routeBuilders/apiBuilder.server"; -import { parseDelay } from "~/utils/delays"; -import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; -import { engine } from "~/v3/runEngine.server"; -import { ServiceValidationError } from "~/v3/services/baseService.server"; - -export const loader = createLoaderApiRoute( - { - searchParams: ApiWaitpointListSearchParams, - findResource: async () => 1, // This is a dummy function, we don't need to find a resource - }, - async ({ searchParams, authentication }) => { - const presenter = new ApiWaitpointListPresenter(); - const result = await presenter.call(authentication.environment, "HTTP_CALLBACK", searchParams); - - return json(result); - } -); - -const { action } = createActionApiRoute( - { - body: CreateWaitpointTokenRequestBody, - maxContentLength: 1024 * 10, // 10KB - method: "POST", - }, - async ({ authentication, body }) => { - try { - const idempotencyKeyExpiresAt = body.idempotencyKeyTTL - ? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL) - : undefined; - - const timeout = await parseDelay(body.timeout); - - //upsert tags - let tags: { id: string; name: string }[] = []; - const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags; - - if (bodyTags && bodyTags.length > MAX_TAGS_PER_WAITPOINT) { - throw new ServiceValidationError( - `Waitpoints can only have ${MAX_TAGS_PER_WAITPOINT} tags, you're trying to set ${bodyTags.length}.` - ); - } - - if (bodyTags && bodyTags.length > 0) { - for (const tag of bodyTags) { - const tagRecord = await createWaitpointTag({ - tag, - environmentId: authentication.environment.id, - projectId: authentication.environment.projectId, - }); - if (tagRecord) { - tags.push(tagRecord); - } - } - } - - const result = await engine.createManualWaitpoint({ - environmentId: authentication.environment.id, - projectId: authentication.environment.projectId, - idempotencyKey: body.idempotencyKey, - idempotencyKeyExpiresAt, - timeout, - resolver: "HTTP_CALLBACK", - tags: bodyTags, - }); - - return json( - { - id: WaitpointId.toFriendlyId(result.waitpoint.id), - url: generateHttpCallbackUrl(result.waitpoint.id, authentication.environment.apiKey), - isCached: result.isCached, - }, - { status: 200 } - ); - } catch (error) { - if (error instanceof ServiceValidationError) { - return json({ error: error.message }, { status: 422 }); - } else if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); - } - - return json({ error: "Something went wrong" }, { status: 500 }); - } - } -); - -export { action }; diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 82427e5631..611a19fb3e 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -59,7 +59,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/usage/ingest", "/api/v1/auth/jwt/claims", /^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts - /^\/api\/v1\/waitpoints\/http-callback\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/http-callback/$waitpointFriendlyId/callback/$hash + /^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash ], log: { rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", diff --git a/apps/webapp/app/services/httpCallback.server.ts b/apps/webapp/app/services/httpCallback.server.ts index 7362220b55..b346da0c14 100644 --- a/apps/webapp/app/services/httpCallback.server.ts +++ b/apps/webapp/app/services/httpCallback.server.ts @@ -5,9 +5,9 @@ import { env } from "~/env.server"; export function generateHttpCallbackUrl(waitpointId: string, apiKey: string) { const hash = generateHttpCallbackHash(waitpointId, apiKey); - return `${ - env.API_ORIGIN ?? env.APP_ORIGIN - }/api/v1/waitpoints/http-callback/${WaitpointId.toFriendlyId(waitpointId)}/callback/${hash}`; + return `${env.API_ORIGIN ?? env.APP_ORIGIN}/api/v1/waitpoints/tokens/${WaitpointId.toFriendlyId( + waitpointId + )}/callback/${hash}`; } function generateHttpCallbackHash(waitpointId: string, apiKey: string) { diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index bf8f6bd5fb..6996eff9d7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -342,33 +342,6 @@ export function v3WaitpointTokenPath( return `${v3WaitpointTokensPath(organization, project, environment)}/${token.id}${query}`; } -export function v3WaitpointHttpCallbacksPath( - organization: OrgForPath, - project: ProjectForPath, - environment: EnvironmentForPath, - filters?: WaitpointSearchParams -) { - const searchParams = objectToSearchParams(filters); - const query = searchParams ? `?${searchParams.toString()}` : ""; - return `${v3EnvironmentPath( - organization, - project, - environment - )}/waitpoints/http-callbacks${query}`; -} - -export function v3WaitpointHttpCallbackPath( - organization: OrgForPath, - project: ProjectForPath, - environment: EnvironmentForPath, - token: { id: string }, - filters?: WaitpointSearchParams -) { - const searchParams = objectToSearchParams(filters); - const query = searchParams ? `?${searchParams.toString()}` : ""; - return `${v3WaitpointHttpCallbacksPath(organization, project, environment)}/${token.id}${query}`; -} - export function v3BatchesPath( organization: OrgForPath, project: ProjectForPath, diff --git a/docs/wait.mdx b/docs/wait.mdx index 9c3f4c7643..cfe5b2385b 100644 --- a/docs/wait.mdx +++ b/docs/wait.mdx @@ -10,9 +10,8 @@ Waiting allows you to write complex tasks as a set of async code, without having -| Function | What it does | -| :--------------------------------------------------- | :------------------------------------------------------------- | -| [wait.for()](/wait-for) | Waits for a specific period of time, e.g. 1 day. | -| [wait.until()](/wait-until) | Waits until the provided `Date`. | -| [wait.forToken()](/wait-for-token) | Pauses runs until a token is completed. | -| [wait.createHttpCallback()](/wait-for-http-callback) | Pause runs until an HTTP callback is made to the provided URL. | +| Function | What it does | +| :--------------------------------- | :----------------------------------------------- | +| [wait.for()](/wait-for) | Waits for a specific period of time, e.g. 1 day. | +| [wait.until()](/wait-until) | Waits until the provided `Date`. | +| [wait.forToken()](/wait-for-token) | Pauses runs until a token is completed. | diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 2e8d7fcd9f..f1102a195c 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -841,7 +841,6 @@ export class RunEngine { idempotencyKey, idempotencyKeyExpiresAt, timeout, - resolver, tags, }: { environmentId: string; @@ -849,7 +848,6 @@ export class RunEngine { idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; - resolver: "TOKEN" | "HTTP_CALLBACK"; tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { return this.waitpointSystem.createManualWaitpoint({ @@ -858,7 +856,6 @@ export class RunEngine { idempotencyKey, idempotencyKeyExpiresAt, timeout, - resolver, tags, }); } diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index 96e8f76cf5..d146edb084 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -229,7 +229,6 @@ export class WaitpointSystem { idempotencyKey, idempotencyKeyExpiresAt, timeout, - resolver, tags, }: { environmentId: string; @@ -237,7 +236,6 @@ export class WaitpointSystem { idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; - resolver: "TOKEN" | "HTTP_CALLBACK"; tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { const existingWaitpoint = idempotencyKey @@ -287,7 +285,6 @@ export class WaitpointSystem { create: { ...WaitpointId.generate(), type: "MANUAL", - resolver, idempotencyKey: idempotencyKey ?? nanoid(24), idempotencyKeyExpiresAt, userProvidedIdempotencyKey: !!idempotencyKey, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index af2036f9f5..320603ce6c 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -961,6 +961,7 @@ export type CreateWaitpointTokenRequestBody = z.infer; diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index b5dcfeda6c..9c44075c84 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -31,6 +31,8 @@ import { tracer } from "./tracer.js"; * * @example * + * **Manually completing a token** + * * ```ts * const token = await wait.createToken({ * idempotencyKey: `approve-document-${documentId}`, @@ -45,6 +47,30 @@ import { tracer } from "./tracer.js"; * }); * ``` * + * @example + * + * **Completing a token with a webhook** + * + * ```ts + * const token = await wait.createToken({ + * timeout: "10m", + * tags: ["replicate"], + * }); + * + * // Later, in a different part of your codebase, you can complete the waitpoint + * await replicate.predictions.create({ + * version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + * input: { + * prompt: "A painting of a cat by Any Warhol", + * }, + * // pass the provided URL to Replicate's webhook, so they can "callback" + * webhook: token.url, + * webhook_events_filter: ["completed"], + * }); + * + * const prediction = await wait.forToken(token).unwrap(); + * ``` + * * @param options - The options for the waitpoint token. * @param requestOptions - The request options for the waitpoint token. * @returns The waitpoint token. @@ -73,91 +99,13 @@ function createToken( onResponseBody: (body: CreateWaitpointTokenResponseBody, span) => { span.setAttribute("id", body.id); span.setAttribute("isCached", body.isCached); - }, - }, - requestOptions - ); - - return apiClient.createWaitpointToken(options ?? {}, $requestOptions); -} - -/** - * This creates an HTTP callback that allows you to start some work on another API (or one of your own services) - * and continue the run when a callback URL we give you is hit with the result. - * - * You should send the callback URL to the other service, and then that service will - * make a request to the callback URL with the result. - * - * @example - * - * ```ts - * // Create a waitpoint and pass the callback URL to the other service - const { token, data } = await wait.createHttpCallback( - async (url) => { - //pass the provided URL to Replicate's webhook - return replicate.predictions.create({ - version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", - input: { - prompt: "A painting of a cat by Any Warhol", - }, - // pass the provided URL to Replicate's webhook, so they can "callback" - webhook: url, - webhook_events_filter: ["completed"], - }); - }, - { - timeout: "10m", - } - ); - - // Now you can wait for the token to complete - // This will pause the run until the token is completed (by the other service calling the callback URL) - const prediction = await wait.forToken(token); - - if (!prediction.ok) { - throw new Error("Failed to create prediction"); - } - - //the value of prediction is the body of the webook that Replicate sent - const result = prediction.output; - * ``` - * - * @param callback A function that gives you a URL you should send to the other service (it will call back with the result) - * @param options - The options for the waitpoint. - * @param requestOptions - The request options for the waitpoint. - * @returns A promise that returns the token and anything you returned from your callback. - */ -async function createHttpCallback( - options?: CreateWaitpointTokenRequestBody, - requestOptions?: ApiRequestOptions -): Promise { - const apiClient = apiClientManager.clientOrThrow(); - - const $requestOptions = mergeRequestOptions( - { - tracer, - name: "wait.createHttpCallback()", - icon: "wait-http-callback", - attributes: { - idempotencyKey: options?.idempotencyKey, - idempotencyKeyTTL: options?.idempotencyKeyTTL, - timeout: options?.timeout - ? typeof options.timeout === "string" - ? options.timeout - : options.timeout.toISOString() - : undefined, - tags: options?.tags, - }, - onResponseBody: (body: CreateWaitpointHttpCallbackResponseBody, span) => { - span.setAttribute("id", body.id); - span.setAttribute("isCached", body.isCached); span.setAttribute("url", body.url); }, }, requestOptions ); - return apiClient.createWaitpointHttpCallback(options ?? {}, $requestOptions); + return apiClient.createWaitpointToken(options ?? {}, $requestOptions); } /** @@ -756,7 +704,6 @@ export const wait = { } }); }, - createHttpCallback, }; function nameForWaitOptions(options: WaitForOptions): string { diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 708d8598c9..9a0afa642f 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -152,7 +152,7 @@ export const waitHttpCallback = task({ auth: process.env.REPLICATE_API_KEY, }); - const token = await wait.createHttpCallback({ + const token = await wait.createToken({ timeout: "10m", tags: ["replicate"], }); From 93263d98f842043037070324dcbe00aaebbbb3c9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:34:32 +0100 Subject: [PATCH 47/56] More deletions --- .../migration.sql | 11 ---------- .../migration.sql | 2 -- .../migration.sql | 2 -- .../migration.sql | 2 -- .../migration.sql | 1 - .../database/prisma/schema.prisma | 21 ++++++------------- .../src/engine/tests/checkpoints.test.ts | 5 ----- .../engine/tests/releaseConcurrency.test.ts | 11 ---------- .../src/engine/tests/waitpoints.test.ts | 8 ------- 9 files changed, 6 insertions(+), 57 deletions(-) delete mode 100644 internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql delete mode 100644 internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql delete mode 100644 internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql delete mode 100644 internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql delete mode 100644 internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql b/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql deleted file mode 100644 index 00fb2be12f..0000000000 --- a/internal-packages/database/prisma/migrations/20250502154714_waitpoint_added_resolvers/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- CreateEnum -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'WaitpointResolver') THEN - CREATE TYPE "WaitpointResolver" AS ENUM ('ENGINE', 'TOKEN', 'HTTP_CALLBACK'); - END IF; -END$$; - --- AlterTable -ALTER TABLE "Waitpoint" -ADD COLUMN IF NOT EXISTS "resolver" "WaitpointResolver" NOT NULL DEFAULT 'ENGINE'; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql b/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql deleted file mode 100644 index 28e07d577e..0000000000 --- a/internal-packages/database/prisma/migrations/20250502155009_waitpoint_resolve_index_for_dashboard/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- CreateIndex -CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_id_idx" ON "Waitpoint" ("environmentId", "resolver", "id" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql b/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql deleted file mode 100644 index 80916addcf..0000000000 --- a/internal-packages/database/prisma/migrations/20250505164454_waitpoint_resolve_status_index/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- CreateIndex -CREATE INDEX CONCURRENTLY IF NOT EXISTS "Waitpoint_environmentId_resolver_status_id_idx" ON "Waitpoint" ("environmentId", "resolver", "status", "id" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql b/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql deleted file mode 100644 index ba35249116..0000000000 --- a/internal-packages/database/prisma/migrations/20250505164720_waitpoint_drop_type_status_index/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- DropIndex -DROP INDEX CONCURRENTLY IF EXISTS "Waitpoint_environmentId_type_status_idx"; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql b/internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql deleted file mode 100644 index dda722e507..0000000000 --- a/internal-packages/database/prisma/migrations/20250505165445_waitpoint_drop_type_index/migration.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX CONCURRENTLY IF EXISTS "Waitpoint_environmentId_type_createdAt_idx"; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1fb04aa5b7..c67edbd173 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2100,9 +2100,6 @@ model Waitpoint { type WaitpointType status WaitpointStatus @default(PENDING) - /// This is what will resolve the waitpoint, used to filter waitpoints in the dashboard - resolver WaitpointResolver @default(ENGINE) - completedAt DateTime? /// If it's an Event type waitpoint, this is the event. It can also be provided for the DATETIME type @@ -2153,6 +2150,7 @@ model Waitpoint { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// Denormized column that holds the raw tags /// Denormalized column that holds the raw tags tags String[] @@ -2160,9 +2158,11 @@ model Waitpoint { @@unique([environmentId, idempotencyKey]) /// Quickly find a batch waitpoint @@index([completedByBatchId]) - /// Dashboard filtering - @@index([environmentId, resolver, id(sort: Desc)]) - @@index([environmentId, resolver, status, id(sort: Desc)]) + /// Used on the Waitpoint dashboard pages + /// Time period filtering + @@index([environmentId, type, createdAt(sort: Desc)]) + /// Status filtering + @@index([environmentId, type, status]) } enum WaitpointType { @@ -2177,15 +2177,6 @@ enum WaitpointStatus { COMPLETED } -enum WaitpointResolver { - /// The engine itself will resolve the waitpoint - ENGINE - /// A token will resolve the waitpoint, i.e. the user completes a token - TOKEN - /// A HTTP callback will resolve the waitpoint, i.e. the user uses our waitForHttpCallback function - HTTP_CALLBACK -} - model TaskRunWaitpoint { id String @id @default(cuid()) diff --git a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts index f1d75b2daf..d9fcd5da8c 100644 --- a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts @@ -91,7 +91,6 @@ describe("RunEngine checkpoints", () => { const waitpointResult = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); expect(waitpointResult.waitpoint.status).toBe("PENDING"); @@ -350,7 +349,6 @@ describe("RunEngine checkpoints", () => { const waitpoint1 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); const blocked1 = await engine.blockRunWithWaitpoint({ @@ -401,7 +399,6 @@ describe("RunEngine checkpoints", () => { const waitpoint2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); const blocked2 = await engine.blockRunWithWaitpoint({ @@ -554,7 +551,6 @@ describe("RunEngine checkpoints", () => { const waitpointResult = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); expect(waitpointResult.waitpoint.status).toBe("PENDING"); @@ -848,7 +844,6 @@ describe("RunEngine checkpoints", () => { const waitpoint = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); expect(waitpoint.waitpoint.status).toBe("PENDING"); diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts index 298be0437a..3602810e6a 100644 --- a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts @@ -102,7 +102,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -155,7 +154,6 @@ describe("RunEngine Releasing Concurrency", () => { const result2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ @@ -304,7 +302,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -358,7 +355,6 @@ describe("RunEngine Releasing Concurrency", () => { const result2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ @@ -494,7 +490,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -548,7 +543,6 @@ describe("RunEngine Releasing Concurrency", () => { const result2 = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ @@ -674,7 +668,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); await engine.releaseConcurrencySystem.consumeToken( @@ -837,7 +830,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); await engine.releaseConcurrencySystem.consumeToken( @@ -1020,7 +1012,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); await engine.releaseConcurrencySystem.consumeToken( @@ -1194,7 +1185,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); // Block the run, not specifying any release concurrency option @@ -1349,7 +1339,6 @@ describe("RunEngine Releasing Concurrency", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); // Block the run, specifying the release concurrency option as true diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index c6e4f48b4e..3e4ae20afa 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -345,7 +345,6 @@ describe("RunEngine Waitpoints", () => { const result = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); @@ -486,7 +485,6 @@ describe("RunEngine Waitpoints", () => { projectId: authenticatedEnvironment.projectId, //fail after 200ms timeout: new Date(Date.now() + 200), - resolver: "TOKEN", }); //block the run @@ -609,7 +607,6 @@ describe("RunEngine Waitpoints", () => { engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }) ) ); @@ -754,7 +751,6 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, timeout, - resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.completedAfter).toStrictEqual(timeout); @@ -904,7 +900,6 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, - resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.idempotencyKey).toBe(idempotencyKey); @@ -1057,7 +1052,6 @@ describe("RunEngine Waitpoints", () => { projectId: authenticatedEnvironment.projectId, idempotencyKey, idempotencyKeyExpiresAt: new Date(Date.now() + 200), - resolver: "TOKEN", }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.idempotencyKey).toBe(idempotencyKey); @@ -1068,7 +1062,6 @@ describe("RunEngine Waitpoints", () => { projectId: authenticatedEnvironment.projectId, idempotencyKey, idempotencyKeyExpiresAt: new Date(Date.now() + 200), - resolver: "TOKEN", }); expect(sameWaitpointResult.waitpoint.id).toBe(result.waitpoint.id); @@ -1224,7 +1217,6 @@ describe("RunEngine Waitpoints", () => { const waitpoint = await engine.createManualWaitpoint({ environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, - resolver: "TOKEN", }); expect(waitpoint.waitpoint.status).toBe("PENDING"); From f7af73fb298acc75289cdd6f816f4f10f4bbbbae Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:35:16 +0100 Subject: [PATCH 48/56] Remove missing icon --- apps/webapp/app/components/navigation/SideMenu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 44fdf70270..3efea32733 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -80,7 +80,6 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { HttpCallbackIcon } from "~/assets/icons/HttpCallbackIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< From 27105015813a9840cea76ae00b73a40f2880139e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:36:13 +0100 Subject: [PATCH 49/56] Updated the changeset --- .changeset/curvy-dogs-share.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curvy-dogs-share.md b/.changeset/curvy-dogs-share.md index 8c8b54bcf0..a0071042aa 100644 --- a/.changeset/curvy-dogs-share.md +++ b/.changeset/curvy-dogs-share.md @@ -2,4 +2,4 @@ "@trigger.dev/sdk": patch --- -Added wait.createHttpCallback() to allow offloading work to 3rd party APIs +When you create a Waitpoint token using `wait.createToken()` you get a URL back that can be used to complete it by making an HTTP POST request. From 4b24a47c55520d73769301ab586ee91fd8080b58 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:50:59 +0100 Subject: [PATCH 50/56] Add URL to the token return types --- .../app/components/runs/v3/WaitpointDetails.tsx | 14 ++++++++------ .../presenters/v3/ApiWaitpointPresenter.server.ts | 5 ++++- .../presenters/v3/WaitpointListPresenter.server.ts | 4 ++-- .../app/presenters/v3/WaitpointPresenter.server.ts | 6 ++---- .../route.tsx | 5 +---- packages/core/src/v3/schemas/api.ts | 2 ++ 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx index 29f11d80e9..b5842b5ec0 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -51,12 +51,14 @@ export function WaitpointDetailTable({ )}
- - Callback URL - - - - + {waitpoint.type === "MANUAL" && ( + + Callback URL + + + + + )} Idempotency key diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts index 19ba1dea13..1cec530cf0 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -2,8 +2,8 @@ import { logger, type RuntimeEnvironmentType } from "@trigger.dev/core/v3"; import { type RunEngineVersion } from "@trigger.dev/database"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BasePresenter } from "./basePresenter.server"; -import { WaitpointPresenter } from "./WaitpointPresenter.server"; import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server"; +import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; export class ApiWaitpointPresenter extends BasePresenter { public async call( @@ -14,6 +14,7 @@ export class ApiWaitpointPresenter extends BasePresenter { id: string; engine: RunEngineVersion; }; + apiKey: string; }, waitpointId: string ) { @@ -24,6 +25,7 @@ export class ApiWaitpointPresenter extends BasePresenter { environmentId: environment.id, }, select: { + id: true, friendlyId: true, type: true, status: true, @@ -62,6 +64,7 @@ export class ApiWaitpointPresenter extends BasePresenter { return { id: waitpoint.friendlyId, type: waitpoint.type, + url: generateHttpCallbackUrl(waitpoint.id, environment.apiKey), status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), idempotencyKey: waitpoint.idempotencyKey, userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, diff --git a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index 13b5f7e787..018c83f6ca 100644 --- a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -43,7 +43,7 @@ export type WaitpointListOptions = { type Result = | { success: true; - tokens: (WaitpointTokenItem & { callbackUrl: string })[]; + tokens: WaitpointTokenItem[]; pagination: { next: string | undefined; previous: string | undefined; @@ -266,7 +266,7 @@ export class WaitpointListPresenter extends BasePresenter { success: true, tokens: tokensToReturn.map((token) => ({ id: token.friendlyId, - callbackUrl: generateHttpCallbackUrl(token.id, environment.apiKey), + url: generateHttpCallbackUrl(token.id, environment.apiKey), status: waitpointStatusToApiStatus(token.status, token.outputIsError), completedAt: token.completedAt ?? undefined, timeoutAt: token.completedAfter ?? undefined, diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index a1a658736e..0262dcea84 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -1,11 +1,9 @@ import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3"; +import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; import { logger } from "~/services/logger.server"; import { BasePresenter } from "./basePresenter.server"; import { type RunListItem, RunListPresenter } from "./RunListPresenter.server"; import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server"; -import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; -import { env } from "~/env.server"; -import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; export type WaitpointDetail = NonNullable>>; @@ -92,7 +90,7 @@ export class WaitpointPresenter extends BasePresenter { return { id: waitpoint.friendlyId, type: waitpoint.type, - callbackUrl: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey), + url: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey), status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), idempotencyKey: waitpoint.idempotencyKey, userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index 09d229ab38..7e32f08244 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -181,10 +181,7 @@ export default function Page() { - + diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 320603ce6c..0e4e450aeb 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -971,6 +971,8 @@ export type WaitpointTokenStatus = z.infer; export const WaitpointTokenItem = z.object({ id: z.string(), + /** If you make a POST request to this URL, it will complete the waitpoint. */ + url: z.string(), status: WaitpointTokenStatus, completedAt: z.coerce.date().optional(), completedAfter: z.coerce.date().optional(), From 776b7eadfd455a90d939c8ecc8365226a60e0413 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:51:45 +0100 Subject: [PATCH 51/56] Remove wait for http callback page --- docs/docs.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index d6866560a4..cf7d0a3949 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -47,13 +47,7 @@ "errors-retrying", { "group": "Wait", - "pages": [ - "wait", - "wait-for", - "wait-until", - "wait-for-token", - "wait-for-http-callback" - ] + "pages": ["wait", "wait-for", "wait-until", "wait-for-token"] }, "queue-concurrency", "versioning", From 474a33332ffaea120a9b424f7766066300f2c7f8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 16:59:10 +0100 Subject: [PATCH 52/56] Updated docs --- docs/wait-for-http-callback.mdx | 135 -------------------------------- docs/wait-for-token.mdx | 46 +++++++++++ 2 files changed, 46 insertions(+), 135 deletions(-) delete mode 100644 docs/wait-for-http-callback.mdx diff --git a/docs/wait-for-http-callback.mdx b/docs/wait-for-http-callback.mdx deleted file mode 100644 index 81adbbbd28..0000000000 --- a/docs/wait-for-http-callback.mdx +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: "Wait for HTTP callback" -description: "Pause runs until an HTTP callback is made to the provided URL." ---- - -import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; - -The `wait.createHttpCallback()` function gives you a callback URL and returns a token representing the waitpoint. You should call a third-party API and provide it with the callback URL so they can notify you when their work is done. - -Then you can use `wait.forToken()` to pause the run until the callback URL is hit with a `POST` request. - - - -## Usage - -In this example we create an image using Replicate. Their API accepts a “webhook”, which is an HTTP POST callback. - -```ts -import { logger, task, wait } from "@trigger.dev/sdk"; -import Replicate, { Prediction } from "replicate"; - -export const replicate = task({ - id: "replicate", - run: async () => { - const replicate = new Replicate({ - auth: process.env.REPLICATE_API_KEY, - }); - - // 1️⃣ Create the callback URL and token - const { token, data } = await wait.createHttpCallback( - async (url) => { - // 👆 This URL continues your run when hit with a POST request - - // 2️⃣ Kick off the long-running job, passing in the URL - return replicate.predictions.create({ - version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", - input: { - prompt: "A painting of a cat by Any Warhol", - }, - webhook: url, // 👈 pass the callback URL - webhook_events_filter: ["completed"], - }); - }, - { - // We'll fail the waitpoint after 10m of inactivity - timeout: "10m", - // Tags can be used to filter waitpoints in the dashboard - tags: ["replicate"], - } - ); - - logger.log("Create result", { token, data }); - // What you returned from the callback 👆 - - // 3️⃣ Wait for the callback to be received - const prediction = await wait.forToken(token); - if (!prediction.ok) { - throw new Error("Failed to create prediction"); - } - - logger.log("Prediction", prediction); - - const imageUrl = prediction.output.output; - logger.log("Image URL", imageUrl); - }, -}); -``` - -## unwrap() - -`wait.forToken()` also supports `.unwrap()` which throws if the waitpoint times out, -keeping the happy path clean: - -```ts -const prediction = await wait.forToken(token).unwrap(); - -// This is the result data that Replicate sent back (via HTTP POST) -logger.log("Prediction", prediction); -``` - -### Options - -The `wait.createHttpCallback()` function accepts an optional second parameter with the following properties: - - - The maximum amount of time to wait for the token to be completed. - - - - An idempotency key for the token. If provided, the token will be completed with the same output if - the same idempotency key is used again. - - - - The time to live for the idempotency key. After this time, the idempotency key will be ignored and - can be reused to create a new waitpoint. - - - - Tags to attach to the token. Tags can be used to filter waitpoints in the dashboard. - - -### returns - -`wait.createHttpCallback()` returns an object with: - - - Whether the token was completed successfully. - - - - The ID of the token. Starts with `waitpoint_`. - - - Whether the token is cached. Will return true if the token was created with an idempotency key and - the same idempotency key was used again. - - - - The URL that was created for the waitpoint. Call this via an HTTP POST request to complete the - waitpoint and continue the run with the JSON body of the request. - - - - - - - If you returned anything from the function, it will be here. - - -## Calling the callback URL yourself - -`wait.createHttpCallback()` returns a unique one-time-use URL. - -You should do a `POST` request to this URL with a text body that is JSON-parseable. If there's no body it will use an empty object `{}`. diff --git a/docs/wait-for-token.mdx b/docs/wait-for-token.mdx index a786bd48f8..9bda1edfea 100644 --- a/docs/wait-for-token.mdx +++ b/docs/wait-for-token.mdx @@ -7,6 +7,8 @@ import UpgradeToV4Note from "/snippets/upgrade-to-v4-note.mdx"; Waitpoint tokens pause task runs until you complete the token. They're commonly used for approval workflows and other scenarios where you need to wait for external confirmation, such as human-in-the-loop processes. +You can complete a token using the SDK or by making a POST request to the token's URL. + ## Usage @@ -52,6 +54,29 @@ await wait.completeToken(tokenId, { }); ``` +Or you can make an HTTP POST request to the `url` it returns: + +```ts +import { wait } from "@trigger.dev/sdk"; + +const token = await wait.createToken({ + timeout: "10m", +}); + +const call = await replicate.predictions.create({ + version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", + input: { + prompt: "A painting of a cat by Andy Warhol", + }, + // pass the provided URL to Replicate's webhook, so they can "callback" + webhook: token.url, + webhook_events_filter: ["completed"], +}); + +const prediction = await wait.forToken(token).unwrap(); +// unwrap() throws a timeout error or returns the result 👆 +``` + ## wait.createToken Create a waitpoint token. @@ -85,6 +110,13 @@ The `createToken` function returns a token object with the following properties: The ID of the token. Starts with `waitpoint_`. + + The URL of the token. This is the URL you can make a POST request to in order to complete the token. + +The JSON body of the POST request will be used as the output of the token. If there's no body the output will be an empty object `{}`. + + + Whether the token is cached. Will return true if the token was created with an idempotency key and the same idempotency key was used again. @@ -338,6 +370,13 @@ Each token is an object with the following properties: The ID of the token. + + The URL of the token. This is the URL you can make a POST request to in order to complete the token. + +The JSON body of the POST request will be used as the output of the token. If there's no body the output will be an empty object `{}`. + + + The status of the token. @@ -404,6 +443,13 @@ The `retrieveToken` function returns a token object with the following propertie The ID of the token. + + The URL of the token. This is the URL you can make a POST request to in order to complete the token. + +The JSON body of the POST request will be used as the output of the token. If there's no body the output will be an empty object `{}`. + + + The status of the token. From e2b39cbae15d9226093e8e7962bebc1f3c30d8f6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 17:03:49 +0100 Subject: [PATCH 53/56] More tidying --- packages/core/src/v3/schemas/api.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 0e4e450aeb..bc73ce534b 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1013,15 +1013,6 @@ export const WaitForWaitpointTokenResponseBody = z.object({ }); export type WaitForWaitpointTokenResponseBody = z.infer; -export const CreateWaitpointHttpCallbackResponseBody = z.object({ - id: z.string(), - url: z.string(), - isCached: z.boolean(), -}); -export type CreateWaitpointHttpCallbackResponseBody = z.infer< - typeof CreateWaitpointHttpCallbackResponseBody ->; - export const WaitForDurationRequestBody = z.object({ /** * An optional idempotency key for the waitpoint. From a72514066030ba1475b8598dafe53401aad44328 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 17:06:02 +0100 Subject: [PATCH 54/56] Type and import fix --- packages/trigger-sdk/src/v3/wait.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 9c44075c84..fc1b5e927e 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -5,7 +5,6 @@ import { ApiPromise, ApiRequestOptions, CompleteWaitpointTokenResponseBody, - CreateWaitpointHttpCallbackResponseBody, CreateWaitpointTokenRequestBody, CreateWaitpointTokenResponse, CreateWaitpointTokenResponseBody, @@ -61,7 +60,7 @@ import { tracer } from "./tracer.js"; * await replicate.predictions.create({ * version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478", * input: { - * prompt: "A painting of a cat by Any Warhol", + * prompt: "A painting of a cat by Andy Warhol", * }, * // pass the provided URL to Replicate's webhook, so they can "callback" * webhook: token.url, From 0a97f3a32862b42942bce1e4205765bcab89d6fc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 17:15:04 +0100 Subject: [PATCH 55/56] Remove unused import --- packages/trigger-sdk/src/v3/wait.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index fc1b5e927e..9b43f82146 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -15,7 +15,6 @@ import { runtime, SemanticInternalAttributes, taskContext, - tryCatch, WaitpointListTokenItem, WaitpointRetrieveTokenResponse, WaitpointTokenStatus, From d4b71351e69750f908dd169ed7ab261fe27143d8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 May 2025 17:46:54 +0100 Subject: [PATCH 56/56] Some type fixes for the retrieve --- packages/core/src/v3/apiClient/index.ts | 27 +++---------------------- packages/trigger-sdk/src/v3/wait.ts | 4 ++++ 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index f4ad3d6ebb..0bac97c8e5 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -12,14 +12,11 @@ import { CreateEnvironmentVariableRequestBody, CreateScheduleOptions, CreateUploadPayloadUrlResponseBody, - CreateWaitpointHttpCallbackResponseBody, CreateWaitpointTokenRequestBody, CreateWaitpointTokenResponseBody, DeletedScheduleObject, EnvironmentVariableResponseBody, - EnvironmentVariableValue, EnvironmentVariableWithSecret, - EnvironmentVariables, ListQueueOptions, ListRunResponseItem, ListScheduleOptions, @@ -43,8 +40,10 @@ import { WaitpointRetrieveTokenResponse, WaitpointTokenItem, } from "../schemas/index.js"; +import { AsyncIterableStream } from "../streams/asyncIterableStream.js"; import { taskContext } from "../task-context-api.js"; import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js"; +import { Prettify } from "../types/utils.js"; import { AnyZodFetchOptions, ApiPromise, @@ -64,9 +63,9 @@ import { RunShape, RunStreamCallback, RunSubscription, + SSEStreamSubscriptionFactory, TaskRunShape, runShapeStream, - SSEStreamSubscriptionFactory, } from "./runStream.js"; import { CreateEnvironmentVariableParams, @@ -77,8 +76,6 @@ import { SubscribeToRunsQueryParams, UpdateEnvironmentVariableParams, } from "./types.js"; -import { AsyncIterableStream } from "../streams/asyncIterableStream.js"; -import { Prettify } from "../types/utils.js"; export type CreateWaitpointTokenResponse = Prettify< CreateWaitpointTokenResponseBody & { @@ -791,24 +788,6 @@ export class ApiClient { ); } - createWaitpointHttpCallback( - options: CreateWaitpointTokenRequestBody, - requestOptions?: ZodFetchOptions - ) { - return zodfetch( - CreateWaitpointHttpCallbackResponseBody, - `${this.baseUrl}/engine/v1/waitpoints/http-callback`, - { - method: "POST", - headers: this.#getHeaders(false), - body: JSON.stringify(options), - }, - { - ...mergeRequestOptions(this.defaultRequestOptions, requestOptions), - } - ) as ApiPromise; - } - async waitForDuration( runId: string, body: WaitForDurationRequestBody, diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 9b43f82146..1c606ea79c 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -176,6 +176,8 @@ function listTokens( */ export type WaitpointRetrievedToken = { id: string; + /** A URL that you can make a POST request to in order to complete the waitpoint. */ + url: string; status: WaitpointTokenStatus; completedAt?: Date; timeoutAt?: Date; @@ -229,6 +231,7 @@ async function retrieveToken( }, onResponseBody: (body: WaitpointRetrieveTokenResponse, span) => { span.setAttribute("id", body.id); + span.setAttribute("url", body.url); span.setAttribute("status", body.status); if (body.completedAt) { span.setAttribute("completedAt", body.completedAt.toISOString()); @@ -269,6 +272,7 @@ async function retrieveToken( return { id: result.id, + url: result.url, status: result.status, completedAt: result.completedAt, timeoutAt: result.timeoutAt,