Skip to content
Open
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
118 changes: 118 additions & 0 deletions apps/dashboard/src/components/run-action-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import type { ReactNode } from "react";
import { useState } from "react";

type ButtonVariant = React.ComponentProps<typeof Button>["variant"];

interface RunActionDialogProps {
triggerLabel: string;
triggerVariant?: ButtonVariant;
title: string;
description: ReactNode;
cancelLabel: string;
confirmLabel: string;
pendingLabel: string;
confirmVariant?: ButtonVariant;
fallbackErrorMessage: string;
action: () => Promise<unknown>;
onDone?: (() => Promise<void>) | (() => void);
}

function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message;
}

return fallback;
}

// Confirmation dialog shared by the run action buttons (cancel, resume).
export function RunActionDialog({
triggerLabel,
triggerVariant = "default",
title,
description,
cancelLabel,
confirmLabel,
pendingLabel,
confirmVariant,
fallbackErrorMessage,
action,
onDone,
}: RunActionDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);

async function runAction() {
setIsPending(true);
setError(null);

try {
await action();
await onDone?.();
setIsOpen(false);
} catch (caughtError) {
setError(getErrorMessage(caughtError, fallbackErrorMessage));
} finally {
setIsPending(false);
}
}

return (
<AlertDialog
open={isOpen}
onOpenChange={(nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) {
setError(null);
}
}}
>
<Button
type="button"
variant={triggerVariant}
onClick={() => {
setIsOpen(true);
}}
disabled={isPending}
>
{triggerLabel}
</Button>

<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>

{error && <p className="text-destructive text-xs">{error}</p>}

<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
variant={confirmVariant}
onClick={() => {
void runAction();
}}
disabled={isPending}
>
{isPending ? pendingLabel : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
105 changes: 16 additions & 89 deletions apps/dashboard/src/components/run-cancel-action.tsx
Original file line number Diff line number Diff line change
@@ -1,111 +1,38 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { RunActionDialog } from "@/components/run-action-dialog";
import { cancelWorkflowRunServerFn } from "@/lib/api";
import { isRunCancelableStatus } from "@/lib/status";
import type { WorkflowRunStatus } from "openworkflow/internal";
import { useState } from "react";

interface RunCancelActionProps {
runId: string;
status: WorkflowRunStatus;
onCanceled?: (() => Promise<void>) | (() => void);
}

function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}

return "Unable to cancel workflow run";
}

export function RunCancelAction({
runId,
status,
onCanceled,
}: RunCancelActionProps) {
const [isOpen, setIsOpen] = useState(false);
const [isCanceling, setIsCanceling] = useState(false);
const [error, setError] = useState<string | null>(null);

if (!isRunCancelableStatus(status)) {
return null;
}

async function cancelRun() {
setIsCanceling(true);
setError(null);

try {
await cancelWorkflowRunServerFn({
data: {
workflowRunId: runId,
},
});
await onCanceled?.();
setIsOpen(false);
} catch (caughtError) {
setError(getErrorMessage(caughtError));
} finally {
setIsCanceling(false);
}
}

return (
<AlertDialog
open={isOpen}
onOpenChange={(nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) {
setError(null);
}
}}
>
<Button
type="button"
variant="destructive"
onClick={() => {
setIsOpen(true);
}}
disabled={isCanceling}
>
Cancel Run
</Button>

<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this run?</AlertDialogTitle>
<AlertDialogDescription>
This will stop any future progress for this workflow run.
</AlertDialogDescription>
</AlertDialogHeader>

{error && <p className="text-destructive text-xs">{error}</p>}

<AlertDialogFooter>
<AlertDialogCancel disabled={isCanceling}>
Keep Running
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
void cancelRun();
}}
disabled={isCanceling}
>
{isCanceling ? "Canceling..." : "Cancel Run"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<RunActionDialog
triggerLabel="Cancel Run"
triggerVariant="destructive"
title="Cancel this run?"
description="This will stop any future progress for this workflow run."
cancelLabel="Keep Running"
confirmLabel="Cancel Run"
pendingLabel="Canceling..."
confirmVariant="destructive"
fallbackErrorMessage="Unable to cancel workflow run"
action={() =>
cancelWorkflowRunServerFn({ data: { workflowRunId: runId } })
}
onDone={onCanceled}
/>
);
}
37 changes: 37 additions & 0 deletions apps/dashboard/src/components/run-resume-action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { RunActionDialog } from "@/components/run-action-dialog";
import { resumeWorkflowRunServerFn } from "@/lib/api";
import { isRunResumableStatus } from "@/lib/status";
import type { WorkflowRunStatus } from "openworkflow/internal";

interface RunResumeActionProps {
runId: string;
status: WorkflowRunStatus;
onResumed?: (() => Promise<void>) | (() => void);
}

export function RunResumeAction({
runId,
status,
onResumed,
}: RunResumeActionProps) {
if (!isRunResumableStatus(status)) {
return null;
}

return (
<RunActionDialog
triggerLabel="Resume Run"
triggerVariant="default"
title="Resume this failed run?"
description="Completed steps stay cached and won't re-run. The failing step is retried with a fresh retry budget, and the run's history is kept."
cancelLabel="Cancel"
confirmLabel="Resume Run"
pendingLabel="Resuming..."
fallbackErrorMessage="Unable to resume workflow run"
action={() =>
resumeWorkflowRunServerFn({ data: { workflowRunId: runId } })
}
onDone={onResumed}
/>
);
}
12 changes: 12 additions & 0 deletions apps/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ export const cancelWorkflowRunServerFn = createServerFn({ method: "POST" })
return backend.cancelWorkflowRun({ workflowRunId: data.workflowRunId });
});

/**
* Resume a failed workflow run by ID. Flips the run back to `pending` and
* gives the failing step a fresh retry budget by only counting failures after
* the resume; completed steps stay cached and history is preserved.
*/
export const resumeWorkflowRunServerFn = createServerFn({ method: "POST" })
.validator(z.object({ workflowRunId: z.string() }))
.handler(async ({ data }): Promise<WorkflowRun> => {
const backend = await getBackend();
return backend.resumeWorkflowRun({ workflowRunId: data.workflowRunId });
});

/**
* List step attempts for a workflow run.
*/
Expand Down
9 changes: 9 additions & 0 deletions apps/dashboard/src/lib/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ const CANCELABLE_RUN_STATUSES: ReadonlySet<WorkflowRunStatus> = new Set([
"sleeping",
]);

/** Run statuses that can be resumed from the dashboard. */
const RESUMABLE_RUN_STATUSES: ReadonlySet<WorkflowRunStatus> = new Set([
"failed",
]);

const fallbackStatusConfig = STATUS_CONFIG.pending;

export function getRunStatusConfig(status: string): StatusConfig {
Expand Down Expand Up @@ -198,3 +203,7 @@ export function getStatusStatIconClass(status: string): string {
export function isRunCancelableStatus(status: string): boolean {
return CANCELABLE_RUN_STATUSES.has(status as WorkflowRunStatus);
}

export function isRunResumableStatus(status: string): boolean {
return RESUMABLE_RUN_STATUSES.has(status as WorkflowRunStatus);
}
10 changes: 9 additions & 1 deletion apps/dashboard/src/routes/runs/$runId.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppLayout } from "@/components/app-layout";
import { CursorPaginationControls } from "@/components/cursor-pagination-controls";
import { RunCancelAction } from "@/components/run-cancel-action";
import { RunResumeAction } from "@/components/run-resume-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
Expand Down Expand Up @@ -292,7 +293,14 @@ function RunDetailsPage() {
/>
)}
</div>
<div className="sm:shrink-0">
<div className="flex gap-2 sm:shrink-0">
<RunResumeAction
runId={run.id}
status={run.status}
onResumed={async () => {
await router.invalidate();
}}
/>
<RunCancelAction
runId={run.id}
status={run.status}
Expand Down
1 change: 1 addition & 0 deletions packages/openworkflow/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ function createMockWorkflowRun(
deadlineAt: null,
startedAt: null,
finishedAt: null,
resumedAt: null,
createdAt: currentTime,
updatedAt: currentTime,
...overrides,
Expand Down
18 changes: 18 additions & 0 deletions packages/openworkflow/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ export class OpenWorkflow {
await this.backend.cancelWorkflowRun({ workflowRunId });
}

/**
* Resume a failed workflow run. The run's status flips back to `pending`
* so the next worker tick picks it up. Already-completed steps are served
* from history without re-executing. Nothing is deleted: the failing step
* starts with a fresh retry budget because only failures recorded after the
* resume count against it.
* @param workflowRunId - The ID of the failed workflow run to resume
* @returns The updated workflow run
* @throws {Error} If the run does not exist or is not in `failed` status
* @example
* ```ts
* await ow.resumeWorkflowRun("123");
* ```
*/
async resumeWorkflowRun(workflowRunId: string): Promise<WorkflowRun> {
return await this.backend.resumeWorkflowRun({ workflowRunId });
}

/**
* Send a signal to all workflows currently waiting on the given signal
* string. If no workflow is waiting, the signal is silently dropped.
Expand Down
Loading