From 75e652aa24905dd01d5841f293e36ddadc4c0ef8 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Mon, 8 Jun 2026 15:36:03 +0100 Subject: [PATCH 1/2] Revert "Make Ctrl+C triggered during the skills-install prompt dismiss it permanently (#14172)" (#14222) --- .changeset/sigint-dismisses-skills-prompt.md | 7 - .../__tests__/agents-skills-install.test.ts | 138 ------------------ .../wrangler/src/agents-skills-install.ts | 67 ++------- 3 files changed, 11 insertions(+), 201 deletions(-) delete mode 100644 .changeset/sigint-dismisses-skills-prompt.md 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/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 Date: Mon, 8 Jun 2026 19:48:04 +0100 Subject: [PATCH 2/2] [local-explorer-ui] Add restart from step button to local explorer UI (#14154) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...al-explorer-workflows-restart-from-step.md | 5 + .../src/__e2e__/workflows/workflow.spec.ts | 60 ++++++++++++ .../__tests__/workflows/step-helpers.test.ts | 53 +++++++++++ .../src/components/workflows/StepRow.tsx | 26 +++++- .../src/components/workflows/types.ts | 35 +++++++ .../workflows/$workflowName/$instanceId.tsx | 93 ++++++++++++++++++- 6 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 .changeset/local-explorer-workflows-restart-from-step.md 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/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); + } + }} + > + +
+ {/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */} + + Restart from this step? + +
+ +
+

+ This will rerun the instance from{" "} + + {getStepDisplayName(restartFromStepTarget?.name)} + + . Saved state for this step and later steps will be cleared, + while earlier completed steps are kept. +

+
+ +
+ + +
+
+
+ {/* Content */}
{error && ( @@ -749,7 +837,10 @@ function InstanceDetailView() { {details.error && } - +