import { parseWithZod } from "@conform-to/zod"; import { Form, useLocation, useNavigation, useSubmit } from "@remix-run/react"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import type { WaitpointTokenStatus } from "@trigger.dev/core/v3"; import { stringifyIO, timeoutError } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import type { Waitpoint } from "@trigger.dev/database"; import { useCallback, useRef } from "react"; import { z } from "zod"; import { AnimatedHourglassIcon } from "~/assets/icons/AnimatedHourglassIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; import { Button } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { Paragraph } from "~/components/primitives/Paragraph"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { LiveCountdown } from "~/components/runs/v3/LiveTimer"; import { $replica, type PrismaReplicaClient } from "~/db.server"; import { resolveWaitpointThroughReadThrough } from "~/runEngine/concerns/resolveWaitpointThroughReadThrough.server"; import { env } from "~/env.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder"; import { engine } from "~/v3/runEngine.server"; const CompleteWaitpointFormData = z.discriminatedUnion("type", [ z.object({ type: z.literal("MANUAL"), payload: z.string().optional(), isTimeout: z.string().optional(), successRedirect: z.string(), failureRedirect: z.string(), }), z.object({ type: z.literal("DATETIME"), successRedirect: z.string(), failureRedirect: z.string(), }), ]); const Params = EnvironmentParamSchema.extend({ waitpointFriendlyId: z.string(), }); export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam, waitpointFriendlyId } = Params.parse(params); const formData = await request.formData(); const submission = parseWithZod(formData, { schema: CompleteWaitpointFormData }); if (submission.status !== "success") { return json(submission.reply()); } try { //first check that the user has access to the project const project = await $replica.project.findUnique({ where: { slug: projectParam, organization: { members: { some: { userId, }, }, }, }, select: { id: true }, }); if (!project) { throw new Error("Project not found"); } const waitpointId = WaitpointId.toId(waitpointFriendlyId); const waitpoint = await resolveWaitpointThroughReadThrough({ waitpointId, environmentId: "", read: (client: PrismaReplicaClient) => client.waitpoint.findFirst({ select: { projectId: true, environmentId: true, }, where: { id: waitpointId, }, }), }); if (waitpoint?.projectId !== project.id) { return redirectWithErrorMessage( submission.value.failureRedirect, request, "No waitpoint found" ); } switch (submission.value.type) { case "DATETIME": { const _result = await engine.completeWaitpoint({ id: waitpointId, }); return redirectWithSuccessMessage( submission.value.successRedirect, request, "Waitpoint skipped" ); } case "MANUAL": { if (submission.value.isTimeout) { try { const _result = await engine.completeWaitpoint({ id: waitpointId, output: { type: "application/json", value: JSON.stringify(timeoutError(new Date())), isError: true, }, }); return redirectWithSuccessMessage( submission.value.successRedirect, request, "Waitpoint timed out" ); } catch (_e) { return redirectWithErrorMessage( submission.value.failureRedirect, request, "Invalid payload, must be valid JSON" ); } } try { if ( submission.value.payload && submission.value.payload.length > env.TASK_PAYLOAD_MAXIMUM_SIZE ) { return redirectWithErrorMessage( submission.value.failureRedirect, request, "Payload is too large" ); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { return redirectWithErrorMessage( submission.value.failureRedirect, request, "Environment not found" ); } if (environment.id !== waitpoint.environmentId) { return redirectWithErrorMessage( submission.value.failureRedirect, request, "No waitpoint found" ); } const data = submission.value.payload ? JSON.parse(submission.value.payload) : {}; const stringifiedData = await stringifyIO(data); const finalData = await processWaitpointCompletionPacket( stringifiedData, environment, `${WaitpointId.toFriendlyId(waitpointId)}/token` ); const _result = await engine.completeWaitpoint({ id: waitpointId, output: finalData.data ? { type: finalData.dataType, value: finalData.data, isError: false } : undefined, }); return redirectWithSuccessMessage( submission.value.successRedirect, request, "Waitpoint completed" ); } catch (_e) { return redirectWithErrorMessage( submission.value.failureRedirect, request, "Invalid payload, must be valid JSON" ); } } } } catch (error: any) { logger.error("Failed to complete waitpoint", error); const errorMessage = `Something went wrong. Please try again.`; return redirectWithErrorMessage( v3RunsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, errorMessage ); } }; type FormWaitpoint = Pick & { status: WaitpointTokenStatus; }; export function CompleteWaitpointForm({ waitpoint }: { waitpoint: FormWaitpoint }) { return (
{waitpoint.type === "DATETIME" ? ( waitpoint.completedAfter ? ( ) : ( <>Waitpoint doesn't have a complete date ) ) : ( )}
); } function CompleteDateTimeWaitpointForm({ waitpoint, }: { waitpoint: { friendlyId: string; completedAfter: Date }; }) { const location = useLocation(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const timeToComplete = waitpoint.completedAfter.getTime() - Date.now(); if (timeToComplete < 0) { return (
Waitpoint completed
); } return (
Manually skip this waitpoint
); } function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { id: string } }) { const location = useLocation(); const navigation = useNavigation(); const submit = useSubmit(); const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const currentJson = useRef("{\n\n}"); const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`; const submitForm = useCallback( (e: React.FormEvent) => { const formData = new FormData(e.currentTarget); const data: Record = { type: formData.get("type") as string, failureRedirect: formData.get("failureRedirect") as string, successRedirect: formData.get("successRedirect") as string, }; data.payload = currentJson.current; submit(data, { action: formAction, method: "post", }); e.preventDefault(); }, [currentJson] ); return ( <>
submitForm(e)} className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden border-t border-grid-bright" >
Manually complete this waitpoint
{ currentJson.current = v; }} showClearButton={false} showCopyButton={false} height="100%" min-height="100%" max-height="100%" />
); } export function ForceTimeout({ waitpoint }: { waitpoint: { id: string } }) { const location = useLocation(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`; return (
); }