Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/local-explorer-workflows-restart-from-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/local-explorer-ui": minor
---

Add a restart-from-step button to each row in the workflow instance step list
7 changes: 0 additions & 7 deletions .changeset/sigint-dismisses-skills-prompt.md

This file was deleted.

60 changes: 60 additions & 0 deletions packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,66 @@ describe("Workflows", () => {
await waitForBreadcrumbText("Workflows", { timeout: 10_000 });
await waitForBreadcrumbText(WORKFLOW_NAME, { timeout: 10_000 });
});

test("opens restart-from-step confirmation dialog from a step row", async () => {
const workflow = await seedWorkflow(WORKFLOW_NAME);
await navigateToWorkflow(WORKFLOW_NAME);

await waitForText(workflow.id, { timeout: 10_000 });

const instanceRow = page
.locator("div.border-b")
.filter({ hasText: workflow.id })
.first();
await instanceRow.click();

await waitForText("Step History", { timeout: 10_000 });
await waitForText("greet", { timeout: 10_000 });

await page
.getByRole("button", { name: "Restart from this step" })
.first()
.click();

await waitForSelector('[role="dialog"]', { timeout: 5_000 });
await waitForText("Restart from this step?");
await waitForText(
"Saved state for this step and later steps will be cleared"
);
});

test("cancels restart-from-step confirmation dialog", async () => {
const workflow = await seedWorkflow(WORKFLOW_NAME);
await navigateToWorkflow(WORKFLOW_NAME);

await waitForText(workflow.id, { timeout: 10_000 });

const instanceRow = page
.locator("div.border-b")
.filter({ hasText: workflow.id })
.first();
await instanceRow.click();

await waitForText("Step History", { timeout: 10_000 });
await waitForText("greet", { timeout: 10_000 });

await page
.getByRole("button", { name: "Restart from this step" })
.first()
.click();

await waitForSelector('[role="dialog"]', { timeout: 5_000 });

await page
.getByRole("dialog")
.getByRole("button", { name: "Cancel" })
.click();

await page.waitForSelector('[role="dialog"]', {
state: "hidden",
timeout: 5_000,
});
});
});

describe("status filter", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getStepDisplayName,
getStepKey,
} from "../../components/workflows/StepRow";
import { getRestartFromStepParam } from "../../components/workflows/types";

describe("getStepKey", () => {
test("produces key from type and name", ({ expect }) => {
Expand Down Expand Up @@ -53,3 +54,55 @@ describe("getStepDisplayName", () => {
expect(getStepDisplayName("step-0")).toBe("step");
});
});

describe("getRestartFromStepParam", () => {
test("strips counter suffix into name + count for do steps", ({ expect }) => {
expect(
getRestartFromStepParam({ type: "step", name: "generate-summary-1" })
).toEqual({ name: "generate-summary", count: 1, type: "do" });
});

test("preserves multi-digit counter as count", ({ expect }) => {
expect(
getRestartFromStepParam({ type: "step", name: "process-item-12" })
).toEqual({ name: "process-item", count: 12, type: "do" });
});

test("maps sleep step type to sleep", ({ expect }) => {
expect(getRestartFromStepParam({ type: "sleep", name: "wait-1" })).toEqual({
name: "wait",
count: 1,
type: "sleep",
});
});

test("maps waitForEvent step type to waitForEvent", ({ expect }) => {
expect(
getRestartFromStepParam({
type: "waitForEvent",
name: "trigger-2",
})
).toEqual({ name: "trigger", count: 2, type: "waitForEvent" });
});

test("omits count when name has no counter suffix", ({ expect }) => {
expect(getRestartFromStepParam({ type: "step", name: "greet" })).toEqual({
name: "greet",
type: "do",
});
});

test("omits type when step has unknown type", ({ expect }) => {
expect(getRestartFromStepParam({ name: "foo-1" })).toEqual({
name: "foo",
count: 1,
});
});

test("returns empty name when step has no name", ({ expect }) => {
expect(getRestartFromStepParam({ type: "step" })).toEqual({
name: "",
type: "do",
});
});
});
26 changes: 23 additions & 3 deletions packages/local-explorer-ui/src/components/workflows/StepRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Loader } from "@cloudflare/kumo";
import { CheckIcon, PlusIcon } from "@phosphor-icons/react";
import { Loader, Tooltip } from "@cloudflare/kumo";
import { ArrowClockwiseIcon, CheckIcon, PlusIcon } from "@phosphor-icons/react";
import { memo } from "react";
import { CopyButton } from "./CopyButton";
import { formatDuration, formatJson } from "./helpers";
Expand Down Expand Up @@ -72,10 +72,12 @@ export const StepRow = memo(function StepRow({
step,
isExpanded,
onToggleExpanded,
onRestartFromStep,
}: {
step: StepData;
isExpanded: boolean;
onToggleExpanded: () => void;
onRestartFromStep?: (step: StepData) => void;
}): JSX.Element {
const hasDetails =
step.type === "step" ||
Expand All @@ -86,7 +88,7 @@ export const StepRow = memo(function StepRow({
<div className="border-b border-kumo-fill p-1 last:border-b-0">
{/* Collapsed row */}
<div
className={`grid h-10 grid-cols-[20px_1fr_160px_160px_80px_24px] items-center gap-3 rounded-lg px-2 transition-colors ${hasDetails ? "cursor-pointer hover:bg-kumo-fill" : ""}`}
className={`grid h-10 grid-cols-[20px_1fr_160px_160px_80px_28px_24px] items-center gap-3 rounded-lg px-2 transition-colors ${hasDetails ? "cursor-pointer hover:bg-kumo-fill" : ""}`}
onClick={hasDetails ? onToggleExpanded : undefined}
>
<StepStatusIcon
Expand Down Expand Up @@ -114,6 +116,24 @@ export const StepRow = memo(function StepRow({
{formatDuration(step.start, step.end)}
</span>

<div className="flex items-center justify-center">
{onRestartFromStep && (
<Tooltip content="Restart from this step" asChild>
<button
aria-label="Restart from this step"
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border border-kumo-fill bg-kumo-base text-kumo-default transition-colors hover:bg-kumo-fill disabled:cursor-not-allowed disabled:opacity-40"
onClick={(event) => {
event.stopPropagation();
onRestartFromStep(step);
}}
type="button"
>
<ArrowClockwiseIcon size={14} />
</button>
</Tooltip>
)}
</div>

<div className="flex items-center justify-center">
{hasDetails ? (
<PlusIcon
Expand Down
35 changes: 35 additions & 0 deletions packages/local-explorer-ui/src/components/workflows/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@ export interface InstanceDetails {

export type Action = "pause" | "resume" | "restart" | "terminate";

export interface RestartFromStepParam {
name: string;
count?: number;
type?: "do" | "sleep" | "waitForEvent";
}

/**
* Convert a step row's data into the `from` payload accepted by the
* change-instance-status API when restarting from a specific step.
*
* The runtime stores step names as `name-N`, where `N` is a 1-based counter
* disambiguating multiple steps that share the same logical name. The API
* expects the logical name + the counter as separate fields.
*/
export function getRestartFromStepParam(step: StepData): RestartFromStepParam {
const rawName = step.name ?? "";
const suffixMatch = rawName.match(/-(\d+)$/);
const name = suffixMatch ? rawName.slice(0, -suffixMatch[0].length) : rawName;
const count = suffixMatch ? Number(suffixMatch[1]) : undefined;

// Local step types use "step" while the API expects "do" for the same concept.
let type: RestartFromStepParam["type"] | undefined;
if (step.type === "step") {
type = "do";
} else if (step.type === "sleep" || step.type === "waitForEvent") {
type = step.type;
}

return {
name,
...(count !== undefined ? { count } : {}),
...(type !== undefined ? { type } : {}),
};
}

const TERMINAL_STATUSES = new Set(["complete", "errored", "terminated"]);

export function getAvailableActions(status: string): Action[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ import {
} from "../../../components/workflows/StepRow";
import {
getAvailableActions,
getRestartFromStepParam,
isTerminalStatus,
} from "../../../components/workflows/types";
import type {
Action,
InstanceDetails,
StepData,
} from "../../../components/workflows/types";

export const Route = createFileRoute("/workflows/$workflowName/$instanceId")({
Expand Down Expand Up @@ -241,8 +243,10 @@ const STEP_TYPE_FILTERS = [

const StepHistory = memo(function StepHistory({
steps,
onRestartFromStep,
}: {
steps: InstanceDetails["steps"];
onRestartFromStep?: (step: StepData) => void;
}) {
const stepList = steps ?? [];
const [search, setSearch] = useState("");
Expand Down Expand Up @@ -356,6 +360,7 @@ const StepHistory = memo(function StepHistory({
step={step}
isExpanded={expandedStepKeys.has(key)}
onToggleExpanded={() => toggleStepExpanded(key)}
onRestartFromStep={onRestartFromStep}
/>
);
})
Expand Down Expand Up @@ -385,6 +390,9 @@ function InstanceDetailView() {
const [eventType, setEventType] = useState("");
const [eventPayload, setEventPayload] = useState("");
const [sendingEvent, setSendingEvent] = useState(false);
const [restartFromStepTarget, setRestartFromStepTarget] =
useState<StepData | null>(null);
const [restartingFromStep, setRestartingFromStep] = useState(false);

// Track last-seen JSON so we skip state updates when polled data is unchanged
const lastDetailsJsonRef = useRef(JSON.stringify(loaderData.details));
Expand Down Expand Up @@ -471,6 +479,38 @@ function InstanceDetailView() {
[params.workflowName, instanceId, fetchDetails]
);

const handleRestartFromStep = useCallback((step: StepData) => {
setRestartFromStepTarget(step);
}, []);

const handleConfirmRestartFromStep = useCallback(async () => {
if (!restartFromStepTarget) {
return;
}
setRestartingFromStep(true);
setError(null);
try {
await workflowsChangeInstanceStatus({
path: {
workflow_name: params.workflowName,
instance_id: instanceId,
},
body: {
action: "restart",
from: getRestartFromStepParam(restartFromStepTarget),
},
});
await fetchDetails();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to restart from step"
);
} finally {
setRestartFromStepTarget(null);
setRestartingFromStep(false);
}
}, [restartFromStepTarget, params.workflowName, instanceId, fetchDetails]);

return (
<div className="flex h-full flex-col">
<Breadcrumbs
Expand Down Expand Up @@ -737,6 +777,54 @@ function InstanceDetailView() {
</Dialog>
</Dialog.Root>

{/* Restart from step confirmation dialog */}
<Dialog.Root
open={restartFromStepTarget !== null}
onOpenChange={(open) => {
if (!open && !restartingFromStep) {
setRestartFromStepTarget(null);
}
}}
>
<Dialog size="lg" className="w-lg">
<div className="border-b border-kumo-fill px-6 py-4">
{/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */}
<Dialog.Title className="text-lg font-semibold text-kumo-default">
Restart from this step?
</Dialog.Title>
</div>

<div className="px-6 py-5">
<p className="text-sm text-kumo-subtle">
This will rerun the instance from{" "}
<span className="font-medium text-kumo-default">
{getStepDisplayName(restartFromStepTarget?.name)}
</span>
. Saved state for this step and later steps will be cleared,
while earlier completed steps are kept.
</p>
</div>

<div className="flex justify-end gap-2 border-t border-kumo-fill px-6 py-4">
<Button
variant="secondary"
onClick={() => setRestartFromStepTarget(null)}
disabled={restartingFromStep}
>
Cancel
</Button>
<Button
variant="primary"
disabled={restartingFromStep}
loading={restartingFromStep}
onClick={() => void handleConfirmRestartFromStep()}
>
Restart from step
</Button>
</div>
</Dialog>
</Dialog.Root>

{/* Content */}
<div className="space-y-6 px-4 py-6 sm:space-y-8 sm:px-6 lg:px-12 xl:px-20 2xl:px-32">
{error && (
Expand All @@ -749,7 +837,10 @@ function InstanceDetailView() {

{details.error && <ErrorCard error={details.error} />}

<StepHistory steps={details.steps} />
<StepHistory
steps={details.steps}
onRestartFromStep={handleRestartFromStep}
/>
</div>
</div>
</div>
Expand Down
Loading
Loading