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 will be retried with a fresh retry budget. Previous failed attempts will be discarded."
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
* drops failed step attempts so the failing step starts with a fresh retry
* budget; completed steps stay cached.
*/
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
17 changes: 17 additions & 0 deletions packages/openworkflow/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,23 @@ 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; failed step attempts are discarded so
* the failing step starts with a fresh retry budget.
* @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
7 changes: 7 additions & 0 deletions packages/openworkflow/core/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export interface Backend {
cancelWorkflowRun(
params: Readonly<CancelWorkflowRunParams>,
): Promise<WorkflowRun>;
resumeWorkflowRun(
params: Readonly<ResumeWorkflowRunParams>,
): Promise<WorkflowRun>;

// Step Attempts
createStepAttempt(
Expand Down Expand Up @@ -143,6 +146,10 @@ export interface CancelWorkflowRunParams {
workflowRunId: string;
}

export interface ResumeWorkflowRunParams {
workflowRunId: string;
}

export interface CreateStepAttemptParams {
workflowRunId: string;
workerId: string;
Expand Down
Loading