Skip to content

Commit f854cb9

Browse files
authored
Replay\Cancel a run (v3) (#1006)
* WIP on replaying a task from the run page * Don’t pass the existing runs idempotency key, it will cause the replay to always return the original run * Replay from the run list * Don’t use fetchers in the replay/cancel dialogs * API endpoint for replaying a run * REST API docs (mostly coming soon) but added replay run * replayRun function added to the SDK * Cancel run added to the SDK * v3-catalog file to test canceling and replaying * Changed the SDK to be runs.replay and runs.cancel * Removed comment * Latest lockfile
1 parent 7268f17 commit f854cb9

File tree

26 files changed

+778
-69
lines changed

26 files changed

+778
-69
lines changed

.changeset/new-rivers-tell.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Added replayRun function to the SDK

.changeset/tiny-doors-type.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Added cancelRun to the SDK

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { StopCircleIcon } from "@heroicons/react/20/solid";
2-
import { useFetcher } from "@remix-run/react";
2+
import { Form, useFetcher, useNavigation } from "@remix-run/react";
33
import { Button } from "~/components/primitives/Buttons";
44
import {
55
DialogContent,
@@ -14,29 +14,32 @@ type CancelRunDialogProps = {
1414
};
1515

1616
export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
17-
const cancelFetcher = useFetcher();
17+
const navigation = useNavigation();
18+
19+
const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
20+
const isLoading = navigation.formAction === formAction;
1821

1922
return (
20-
<DialogContent>
23+
<DialogContent key="cancel">
2124
<DialogHeader>Cancel this run?</DialogHeader>
2225
<DialogDescription>
2326
Canceling a run will stop execution. If you want to run this later you will have to replay
2427
the entire run with the original payload.
2528
</DialogDescription>
2629
<DialogFooter>
27-
<cancelFetcher.Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
30+
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
2831
<Button
2932
type="submit"
3033
name="redirectUrl"
3134
value={redirectPath}
3235
variant="danger/small"
33-
LeadingIcon={cancelFetcher.state === "idle" ? StopCircleIcon : "spinner-white"}
34-
disabled={cancelFetcher.state !== "idle"}
36+
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
37+
disabled={isLoading}
3538
shortcut={{ modifiers: ["meta"], key: "enter" }}
3639
>
37-
{cancelFetcher.state === "idle" ? "Cancel run" : "Canceling..."}
40+
{isLoading ? "Canceling..." : "Cancel run"}
3841
</Button>
39-
</cancelFetcher.Form>
42+
</Form>
4043
</DialogFooter>
4144
</DialogContent>
4245
);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ArrowPathIcon } from "@heroicons/react/20/solid";
2+
import { Form, useFetcher, useNavigation } from "@remix-run/react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import {
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
} from "~/components/primitives/Dialog";
10+
11+
type ReplayRunDialogProps = {
12+
runFriendlyId: string;
13+
failedRedirect: string;
14+
};
15+
16+
export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
17+
const navigation = useNavigation();
18+
19+
const formAction = `/resources/taskruns/${runFriendlyId}/replay`;
20+
const isLoading = navigation.formAction === formAction;
21+
22+
return (
23+
<DialogContent key="replay">
24+
<DialogHeader>Replay this run?</DialogHeader>
25+
<DialogDescription>
26+
Replaying a run will create a new run with the same payload and environment as the original.
27+
</DialogDescription>
28+
<DialogFooter>
29+
<Form action={formAction} method="post">
30+
<input type="hidden" name="failedRedirect" value={failedRedirect} />
31+
<Button
32+
type="submit"
33+
variant="primary/small"
34+
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
35+
disabled={isLoading}
36+
shortcut={{ modifiers: ["meta"], key: "enter" }}
37+
>
38+
{isLoading ? "Replaying..." : "Replay run"}
39+
</Button>
40+
</Form>
41+
</DialogFooter>
42+
</DialogContent>
43+
);
44+
}

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

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ import { formatDuration } from "@trigger.dev/core/v3";
2424
import { TaskRunStatusCombo } from "./TaskRunStatus";
2525
import { useEnvironments } from "~/hooks/useEnvironments";
2626
import { Button, LinkButton } from "~/components/primitives/Buttons";
27-
import { StopCircleIcon } from "@heroicons/react/20/solid";
27+
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
2828
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
2929
import { CancelRunDialog } from "./CancelRunDialog";
3030
import { useLocation } from "@remix-run/react";
31+
import { ReplayRunDialog } from "./ReplayRunDialog";
3132

3233
type RunsTableProps = {
3334
total: number;
@@ -49,7 +50,6 @@ export function TaskRunsTable({
4950
}: RunsTableProps) {
5051
const organization = useOrganization();
5152
const project = useProject();
52-
const location = useLocation();
5353

5454
return (
5555
<Table>
@@ -110,23 +110,7 @@ export function TaskRunsTable({
110110
<TableCell to={path}>
111111
{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}
112112
</TableCell>
113-
{run.isCancellable ? (
114-
<TableCellMenu isSticky>
115-
<Dialog>
116-
<DialogTrigger asChild>
117-
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
118-
Cancel run
119-
</Button>
120-
</DialogTrigger>
121-
<CancelRunDialog
122-
runFriendlyId={run.friendlyId}
123-
redirectPath={`${location.pathname}${location.search}`}
124-
/>
125-
</Dialog>
126-
</TableCellMenu>
127-
) : (
128-
<TableCell to={path}>{""}</TableCell>
129-
)}
113+
<RunActionsCell run={run} path={path} />
130114
</TableRow>
131115
);
132116
})
@@ -144,6 +128,43 @@ export function TaskRunsTable({
144128
);
145129
}
146130

131+
function RunActionsCell({ run, path }: { run: RunListItem; path: string }) {
132+
const location = useLocation();
133+
134+
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
135+
136+
return (
137+
<TableCellMenu isSticky>
138+
{run.isCancellable && (
139+
<Dialog>
140+
<DialogTrigger asChild>
141+
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
142+
Cancel run
143+
</Button>
144+
</DialogTrigger>
145+
<CancelRunDialog
146+
runFriendlyId={run.friendlyId}
147+
redirectPath={`${location.pathname}${location.search}`}
148+
/>
149+
</Dialog>
150+
)}
151+
{run.isReplayable && (
152+
<Dialog>
153+
<DialogTrigger asChild>
154+
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
155+
Replay run
156+
</Button>
157+
</DialogTrigger>
158+
<ReplayRunDialog
159+
runFriendlyId={run.friendlyId}
160+
failedRedirect={`${location.pathname}${location.search}`}
161+
/>
162+
</Dialog>
163+
)}
164+
</TableCellMenu>
165+
);
166+
}
167+
147168
function NoRuns({ title }: { title: string }) {
148169
return (
149170
<div className="flex items-center justify-center">

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export class RunListPresenter {
222222
version: run.version,
223223
taskIdentifier: run.taskIdentifier,
224224
attempts: Number(run.attempts),
225+
isReplayable: true,
225226
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
226227
environment: {
227228
type: environment.type,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { CloudArrowDownIcon, QueueListIcon, StopCircleIcon } from "@heroicons/react/20/solid";
1+
import {
2+
ArrowPathIcon,
3+
CloudArrowDownIcon,
4+
QueueListIcon,
5+
StopCircleIcon,
6+
} from "@heroicons/react/20/solid";
27
import { useParams } from "@remix-run/react";
38
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
49
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
@@ -14,6 +19,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
1419
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
1520
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
1621
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
22+
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
1723
import { RunIcon } from "~/components/runs/v3/RunIcon";
1824
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
1925
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
@@ -22,7 +28,7 @@ import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptSt
2228
import { useOrganization } from "~/hooks/useOrganizations";
2329
import { useProject } from "~/hooks/useProject";
2430
import { redirectWithErrorMessage } from "~/models/message.server";
25-
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
31+
import { Span, SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
2632
import { requireUserId } from "~/services/session.server";
2733
import { cn } from "~/utils/cn";
2834
import { v3RunPath, v3RunSpanPath, v3SpanParamsSchema, v3TraceSpanPath } from "~/utils/pathBuilder";
@@ -188,31 +194,62 @@ export default function Page() {
188194
)}
189195
</div>
190196
<div className="flex items-center gap-4">
191-
{event.isPartial && runParam && (
192-
<Dialog>
193-
<DialogTrigger asChild>
194-
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
195-
Cancel run
196-
</Button>
197-
</DialogTrigger>
198-
<CancelRunDialog
199-
runFriendlyId={event.runId}
200-
redirectPath={v3RunSpanPath(
201-
organization,
202-
project,
203-
{ friendlyId: runParam },
204-
{ spanId: event.spanId }
205-
)}
206-
/>
207-
</Dialog>
208-
)}
197+
<RunActionButtons span={event} />
209198
</div>
210199
</div>
211200
) : null}
212201
</div>
213202
);
214203
}
215204

205+
function RunActionButtons({ span }: { span: Span }) {
206+
const organization = useOrganization();
207+
const project = useProject();
208+
const { runParam } = useParams();
209+
210+
if (!runParam) return null;
211+
212+
if (span.isPartial) {
213+
return (
214+
<Dialog>
215+
<DialogTrigger asChild>
216+
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
217+
Cancel run
218+
</Button>
219+
</DialogTrigger>
220+
<CancelRunDialog
221+
runFriendlyId={span.runId}
222+
redirectPath={v3RunSpanPath(
223+
organization,
224+
project,
225+
{ friendlyId: runParam },
226+
{ spanId: span.spanId }
227+
)}
228+
/>
229+
</Dialog>
230+
);
231+
}
232+
233+
return (
234+
<Dialog>
235+
<DialogTrigger asChild>
236+
<Button variant="tertiary/small" LeadingIcon={ArrowPathIcon}>
237+
Replay run
238+
</Button>
239+
</DialogTrigger>
240+
<ReplayRunDialog
241+
runFriendlyId={span.runId}
242+
failedRedirect={v3RunSpanPath(
243+
organization,
244+
project,
245+
{ friendlyId: runParam },
246+
{ spanId: span.spanId }
247+
)}
248+
/>
249+
</Dialog>
250+
);
251+
}
252+
216253
function PacketDisplay({
217254
data,
218255
dataType,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { json } from "@remix-run/server-runtime";
3+
import { PrismaErrorSchema, prisma } from "~/db.server";
4+
import { z } from "zod";
5+
import { authenticateApiRequest } from "~/services/apiAuth.server";
6+
import { CancelRunService } from "~/services/runs/cancelRun.server";
7+
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
8+
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
9+
import { logger } from "~/services/logger.server";
10+
11+
const ParamsSchema = z.object({
12+
/* This is the run friendly ID */
13+
runParam: z.string(),
14+
});
15+
16+
export async function action({ request, params }: ActionFunctionArgs) {
17+
// Ensure this is a POST request
18+
if (request.method.toUpperCase() !== "POST") {
19+
return { status: 405, body: "Method Not Allowed" };
20+
}
21+
22+
// Authenticate the request
23+
const authenticationResult = await authenticateApiRequest(request);
24+
if (!authenticationResult) {
25+
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
26+
}
27+
28+
const parsed = ParamsSchema.safeParse(params);
29+
if (!parsed.success) {
30+
return json({ error: "Invalid or missing run ID" }, { status: 400 });
31+
}
32+
33+
const { runParam } = parsed.data;
34+
35+
try {
36+
const taskRun = await prisma.taskRun.findUnique({
37+
where: {
38+
friendlyId: runParam,
39+
},
40+
});
41+
42+
if (!taskRun) {
43+
return json({ error: "Run not found" }, { status: 404 });
44+
}
45+
46+
const service = new ReplayTaskRunService();
47+
const newRun = await service.call(taskRun);
48+
49+
if (!newRun) {
50+
return json({ error: "Failed to create new run" }, { status: 400 });
51+
}
52+
53+
return json({
54+
id: newRun?.friendlyId,
55+
});
56+
} catch (error) {
57+
if (error instanceof Error) {
58+
logger.error("Failed to replay run", {
59+
error: {
60+
name: error.name,
61+
message: error.message,
62+
stack: error.stack,
63+
},
64+
run: runParam,
65+
});
66+
return json({ error: error.message }, { status: 400 });
67+
} else {
68+
logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam });
69+
return json({ error: JSON.stringify(error) }, { status: 400 });
70+
}
71+
}
72+
}

apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
2525
const parsed = ParamsSchema.safeParse(params);
2626

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

3131
const { runParam } = parsed.data;

0 commit comments

Comments
 (0)