Skip to content

Replay\Cancel a run (v3) #1006

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 13 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/new-rivers-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---

Added replayRun function to the SDK
6 changes: 6 additions & 0 deletions .changeset/tiny-doors-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---

Added cancelRun to the SDK
19 changes: 11 additions & 8 deletions apps/webapp/app/components/runs/v3/CancelRunDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { Form, useFetcher, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
Expand All @@ -14,29 +14,32 @@ type CancelRunDialogProps = {
};

export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
const cancelFetcher = useFetcher();
const navigation = useNavigation();

const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
const isLoading = navigation.formAction === formAction;

return (
<DialogContent>
<DialogContent key="cancel">
<DialogHeader>Cancel this run?</DialogHeader>
<DialogDescription>
Canceling a run will stop execution. If you want to run this later you will have to replay
the entire run with the original payload.
</DialogDescription>
<DialogFooter>
<cancelFetcher.Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/small"
LeadingIcon={cancelFetcher.state === "idle" ? StopCircleIcon : "spinner-white"}
disabled={cancelFetcher.state !== "idle"}
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{cancelFetcher.state === "idle" ? "Cancel run" : "Canceling..."}
{isLoading ? "Canceling..." : "Cancel run"}
</Button>
</cancelFetcher.Form>
</Form>
</DialogFooter>
</DialogContent>
);
Expand Down
44 changes: 44 additions & 0 deletions apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";

type ReplayRunDialogProps = {
runFriendlyId: string;
failedRedirect: string;
};

export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const navigation = useNavigation();

const formAction = `/resources/taskruns/${runFriendlyId}/replay`;
const isLoading = navigation.formAction === formAction;

return (
<DialogContent key="replay">
<DialogHeader>Replay this run?</DialogHeader>
<DialogDescription>
Replaying a run will create a new run with the same payload and environment as the original.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post">
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<Button
type="submit"
variant="primary/small"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Replaying..." : "Replay run"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
59 changes: 40 additions & 19 deletions apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ import { formatDuration } from "@trigger.dev/core/v3";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { useEnvironments } from "~/hooks/useEnvironments";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { CancelRunDialog } from "./CancelRunDialog";
import { useLocation } from "@remix-run/react";
import { ReplayRunDialog } from "./ReplayRunDialog";

type RunsTableProps = {
total: number;
Expand All @@ -49,7 +50,6 @@ export function TaskRunsTable({
}: RunsTableProps) {
const organization = useOrganization();
const project = useProject();
const location = useLocation();

return (
<Table>
Expand Down Expand Up @@ -110,23 +110,7 @@ export function TaskRunsTable({
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}
</TableCell>
{run.isCancellable ? (
<TableCellMenu isSticky>
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
</TableCellMenu>
) : (
<TableCell to={path}>{""}</TableCell>
)}
<RunActionsCell run={run} path={path} />
</TableRow>
);
})
Expand All @@ -144,6 +128,43 @@ export function TaskRunsTable({
);
}

function RunActionsCell({ run, path }: { run: RunListItem; path: string }) {
const location = useLocation();

if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;

return (
<TableCellMenu isSticky>
{run.isCancellable && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{run.isReplayable && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</TableCellMenu>
);
}

function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
Expand Down
1 change: 1 addition & 0 deletions apps/webapp/app/presenters/v3/RunListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export class RunListPresenter {
version: run.version,
taskIdentifier: run.taskIdentifier,
attempts: Number(run.attempts),
isReplayable: true,
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
environment: {
type: environment.type,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { CloudArrowDownIcon, QueueListIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
CloudArrowDownIcon,
QueueListIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
Expand All @@ -14,6 +19,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
Expand All @@ -22,7 +28,7 @@ import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptSt
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
import { Span, SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3RunPath, v3RunSpanPath, v3SpanParamsSchema, v3TraceSpanPath } from "~/utils/pathBuilder";
Expand Down Expand Up @@ -188,31 +194,62 @@ export default function Page() {
)}
</div>
<div className="flex items-center gap-4">
{event.isPartial && runParam && (
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={event.runId}
redirectPath={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: event.spanId }
)}
/>
</Dialog>
)}
<RunActionButtons span={event} />
</div>
</div>
) : null}
</div>
);
}

function RunActionButtons({ span }: { span: Span }) {
const organization = useOrganization();
const project = useProject();
const { runParam } = useParams();

if (!runParam) return null;

if (span.isPartial) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={span.runId}
redirectPath={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: span.spanId }
)}
/>
</Dialog>
);
}

return (
<Dialog>
<DialogTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ArrowPathIcon}>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={span.runId}
failedRedirect={v3RunSpanPath(
organization,
project,
{ friendlyId: runParam },
{ spanId: span.spanId }
)}
/>
</Dialog>
);
}

function PacketDisplay({
data,
dataType,
Expand Down
72 changes: 72 additions & 0 deletions apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { PrismaErrorSchema, prisma } from "~/db.server";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { CancelRunService } from "~/services/runs/cancelRun.server";
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
import { logger } from "~/services/logger.server";

const ParamsSchema = z.object({
/* This is the run friendly ID */
runParam: z.string(),
});

export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}

// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}

const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return json({ error: "Invalid or missing run ID" }, { status: 400 });
}

const { runParam } = parsed.data;

try {
const taskRun = await prisma.taskRun.findUnique({
where: {
friendlyId: runParam,
},
});

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

const service = new ReplayTaskRunService();
const newRun = await service.call(taskRun);

if (!newRun) {
return json({ error: "Failed to create new run" }, { status: 400 });
}

return json({
id: newRun?.friendlyId,
});
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
run: runParam,
});
return json({ error: error.message }, { status: 400 });
} else {
logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam });
return json({ error: JSON.stringify(error) }, { status: 400 });
}
}
}
2 changes: 1 addition & 1 deletion apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const parsed = ParamsSchema.safeParse(params);

if (!parsed.success) {
return json({ error: "Invalid or Missing runId" }, { status: 400 });
return json({ error: "Invalid or Missing run id" }, { status: 400 });
}

const { runParam } = parsed.data;
Expand Down
Loading