diff --git a/.changeset/local-explorer-workflows-restart-from-step.md b/.changeset/local-explorer-workflows-restart-from-step.md
new file mode 100644
index 0000000000..fe59388f4f
--- /dev/null
+++ b/.changeset/local-explorer-workflows-restart-from-step.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/local-explorer-ui": minor
+---
+
+Add a restart-from-step button to each row in the workflow instance step list
diff --git a/.changeset/sigint-dismisses-skills-prompt.md b/.changeset/sigint-dismisses-skills-prompt.md
deleted file mode 100644
index 95e4980c64..0000000000
--- a/.changeset/sigint-dismisses-skills-prompt.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"wrangler": patch
----
-
-Make Ctrl+C triggered during the skills-install prompt dismiss it permanently
-
-Previously, pressing Ctrl+C (SIGINT) during the "Would you like to install Cloudflare skills?" prompt terminated the process without writing the metadata file, causing the prompt to reappear on every subsequent `wrangler` invocation. A SIGINT handler is now registered around the prompt so that the metadata file is written with `accepted: "SIGINT"` before the process exits, preventing the prompt from being shown again.
diff --git a/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts b/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts
index 5e8983f8de..e201dc8795 100644
--- a/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts
+++ b/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts
@@ -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", () => {
diff --git a/packages/local-explorer-ui/src/__tests__/workflows/step-helpers.test.ts b/packages/local-explorer-ui/src/__tests__/workflows/step-helpers.test.ts
index ecfb4f3bcf..a62630f894 100644
--- a/packages/local-explorer-ui/src/__tests__/workflows/step-helpers.test.ts
+++ b/packages/local-explorer-ui/src/__tests__/workflows/step-helpers.test.ts
@@ -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 }) => {
@@ -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",
+ });
+ });
+});
diff --git a/packages/local-explorer-ui/src/components/workflows/StepRow.tsx b/packages/local-explorer-ui/src/components/workflows/StepRow.tsx
index b7a2068a8e..8124f46798 100644
--- a/packages/local-explorer-ui/src/components/workflows/StepRow.tsx
+++ b/packages/local-explorer-ui/src/components/workflows/StepRow.tsx
@@ -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";
@@ -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" ||
@@ -86,7 +88,7 @@ export const StepRow = memo(function StepRow({
{/* Collapsed row */}
+
+ {onRestartFromStep && (
+
+
+
+ )}
+
+
{hasDetails ? (
void;
}) {
const stepList = steps ?? [];
const [search, setSearch] = useState("");
@@ -356,6 +360,7 @@ const StepHistory = memo(function StepHistory({
step={step}
isExpanded={expandedStepKeys.has(key)}
onToggleExpanded={() => toggleStepExpanded(key)}
+ onRestartFromStep={onRestartFromStep}
/>
);
})
@@ -385,6 +390,9 @@ function InstanceDetailView() {
const [eventType, setEventType] = useState("");
const [eventPayload, setEventPayload] = useState("");
const [sendingEvent, setSendingEvent] = useState(false);
+ const [restartFromStepTarget, setRestartFromStepTarget] =
+ useState(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));
@@ -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 (
+ {/* Restart from step confirmation dialog */}
+ {
+ if (!open && !restartingFromStep) {
+ setRestartFromStepTarget(null);
+ }
+ }}
+ >
+
+
+
{/* Content */}
{error && (
@@ -749,7 +837,10 @@ function InstanceDetailView() {
{details.error && }
-
+
diff --git a/packages/wrangler/src/__tests__/agents-skills-install.test.ts b/packages/wrangler/src/__tests__/agents-skills-install.test.ts
index 65bc511eec..f233cb09de 100644
--- a/packages/wrangler/src/__tests__/agents-skills-install.test.ts
+++ b/packages/wrangler/src/__tests__/agents-skills-install.test.ts
@@ -6,7 +6,6 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers";
import { detectAgenticEnvironment } from "am-i-vibing";
import ci from "ci-info";
import { http, HttpResponse } from "msw";
-import prompts from "prompts";
import { afterEach, beforeEach, describe, test, vi } from "vitest";
import { sendMetricsEvent } from "../metrics/send-event";
import { mockConsoleMethods } from "./helpers/mock-console";
@@ -18,7 +17,6 @@ import type {
telemetryCurrentAgentSkillsInstalled as TelemetryFnType,
} from "../agents-skills-install";
import type * as SendEventModule from "../metrics/send-event";
-import type { Mock } from "vitest";
// Undo the global no-op mock from vitest.setup.ts so we test the real implementation
vi.unmock("../agents-skills-install");
@@ -195,23 +193,6 @@ describe("maybeInstallCloudflareSkillsGlobally", () => {
]);
});
- test("skips silently when metadata file has accepted: 'SIGINT' (Ctrl+C dismissal)", async ({
- expect,
- }) => {
- writeMetadataFile({
- version: 1,
- accepted: "SIGINT",
- date: "2025-01-01T00:00:00Z",
- });
- const maybeInstallCloudflareSkillsGlobally = await freshImport();
-
- await maybeInstallCloudflareSkillsGlobally(false);
-
- expect(mockRosieAgents).not.toHaveBeenCalled();
- expect(mockRosieInstall).not.toHaveBeenCalled();
- expect(sendMetricsEvent).not.toHaveBeenCalled();
- });
-
test("force=true ignores existing metadata file", async ({ expect }) => {
writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" });
const maybeInstallCloudflareSkillsGlobally = await freshImport();
@@ -431,62 +412,6 @@ describe("maybeInstallCloudflareSkillsGlobally", () => {
);
});
- test("writes SIGINT metadata when user presses Ctrl+C during the prompt", async ({
- expect,
- }) => {
- // Stub process.exit so the abort flow doesn't terminate the test runner.
- const exitSpy = vi
- .spyOn(process, "exit")
- .mockImplementation((() => {}) as never);
-
- // Simulate Ctrl+C: invoke the onState callback with
- // { aborted: true }, then resolve with { value: undefined }
- // just as the real prompts library does on abort.
- (prompts as unknown as Mock).mockImplementationOnce(
- ({ type, name, message, onState }) => {
- expect({ type, name }).toStrictEqual({
- type: "confirm",
- name: "value",
- });
- expect(message).toContain("Claude Code");
-
- // Trigger the abort handler (simulates Ctrl+C)
- onState({ aborted: true });
-
- return Promise.resolve({ value: undefined });
- }
- );
- const maybeInstallCloudflareSkillsGlobally = await freshImport();
-
- await maybeInstallCloudflareSkillsGlobally(false);
-
- // Should have warned the user that Ctrl+C was treated as a decline
- expect(std.warn).toContain(
- "Ctrl+C received — skipping Cloudflare skills installation. This prompt will not be shown again."
- );
-
- // The onState abort handler should have written metadata
- // with accepted: "SIGINT"
- const metadata = readMetadataFile();
- expect(metadata.accepted).toBe("SIGINT");
- expect(metadata.version).toBe(1);
-
- // Should not have attempted installation
- expect(mockRosieInstall).not.toHaveBeenCalled();
-
- // Should have sent a skipped metrics event
- expect(sendMetricsEvent).toHaveBeenCalledWith(
- "skills_install_skipped",
- { reason: "User dismissed (SIGINT)" },
- {}
- );
-
- // Should have called process.exit(1) after flushing metrics
- expect(exitSpy).toHaveBeenCalledWith(1);
-
- exitSpy.mockRestore();
- });
-
test("force=true installs skills without prompting", async ({ expect }) => {
// No mockConfirm — if a prompt fires, the test will fail with "Unexpected call to prompts"
const maybeInstallCloudflareSkillsGlobally = await freshImport();
@@ -997,69 +922,6 @@ describe("telemetryCurrentAgentSkillsInstalled", () => {
expect(result).toBe("manual");
});
- test("resolves to 'manual' when metadata has accepted: 'SIGINT' at primary path", async ({
- expect,
- }) => {
- vi.mocked(detectAgenticEnvironment).mockReturnValue({
- isAgentic: true,
- id: "claude-code",
- name: "Claude Code",
- type: "agent",
- });
- createAgentDir(".claude");
- const claudeSkills = path.join(os.homedir(), ".claude", "skills");
- mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true });
- const claudeGlobalSkillsPath = path.join(os.homedir(), ".claude", "skills");
- writeMetadataFile({
- version: 1,
- accepted: "SIGINT",
- date: new Date().toISOString(),
- detectedAgents: [
- {
- name: "Claude Code",
- rosie: { id: "claude", globalPath: claudeGlobalSkillsPath },
- },
- ],
- });
- mockGitHubSkillsApi(["cloudflare", "wrangler"]);
- const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport();
-
- const result = await telemetryCurrentAgentSkillsInstalled();
-
- expect(result).toBe("manual");
- });
-
- test("resolves to 'manual' when metadata has accepted: 'SIGINT' at alternativeGlobalPath", async ({
- expect,
- }) => {
- vi.mocked(detectAgenticEnvironment).mockReturnValue({
- isAgentic: true,
- id: "opencode",
- name: "OpenCode",
- type: "agent",
- });
- createAgentDir(".config/opencode");
- const agentsSkills = path.join(os.homedir(), ".agents", "skills");
- mkdirSync(path.join(agentsSkills, "cloudflare"), { recursive: true });
- writeMetadataFile({
- version: 1,
- accepted: "SIGINT",
- date: new Date().toISOString(),
- detectedAgents: [
- {
- name: "Cline, Dexto, Warp",
- rosie: { id: "warp", globalPath: agentsSkills },
- },
- ],
- });
- mockGitHubSkillsApi(["cloudflare", "wrangler"]);
- const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport();
-
- const result = await telemetryCurrentAgentSkillsInstalled();
-
- expect(result).toBe("manual");
- });
-
test("uses cached GitHub API response within TTL", async ({ expect }) => {
vi.mocked(detectAgenticEnvironment).mockReturnValue({
isAgentic: true,
diff --git a/packages/wrangler/src/agents-skills-install.ts b/packages/wrangler/src/agents-skills-install.ts
index c6d3cff317..8ba80be4d7 100644
--- a/packages/wrangler/src/agents-skills-install.ts
+++ b/packages/wrangler/src/agents-skills-install.ts
@@ -7,13 +7,12 @@ import {
} from "@cloudflare/workers-utils";
import { detectAgenticEnvironment } from "am-i-vibing";
import ci from "ci-info";
-import prompts from "prompts";
import { install as rosieInstall, agents as rosieAgents } from "rosie-skills";
import { fetch } from "undici";
+import { confirm } from "./dialogs";
import isInteractive from "./is-interactive";
import { logger } from "./logger";
import { sendMetricsEvent } from "./metrics";
-import { allMetricsDispatchesCompleted } from "./metrics/metrics-dispatcher";
/**
* Detects AI coding agents installed on the user's machine and, if
@@ -96,48 +95,12 @@ export async function maybeInstallCloudflareSkillsGlobally(
return;
}
- let accepted: boolean;
- let sigintReceived = false;
- if (force) {
- accepted = true;
- } else {
- // Use prompts directly (instead of the shared `confirm()` helper) so
- // we can intercept the abort (Ctrl+C) and write SIGINT metadata
- // before the process exits. The prompts library's readline interface
- // swallows SIGINT — it never reaches `process.on("SIGINT")` — so this
- // `onState` callback is the only reliable place to handle it.
- const { value } = await prompts({
- type: "confirm",
- name: "value",
- message: `Wrangler detected the following AI coding agents: ${detectedAgents.map(({ name }) => name).join(", ")}. Would you like to install Cloudflare skills for them?`,
- initial: true,
- onState: (state) => {
- if (state.aborted) {
- sigintReceived = true;
- logger.warn(
- "Ctrl+C received — skipping Cloudflare skills installation. This prompt will not be shown again."
- );
- // Write metadata synchronously so it survives the exit.
- writeSkillsInstallMetadataFile({
- version: 1,
- accepted: "SIGINT",
- date: new Date().toISOString(),
- detectedAgents,
- });
- }
- },
- });
- accepted = value;
- }
-
- if (sigintReceived) {
- // Metadata was already written in the onState callback.
- // Send metrics and wait for the dispatch to complete before exiting.
- sendResultMetricsEvent({ skippedBecause: "User dismissed (SIGINT)" });
- await allMetricsDispatchesCompleted();
- // Note: the return is unnecessary but it guards against tests that stub process.exit
- return process.exit(1);
- }
+ const accepted =
+ force ||
+ (await confirm(
+ `Wrangler detected the following AI coding agents: ${detectedAgents.map(({ name }) => name).join(", ")}. Would you like to install Cloudflare skills for them?`,
+ { defaultValue: true, fallbackValue: false }
+ ));
if (!accepted) {
writeSkillsInstallMetadataFile({
@@ -237,16 +200,8 @@ type AgentInfo = {
interface SkillsInstallMetadata {
/** Schema version for forward-compatibility. Currently always `1`. */
version: 1;
- /**
- * Whether the user accepted the prompt to install skills.
- *
- * - `true` — the user explicitly accepted.
- * - `false` — the user explicitly declined.
- * - `"SIGINT"` — the user dismissed the prompt via Ctrl+C / SIGINT before
- * answering. Treated as a decline but stored separately so we can
- * distinguish these users in telemetry.
- */
- accepted: boolean | "SIGINT";
+ /** Whether the user accepted the prompt to install skills. */
+ accepted: boolean;
/** ISO date string of when the user was prompted. */
date: string;
/** All agents detected on the user's machine. */
@@ -698,7 +653,7 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise {
@@ -745,7 +700,7 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise