Skip to content

Commit d23fa38

Browse files
Waitpoint token callback URLs (#2025)
* Initial commit with a plan for what we’re going to do * Some initial types and improved plan * Add Waitpoint resolver * Add resolver + status index * Remove type + status index * Only drop if exists * Remove type index * Update waitpoint list presenter to use resolver * Added resolver to the engine * Made the existing waitpoint list presenter more flexible * Initial implentation ofr wait.forHttpCallback() * Added the callback endpoint (no API rate limit) * schema version * Added jsdocs, removed schema version because of errors * Show callback URL if it’s set * Dashboard pages and panels * Remove todos * Added temporary icon * Added a blank state * Some tweaks and added a Replicate example * Implement unwrap() for httpCallback * Added unwrap to wait.forToken() as well * Improved jsdocs * Added docs * Added unwrap to the token docs * Show a dash if there are no tags Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Make the timeout error safer * Fixed migrations… should use id desc not createdAt desc * Fixed page title * Fixed migration so it only adds them if they don’t exist. This allows us to manuall run in cloud first * Respect the max content length by getting the length of the body * Added more docs details about the callback format * Remove code comment * Improved the error * Added a hash to the HTTP callback URLs * Add the apiKey to the API input type to fix TS error * Return the error responses. They were being caught and not preserved * The content-length header is required. Deal with an empty body * Removed unused types * Added some new span icons * Reworked http callback to be a create call then just use wait.forToken() * Added a changeset * Updated the docs * Updated the wait overview docs * Simplify to just a call * WIP stripping right back to waitpoints just having a URL associated with them… * More deletions * Remove missing icon * Updated the changeset * Add URL to the token return types * Remove wait for http callback page * Updated docs * More tidying * Type and import fix * Remove unused import * Some type fixes for the retrieve --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 4ec0395 commit d23fa38

File tree

21 files changed

+494
-131
lines changed

21 files changed

+494
-131
lines changed

.changeset/curvy-dogs-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
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.

apps/webapp/app/components/runs/v3/RunIcon.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { FunctionIcon } from "~/assets/icons/FunctionIcon";
1919
import { TriggerIcon } from "~/assets/icons/TriggerIcon";
2020
import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
2121
import { TraceIcon } from "~/assets/icons/TraceIcon";
22+
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
2223

2324
type TaskIconProps = {
2425
name: string | undefined;
@@ -75,6 +76,10 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
7576
return <TriggerIcon className={cn(className, "text-orange-500")} />;
7677
case "python":
7778
return <PythonLogoIcon className={className} />;
79+
case "wait-token":
80+
return <WaitpointTokenIcon className={cn(className, "text-sky-500")} />;
81+
case "function":
82+
return <FunctionIcon className={cn(className, "text-text-dimmed")} />;
7883
//log levels
7984
case "debug":
8085
case "log":

apps/webapp/app/components/runs/v3/WaitpointDetails.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder
1111
import { PacketDisplay } from "./PacketDisplay";
1212
import { WaitpointStatusCombo } from "./WaitpointStatus";
1313
import { RunTag } from "./RunTag";
14+
import { ClipboardField } from "~/components/primitives/ClipboardField";
1415

1516
export function WaitpointDetailTable({
1617
waitpoint,
@@ -50,6 +51,14 @@ export function WaitpointDetailTable({
5051
)}
5152
</Property.Value>
5253
</Property.Item>
54+
{waitpoint.type === "MANUAL" && (
55+
<Property.Item>
56+
<Property.Label>Callback URL</Property.Label>
57+
<Property.Value className="my-1">
58+
<ClipboardField value={waitpoint.url} variant={"secondary/small"} />
59+
</Property.Value>
60+
</Property.Item>
61+
)}
5362
<Property.Item>
5463
<Property.Label>Idempotency key</Property.Label>
5564
<Property.Value>

apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts renamed to apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import { RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3";
1+
import { type RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3";
2+
import { type RunEngineVersion, type WaitpointResolver } from "@trigger.dev/database";
23
import { z } from "zod";
3-
import { BasePresenter } from "./basePresenter.server";
44
import { CoercedDate } from "~/utils/zod";
5-
import { AuthenticatedEnvironment } from "@internal/run-engine";
6-
import {
7-
WaitpointTokenListOptions,
8-
WaitpointTokenListPresenter,
9-
} from "./WaitpointTokenListPresenter.server";
105
import { ServiceValidationError } from "~/v3/services/baseService.server";
11-
import { RunEngineVersion } from "@trigger.dev/database";
6+
import { BasePresenter } from "./basePresenter.server";
7+
import { type WaitpointListOptions, WaitpointListPresenter } from "./WaitpointListPresenter.server";
128

13-
export const ApiWaitpointTokenListSearchParams = z.object({
9+
export const ApiWaitpointListSearchParams = z.object({
1410
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
1511
"page[after]": z.string().optional(),
1612
"page[before]": z.string().optional(),
@@ -61,9 +57,9 @@ export const ApiWaitpointTokenListSearchParams = z.object({
6157
"filter[createdAt][to]": CoercedDate,
6258
});
6359

64-
type ApiWaitpointTokenListSearchParams = z.infer<typeof ApiWaitpointTokenListSearchParams>;
60+
type ApiWaitpointListSearchParams = z.infer<typeof ApiWaitpointListSearchParams>;
6561

66-
export class ApiWaitpointTokenListPresenter extends BasePresenter {
62+
export class ApiWaitpointListPresenter extends BasePresenter {
6763
public async call(
6864
environment: {
6965
id: string;
@@ -72,11 +68,12 @@ export class ApiWaitpointTokenListPresenter extends BasePresenter {
7268
id: string;
7369
engine: RunEngineVersion;
7470
};
71+
apiKey: string;
7572
},
76-
searchParams: ApiWaitpointTokenListSearchParams
73+
searchParams: ApiWaitpointListSearchParams
7774
) {
7875
return this.trace("call", async (span) => {
79-
const options: WaitpointTokenListOptions = {
76+
const options: WaitpointListOptions = {
8077
environment,
8178
};
8279

@@ -118,7 +115,7 @@ export class ApiWaitpointTokenListPresenter extends BasePresenter {
118115
options.to = searchParams["filter[createdAt][to]"].getTime();
119116
}
120117

121-
const presenter = new WaitpointTokenListPresenter();
118+
const presenter = new WaitpointListPresenter();
122119
const result = await presenter.call(options);
123120

124121
if (!result.success) {

apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { logger, type RuntimeEnvironmentType } from "@trigger.dev/core/v3";
22
import { type RunEngineVersion } from "@trigger.dev/database";
33
import { ServiceValidationError } from "~/v3/services/baseService.server";
44
import { BasePresenter } from "./basePresenter.server";
5-
import { WaitpointPresenter } from "./WaitpointPresenter.server";
6-
import { waitpointStatusToApiStatus } from "./WaitpointTokenListPresenter.server";
5+
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
6+
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
77

88
export class ApiWaitpointPresenter extends BasePresenter {
99
public async call(
@@ -14,6 +14,7 @@ export class ApiWaitpointPresenter extends BasePresenter {
1414
id: string;
1515
engine: RunEngineVersion;
1616
};
17+
apiKey: string;
1718
},
1819
waitpointId: string
1920
) {
@@ -24,6 +25,7 @@ export class ApiWaitpointPresenter extends BasePresenter {
2425
environmentId: environment.id,
2526
},
2627
select: {
28+
id: true,
2729
friendlyId: true,
2830
type: true,
2931
status: true,
@@ -62,6 +64,7 @@ export class ApiWaitpointPresenter extends BasePresenter {
6264
return {
6365
id: waitpoint.friendlyId,
6466
type: waitpoint.type,
67+
url: generateHttpCallbackUrl(waitpoint.id, environment.apiKey),
6568
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
6669
idempotencyKey: waitpoint.idempotencyKey,
6770
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,

apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts renamed to apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import parse from "parse-duration";
22
import {
33
Prisma,
4+
type WaitpointResolver,
45
type RunEngineVersion,
56
type RuntimeEnvironmentType,
67
type WaitpointStatus,
@@ -11,17 +12,19 @@ import { BasePresenter } from "./basePresenter.server";
1112
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
1213
import { determineEngineVersion } from "~/v3/engineVersion.server";
1314
import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev/core/v3";
15+
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
1416

1517
const DEFAULT_PAGE_SIZE = 25;
1618

17-
export type WaitpointTokenListOptions = {
19+
export type WaitpointListOptions = {
1820
environment: {
1921
id: string;
2022
type: RuntimeEnvironmentType;
2123
project: {
2224
id: string;
2325
engine: RunEngineVersion;
2426
};
27+
apiKey: string;
2528
};
2629
// filters
2730
id?: string;
@@ -63,7 +66,7 @@ type Result =
6366
filters: undefined;
6467
};
6568

66-
export class WaitpointTokenListPresenter extends BasePresenter {
69+
export class WaitpointListPresenter extends BasePresenter {
6770
public async call({
6871
environment,
6972
id,
@@ -76,7 +79,7 @@ export class WaitpointTokenListPresenter extends BasePresenter {
7679
direction = "forward",
7780
cursor,
7881
pageSize = DEFAULT_PAGE_SIZE,
79-
}: WaitpointTokenListOptions): Promise<Result> {
82+
}: WaitpointListOptions): Promise<Result> {
8083
const engineVersion = await determineEngineVersion({ environment });
8184
if (engineVersion === "V1") {
8285
return {
@@ -165,8 +168,8 @@ export class WaitpointTokenListPresenter extends BasePresenter {
165168
${sqlDatabaseSchema}."Waitpoint" w
166169
WHERE
167170
w."environmentId" = ${environment.id}
168-
AND w.type = 'MANUAL'
169-
-- cursor
171+
AND w.type = 'MANUAL'
172+
-- cursor
170173
${
171174
cursor
172175
? direction === "forward"
@@ -263,6 +266,7 @@ export class WaitpointTokenListPresenter extends BasePresenter {
263266
success: true,
264267
tokens: tokensToReturn.map((token) => ({
265268
id: token.friendlyId,
269+
url: generateHttpCallbackUrl(token.id, environment.apiKey),
266270
status: waitpointStatusToApiStatus(token.status, token.outputIsError),
267271
completedAt: token.completedAt ?? undefined,
268272
timeoutAt: token.completedAfter ?? undefined,

apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
2+
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
23
import { logger } from "~/services/logger.server";
34
import { BasePresenter } from "./basePresenter.server";
45
import { type RunListItem, RunListPresenter } from "./RunListPresenter.server";
5-
import { waitpointStatusToApiStatus } from "./WaitpointTokenListPresenter.server";
6+
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
67

78
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
89

@@ -22,6 +23,7 @@ export class WaitpointPresenter extends BasePresenter {
2223
environmentId,
2324
},
2425
select: {
26+
id: true,
2527
friendlyId: true,
2628
type: true,
2729
status: true,
@@ -42,6 +44,11 @@ export class WaitpointPresenter extends BasePresenter {
4244
take: 5,
4345
},
4446
tags: true,
47+
environment: {
48+
select: {
49+
apiKey: true,
50+
},
51+
},
4552
},
4653
});
4754

@@ -83,6 +90,7 @@ export class WaitpointPresenter extends BasePresenter {
8390
return {
8491
id: waitpoint.friendlyId,
8592
type: waitpoint.type,
93+
url: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey),
8694
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
8795
idempotencyKey: waitpoint.idempotencyKey,
8896
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { NoWaitpointTokens } from "~/components/BlankStatePanels";
77
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
88
import { ListPagination } from "~/components/ListPagination";
99
import { LinkButton } from "~/components/primitives/Buttons";
10+
import { ClipboardField } from "~/components/primitives/ClipboardField";
1011
import { CopyableText } from "~/components/primitives/CopyableText";
1112
import { DateTime } from "~/components/primitives/DateTime";
1213
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
@@ -36,7 +37,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
3637
import { useProject } from "~/hooks/useProject";
3738
import { findProjectBySlug } from "~/models/project.server";
3839
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
39-
import { WaitpointTokenListPresenter } from "~/presenters/v3/WaitpointTokenListPresenter.server";
40+
import { WaitpointListPresenter } from "~/presenters/v3/WaitpointListPresenter.server";
4041
import { requireUserId } from "~/services/session.server";
4142
import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder";
4243

@@ -84,7 +85,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8485
}
8586

8687
try {
87-
const presenter = new WaitpointTokenListPresenter();
88+
const presenter = new WaitpointListPresenter();
8889
const result = await presenter.call({
8990
environment,
9091
...searchParams,
@@ -143,6 +144,7 @@ export default function Page() {
143144
<TableRow>
144145
<TableHeaderCell className="w-[1%]">Created</TableHeaderCell>
145146
<TableHeaderCell className="w-[20%]">ID</TableHeaderCell>
147+
<TableHeaderCell className="w-[20%]">Callback URL</TableHeaderCell>
146148
<TableHeaderCell className="w-[20%]">Status</TableHeaderCell>
147149
<TableHeaderCell className="w-[20%]">Completed</TableHeaderCell>
148150
<TableHeaderCell className="w-[20%]">Idempotency Key</TableHeaderCell>
@@ -178,6 +180,9 @@ export default function Page() {
178180
<TableCell to={path}>
179181
<CopyableText value={token.id} className="font-mono" />
180182
</TableCell>
183+
<TableCell to={path}>
184+
<ClipboardField value={token.url} variant={"secondary/small"} />
185+
</TableCell>
181186
<TableCell to={path}>
182187
<WaitpointStatusCombo status={token.status} className="text-xs" />
183188
</TableCell>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import {
3+
type CompleteWaitpointTokenResponseBody,
4+
conditionallyExportPacket,
5+
stringifyIO,
6+
} from "@trigger.dev/core/v3";
7+
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
8+
import { z } from "zod";
9+
import { $replica } from "~/db.server";
10+
import { env } from "~/env.server";
11+
import { verifyHttpCallbackHash } from "~/services/httpCallback.server";
12+
import { logger } from "~/services/logger.server";
13+
import { engine } from "~/v3/runEngine.server";
14+
15+
const paramsSchema = z.object({
16+
waitpointFriendlyId: z.string(),
17+
hash: z.string(),
18+
});
19+
20+
export async function action({ request, params }: ActionFunctionArgs) {
21+
if (request.method.toUpperCase() !== "POST") {
22+
return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "POST" } });
23+
}
24+
25+
const contentLength = request.headers.get("content-length");
26+
if (!contentLength) {
27+
return json({ error: "Content-Length header is required" }, { status: 411 });
28+
}
29+
30+
if (parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) {
31+
return json({ error: "Request body too large" }, { status: 413 });
32+
}
33+
34+
const { waitpointFriendlyId, hash } = paramsSchema.parse(params);
35+
const waitpointId = WaitpointId.toId(waitpointFriendlyId);
36+
37+
try {
38+
const waitpoint = await $replica.waitpoint.findFirst({
39+
where: {
40+
id: waitpointId,
41+
},
42+
include: {
43+
environment: {
44+
select: {
45+
apiKey: true,
46+
},
47+
},
48+
},
49+
});
50+
51+
if (!waitpoint) {
52+
return json({ error: "Waitpoint not found" }, { status: 404 });
53+
}
54+
55+
if (!verifyHttpCallbackHash(waitpoint.id, hash, waitpoint.environment.apiKey)) {
56+
return json({ error: "Invalid URL, hash doesn't match" }, { status: 401 });
57+
}
58+
59+
if (waitpoint.status === "COMPLETED") {
60+
return json<CompleteWaitpointTokenResponseBody>({
61+
success: true,
62+
});
63+
}
64+
65+
// If the request body is not valid JSON, return an empty object
66+
const body = await request.json().catch(() => ({}));
67+
68+
const stringifiedData = await stringifyIO(body);
69+
const finalData = await conditionallyExportPacket(
70+
stringifiedData,
71+
`${waitpointId}/waitpoint/http-callback`
72+
);
73+
74+
const result = await engine.completeWaitpoint({
75+
id: waitpointId,
76+
output: finalData.data
77+
? { type: finalData.dataType, value: finalData.data, isError: false }
78+
: undefined,
79+
});
80+
81+
return json<CompleteWaitpointTokenResponseBody>(
82+
{
83+
success: true,
84+
},
85+
{ status: 200 }
86+
);
87+
} catch (error) {
88+
logger.error("Failed to complete HTTP callback", { error });
89+
throw json({ error: "Failed to complete HTTP callback" }, { status: 500 });
90+
}
91+
}

0 commit comments

Comments
 (0)