Skip to content

Waitpoint token callback URLs #2025

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 57 commits into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
62135c6
Initial commit with a plan for what we’re going to do
matt-aitken May 2, 2025
78f534e
Some initial types and improved plan
matt-aitken May 2, 2025
d9aea07
Add Waitpoint resolver
matt-aitken May 5, 2025
87e107d
Add resolver + status index
matt-aitken May 5, 2025
59b13f6
Remove type + status index
matt-aitken May 5, 2025
30a5497
Only drop if exists
matt-aitken May 5, 2025
b7218af
Remove type index
matt-aitken May 5, 2025
601002b
Update waitpoint list presenter to use resolver
matt-aitken May 5, 2025
79e98c1
Added resolver to the engine
matt-aitken May 5, 2025
1c09fbe
Made the existing waitpoint list presenter more flexible
matt-aitken May 5, 2025
b9edd2e
Initial implentation ofr wait.forHttpCallback()
matt-aitken May 5, 2025
93fbfb4
Added the callback endpoint (no API rate limit)
matt-aitken May 5, 2025
6df4084
schema version
matt-aitken May 5, 2025
676f676
Added jsdocs, removed schema version because of errors
matt-aitken May 5, 2025
27f2bde
Show callback URL if it’s set
matt-aitken May 5, 2025
25448bd
Dashboard pages and panels
matt-aitken May 5, 2025
8bfc515
Remove todos
matt-aitken May 5, 2025
2b21ca3
Added temporary icon
matt-aitken May 5, 2025
f5d73f4
Added a blank state
matt-aitken May 5, 2025
1e5350a
Some tweaks and added a Replicate example
matt-aitken May 6, 2025
71d88b9
Implement unwrap() for httpCallback
matt-aitken May 6, 2025
7766c2f
Added unwrap to wait.forToken() as well
matt-aitken May 6, 2025
54e76ce
Improved jsdocs
matt-aitken May 6, 2025
6cd8870
Added docs
matt-aitken May 6, 2025
3f50577
Added unwrap to the token docs
matt-aitken May 6, 2025
c48b27a
Merge branch 'main' into wait-for-http-callback
matt-aitken May 6, 2025
455f5a2
Show a dash if there are no tags
matt-aitken May 6, 2025
d1c19fc
Make the timeout error safer
matt-aitken May 6, 2025
e2eb321
Fixed migrations… should use id desc not createdAt desc
matt-aitken May 6, 2025
b218ff8
Fixed page title
matt-aitken May 6, 2025
8503cea
Fixed migration so it only adds them if they don’t exist. This allows…
matt-aitken May 6, 2025
799f8e1
Respect the max content length by getting the length of the body
matt-aitken May 7, 2025
ec9fdf6
Added more docs details about the callback format
matt-aitken May 7, 2025
49e7f15
Remove code comment
matt-aitken May 7, 2025
aad1278
Improved the error
matt-aitken May 7, 2025
17bcce6
Added a hash to the HTTP callback URLs
matt-aitken May 7, 2025
46c34f3
Add the apiKey to the API input type to fix TS error
matt-aitken May 7, 2025
344dfde
Return the error responses. They were being caught and not preserved
matt-aitken May 7, 2025
fcbc275
The content-length header is required. Deal with an empty body
matt-aitken May 7, 2025
4b790bd
Removed unused types
matt-aitken May 7, 2025
c0d01e4
Added some new span icons
matt-aitken May 7, 2025
beca6ea
Reworked http callback to be a create call then just use wait.forToken()
matt-aitken May 7, 2025
4e9c2ba
Added a changeset
matt-aitken May 7, 2025
b87d98f
Updated the docs
matt-aitken May 7, 2025
2536c10
Updated the wait overview docs
matt-aitken May 7, 2025
d8c76ee
Simplify to just a call
matt-aitken May 7, 2025
9d01c88
WIP stripping right back to waitpoints just having a URL associated w…
matt-aitken May 7, 2025
93263d9
More deletions
matt-aitken May 7, 2025
f7af73f
Remove missing icon
matt-aitken May 7, 2025
2710501
Updated the changeset
matt-aitken May 7, 2025
4b24a47
Add URL to the token return types
matt-aitken May 7, 2025
776b7ea
Remove wait for http callback page
matt-aitken May 7, 2025
474a333
Updated docs
matt-aitken May 7, 2025
e2b39cb
More tidying
matt-aitken May 7, 2025
a725140
Type and import fix
matt-aitken May 7, 2025
0a97f3a
Remove unused import
matt-aitken May 7, 2025
d4b7135
Some type fixes for the retrieve
matt-aitken May 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curvy-dogs-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Added wait.createHttpCallback() to allow offloading work to 3rd party APIs
1 change: 1 addition & 0 deletions apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,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<User, "email" | "admin"> & { isImpersonating: boolean };
export type SideMenuProject = Pick<
Expand Down
5 changes: 5 additions & 0 deletions apps/webapp/app/components/runs/v3/RunIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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 { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";

type TaskIconProps = {
name: string | undefined;
Expand Down Expand Up @@ -75,6 +76,10 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <TriggerIcon className={cn(className, "text-orange-500")} />;
case "python":
return <PythonLogoIcon className={className} />;
case "wait-token":
return <WaitpointTokenIcon className={cn(className, "text-sky-500")} />;
case "function":
return <FunctionIcon className={cn(className, "text-text-dimmed")} />;
//log levels
case "debug":
case "log":
Expand Down
7 changes: 7 additions & 0 deletions apps/webapp/app/components/runs/v3/WaitpointDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder
import { PacketDisplay } from "./PacketDisplay";
import { WaitpointStatusCombo } from "./WaitpointStatus";
import { RunTag } from "./RunTag";
import { ClipboardField } from "~/components/primitives/ClipboardField";

export function WaitpointDetailTable({
waitpoint,
Expand Down Expand Up @@ -50,6 +51,12 @@ export function WaitpointDetailTable({
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Callback URL</Property.Label>
<Property.Value className="my-1">
<ClipboardField value={waitpoint.callbackUrl} variant={"secondary/small"} />
</Property.Value>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against missing callbackUrl & consider masking secret portions

  1. ClipboardField renders whatever string it receives. If waitpoint.callbackUrl is undefined (e.g. resolver ≠ HTTP_CALLBACK or the presenter failed) the input will literally show the string "undefined", which is misleading.
  2. The URL contains an HMAC-style secret. Displaying it in full may be undesirable; ClipboardField already supports a secure prop to mask until focused.
-      <Property.Item>
-        <Property.Label>Callback URL</Property.Label>
-        <Property.Value className="my-1">
-          <ClipboardField value={waitpoint.callbackUrl} variant={"secondary/small"} />
-        </Property.Value>
-      </Property.Item>
+      {waitpoint.callbackUrl ? (
+        <Property.Item>
+          <Property.Label>Callback URL</Property.Label>
+          <Property.Value className="my-1">
+            {/* Mask the URL until the user focuses the field */}
+            <ClipboardField
+              value={waitpoint.callbackUrl}
+              secure
+              variant="secondary/small"
+            />
+          </Property.Value>
+        </Property.Item>
+      ) : null}

This prevents accidental leakage and avoids confusing "undefined" renders.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Property.Item>
<Property.Label>Callback URL</Property.Label>
<Property.Value className="my-1">
<ClipboardField value={waitpoint.callbackUrl} variant={"secondary/small"} />
</Property.Value>
{waitpoint.callbackUrl ? (
<Property.Item>
<Property.Label>Callback URL</Property.Label>
<Property.Value className="my-1">
{/* Mask the URL until the user focuses the field */}
<ClipboardField
value={waitpoint.callbackUrl}
secure
variant="secondary/small"
/>
</Property.Value>
</Property.Item>
) : null}

</Property.Item>
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -61,9 +57,9 @@ export const ApiWaitpointTokenListSearchParams = z.object({
"filter[createdAt][to]": CoercedDate,
});

type ApiWaitpointTokenListSearchParams = z.infer<typeof ApiWaitpointTokenListSearchParams>;
type ApiWaitpointListSearchParams = z.infer<typeof ApiWaitpointListSearchParams>;

export class ApiWaitpointTokenListPresenter extends BasePresenter {
export class ApiWaitpointListPresenter extends BasePresenter {
public async call(
environment: {
id: string;
Expand All @@ -72,11 +68,12 @@ export class ApiWaitpointTokenListPresenter extends BasePresenter {
id: string;
engine: RunEngineVersion;
};
apiKey: string;
},
searchParams: ApiWaitpointTokenListSearchParams
searchParams: ApiWaitpointListSearchParams
) {
return this.trace("call", async (span) => {
const options: WaitpointTokenListOptions = {
const options: WaitpointListOptions = {
environment,
};

Expand Down Expand Up @@ -118,7 +115,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import parse from "parse-duration";
import {
Prisma,
type WaitpointResolver,
type RunEngineVersion,
type RuntimeEnvironmentType,
type WaitpointStatus,
Expand All @@ -11,17 +12,19 @@ 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 { generateHttpCallbackUrl } from "~/services/httpCallback.server";

const DEFAULT_PAGE_SIZE = 25;

export type WaitpointTokenListOptions = {
export type WaitpointListOptions = {
environment: {
id: string;
type: RuntimeEnvironmentType;
project: {
id: string;
engine: RunEngineVersion;
};
apiKey: string;
};
// filters
id?: string;
Expand All @@ -40,7 +43,7 @@ export type WaitpointTokenListOptions = {
type Result =
| {
success: true;
tokens: WaitpointTokenItem[];
tokens: (WaitpointTokenItem & { callbackUrl: string })[];
pagination: {
next: string | undefined;
previous: string | undefined;
Expand All @@ -63,7 +66,7 @@ type Result =
filters: undefined;
};

export class WaitpointTokenListPresenter extends BasePresenter {
export class WaitpointListPresenter extends BasePresenter {
public async call({
environment,
id,
Expand All @@ -76,7 +79,7 @@ export class WaitpointTokenListPresenter extends BasePresenter {
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: WaitpointTokenListOptions): Promise<Result> {
}: WaitpointListOptions): Promise<Result> {
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
return {
Expand Down Expand Up @@ -165,8 +168,8 @@ export class WaitpointTokenListPresenter extends BasePresenter {
${sqlDatabaseSchema}."Waitpoint" w
WHERE
w."environmentId" = ${environment.id}
AND w.type = 'MANUAL'
-- cursor
AND w.type = 'MANUAL'
-- cursor
${
cursor
? direction === "forward"
Expand Down Expand Up @@ -263,6 +266,7 @@ export class WaitpointTokenListPresenter extends BasePresenter {
success: true,
tokens: tokensToReturn.map((token) => ({
id: token.friendlyId,
callbackUrl: generateHttpCallbackUrl(token.id, environment.apiKey),
status: waitpointStatusToApiStatus(token.status, token.outputIsError),
completedAt: token.completedAt ?? undefined,
timeoutAt: token.completedAfter ?? undefined,
Expand Down
12 changes: 11 additions & 1 deletion apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ 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";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import { env } from "~/env.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";

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

Expand All @@ -22,6 +25,7 @@ export class WaitpointPresenter extends BasePresenter {
environmentId,
},
select: {
id: true,
friendlyId: true,
type: true,
status: true,
Expand All @@ -42,6 +46,11 @@ export class WaitpointPresenter extends BasePresenter {
take: 5,
},
tags: true,
environment: {
select: {
apiKey: true,
},
},
},
});

Expand Down Expand Up @@ -83,6 +92,7 @@ export class WaitpointPresenter extends BasePresenter {
return {
id: waitpoint.friendlyId,
type: waitpoint.type,
callbackUrl: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey),
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
idempotencyKey: waitpoint.idempotencyKey,
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -36,7 +37,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";

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

try {
const presenter = new WaitpointTokenListPresenter();
const presenter = new WaitpointListPresenter();
const result = await presenter.call({
environment,
...searchParams,
Expand Down Expand Up @@ -143,6 +144,7 @@ export default function Page() {
<TableRow>
<TableHeaderCell className="w-[1%]">Created</TableHeaderCell>
<TableHeaderCell className="w-[20%]">ID</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Callback URL</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Status</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Completed</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Idempotency Key</TableHeaderCell>
Expand Down Expand Up @@ -178,6 +180,12 @@ export default function Page() {
<TableCell to={path}>
<CopyableText value={token.id} className="font-mono" />
</TableCell>
<TableCell to={path}>
<ClipboardField
value={token.callbackUrl}
variant={"secondary/small"}
/>
</TableCell>
<TableCell to={path}>
<WaitpointStatusCombo status={token.status} className="text-xs" />
</TableCell>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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 { 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) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "POST" } });
}

const contentLength = request.headers.get("content-length");
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 });
}

const { waitpointFriendlyId, hash } = paramsSchema.parse(params);
const waitpointId = WaitpointId.toId(waitpointFriendlyId);

try {
const waitpoint = await $replica.waitpoint.findFirst({
where: {
id: waitpointId,
},
include: {
environment: {
select: {
apiKey: true,
},
},
},
});

if (!waitpoint) {
return json({ error: "Waitpoint not found" }, { status: 404 });
}

if (!verifyHttpCallbackHash(waitpoint.id, hash, waitpoint.environment.apiKey)) {
return json({ error: "Invalid URL, hash doesn't match" }, { status: 401 });
}

if (waitpoint.status === "COMPLETED") {
return json<CompleteWaitpointTokenResponseBody>({
success: true,
});
}

// 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(
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<CompleteWaitpointTokenResponseBody>(
{
success: true,
},
{ status: 200 }
);
} catch (error) {
logger.error("Failed to complete HTTP callback", { error });
throw json({ error: "Failed to complete HTTP callback" }, { status: 500 });
}
}
Loading
Loading