From 051894b7569931f9e7cbd271033dbe93f0112a14 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 12:56:16 -0700 Subject: [PATCH 1/5] feat(rivetkit): rerun workflows from inspector --- .../actors/workflow/actor-workflow-tab.tsx | 23 ++- .../actors/workflow/workflow-to-xyflow.ts | 9 + .../actors/workflow/workflow-visualizer.tsx | 102 ++++++++++ .../actors/workflow/xyflow-nodes.tsx | 2 + frontend/src/components/index.ts | 1 + frontend/src/components/ui/context-menu.tsx | 174 ++++++++++++++++++ .../packages/workflow-engine/src/index.ts | 2 + .../workflow-engine/tests/rerun.test.ts | 74 ++++++++ 8 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ui/context-menu.tsx create mode 100644 rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts diff --git a/frontend/src/components/actors/workflow/actor-workflow-tab.tsx b/frontend/src/components/actors/workflow/actor-workflow-tab.tsx index b9423ee7fa..5c49a11e7f 100644 --- a/frontend/src/components/actors/workflow/actor-workflow-tab.tsx +++ b/frontend/src/components/actors/workflow/actor-workflow-tab.tsx @@ -1,7 +1,7 @@ import { faSpinnerThird, Icon } from "@rivet-gg/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, type PropsWithChildren } from "react"; -import { toast } from "@/components"; +import { Button, toast } from "@/components"; import { useActorInspector } from "../actor-inspector-context"; import { actorInspectorQueriesKeys } from "../actor-inspector-context"; import { useDataProvider } from "../data-provider"; @@ -39,6 +39,11 @@ export function ActorWorkflowTab({ actorId }: ActorWorkflowTabProps) { const replayMutation = useMutation( inspector.actorWorkflowReplayMutation(actorId), ); + const canReplayCurrentStep = + inspector.inspectorProtocolVersion >= 4 && + currentStep?.entry.status !== "running" && + currentStep?.entry.retryCount !== undefined && + currentStep.entry.retryCount > 0; const replayingEntryId = replayMutation.isPending ? replayMutation.variables : undefined; @@ -108,6 +113,22 @@ export function ActorWorkflowTab({ actorId }: ActorWorkflowTabProps) { return (
+
+

+ Right-click a previous step to replay the workflow from + there. +

+ {canReplayCurrentStep && currentStep && ( + + )} +
void; }; @@ -157,6 +159,11 @@ function itemToNodeData( rawData: kind.data, nodeKey: item.key, entryId: id, + canReplay: + kind.type === "step" && + status !== "running" && + id !== options.currentStepId, + isReplaying: id === options.replayingEntryId, onReplayStep: options.onReplayStep, }; } @@ -206,6 +213,8 @@ function makeNode( completedAt: data.completedAt, rawData: data.rawData, entryId: data.entryId, + canReplay: data.canReplay, + isReplaying: data.isReplaying, onReplayStep: data.onReplayStep, }, }; diff --git a/frontend/src/components/actors/workflow/workflow-visualizer.tsx b/frontend/src/components/actors/workflow/workflow-visualizer.tsx index 6015fd6ec1..f6163bd6d4 100644 --- a/frontend/src/components/actors/workflow/workflow-visualizer.tsx +++ b/frontend/src/components/actors/workflow/workflow-visualizer.tsx @@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import "@xyflow/react/dist/style.css"; @@ -82,15 +83,25 @@ export function WorkflowVisualizer({ const [selectedNode, setSelectedNode] = useState( null, ); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + entryId: string; + isReplaying: boolean; + onReplayStep: (entryId: string) => void; + } | null>(null); + const contextMenuRef = useRef(null); const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { if (node.type === "workflow" && node.data) { setSelectedNode(node.data as WorkflowNodeData); + setContextMenu(null); } }, []); const onPaneClick = useCallback(() => { setSelectedNode(null); + setContextMenu(null); }, []); const replayState = selectedNode @@ -101,6 +112,65 @@ export function WorkflowVisualizer({ }) : null; + const onNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { + if (node.type !== "workflow" || !node.data) { + setContextMenu(null); + return; + } + + const data = node.data as WorkflowNodeData; + if ( + !data.canReplay || + !data.entryId || + typeof data.onReplayStep !== "function" + ) { + setContextMenu(null); + return; + } + + event.preventDefault(); + event.stopPropagation(); + setSelectedNode(data); + setContextMenu({ + x: event.clientX, + y: event.clientY, + entryId: data.entryId, + isReplaying: Boolean(data.isReplaying), + onReplayStep: data.onReplayStep, + }); + }, []); + + useEffect(() => { + if (!contextMenu) { + return; + } + + const onPointerDown = (event: PointerEvent) => { + if ( + contextMenuRef.current && + event.target instanceof globalThis.Node && + contextMenuRef.current.contains(event.target) + ) { + return; + } + setContextMenu(null); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setContextMenu(null); + } + }; + + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [contextMenu]); + return (
@@ -109,8 +179,37 @@ export function WorkflowVisualizer({ nodes={nodes} edges={edges} onNodeClick={onNodeClick} + onNodeContextMenu={onNodeContextMenu} onPaneClick={onPaneClick} /> + {contextMenu && ( +
event.preventDefault()} + > + +
+ )}
@@ -328,11 +427,13 @@ function WorkflowGraph({ nodes, edges, onNodeClick, + onNodeContextMenu, onPaneClick, }: { nodes: Node[]; edges: ReturnType["edges"]; onNodeClick: NodeMouseHandler; + onNodeContextMenu: NodeMouseHandler; onPaneClick: () => void; }) { const { fitView } = useReactFlow(); @@ -361,6 +462,7 @@ function WorkflowGraph({ edgesFocusable={false} panActivationKeyCode={null} onNodeClick={onNodeClick} + onNodeContextMenu={onNodeContextMenu} onPaneClick={onPaneClick} nodesDraggable={false} nodesConnectable={false} diff --git a/frontend/src/components/actors/workflow/xyflow-nodes.tsx b/frontend/src/components/actors/workflow/xyflow-nodes.tsx index 0b7df27f6c..b6ee3ccd98 100644 --- a/frontend/src/components/actors/workflow/xyflow-nodes.tsx +++ b/frontend/src/components/actors/workflow/xyflow-nodes.tsx @@ -177,6 +177,8 @@ export interface WorkflowNodeData { /** Raw entry data for the object inspector. */ rawData?: unknown; entryId?: string; + canReplay?: boolean; + isReplaying?: boolean; onReplayStep?: (entryId: string) => void; } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index cec27ab530..077ced1e1a 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -53,6 +53,7 @@ export * from "./ui/chart"; export * from "./ui/checkbox"; export * from "./ui/combobox"; export * from "./ui/command"; +export * from "./ui/context-menu"; export * from "./ui/dialog"; export * from "./ui/dialog-activity-indicator"; export * from "./ui/dropdown-menu"; diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx new file mode 100644 index 0000000000..a09fa98262 --- /dev/null +++ b/frontend/src/components/ui/context-menu.tsx @@ -0,0 +1,174 @@ +"use client"; + +import * as React from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../lib/utils"; + +interface ContextMenuState { + open: boolean; + x: number; + y: number; + setOpen: (open: boolean) => void; + setPosition: (x: number, y: number) => void; +} + +const ContextMenuStateContext = React.createContext( + null, +); + +function useContextMenuState(): ContextMenuState { + const value = React.useContext(ContextMenuStateContext); + if (!value) { + throw new Error("ContextMenu components must be used within ContextMenu"); + } + return value; +} + +function composeEventHandler( + original: ((event: E) => void) | undefined, + next: (event: E) => void, +) { + return (event: E) => { + original?.(event); + next(event); + }; +} + +export function ContextMenu({ children }: { children: React.ReactNode }) { + const [open, setOpen] = React.useState(false); + const [position, setPositionState] = React.useState({ x: 0, y: 0 }); + + const setPosition = React.useCallback((x: number, y: number) => { + setPositionState({ x, y }); + }, []); + + return ( + + {children} + + ); +} + +export function ContextMenuTrigger({ + children, + asChild, +}: { + children: React.ReactElement; + asChild?: boolean; +}) { + const { setOpen, setPosition } = useContextMenuState(); + + const triggerProps = { + onContextMenu: (event: React.MouseEvent) => { + event.preventDefault(); + setPosition(event.clientX, event.clientY); + setOpen(true); + }, + }; + + if (asChild) { + return React.cloneElement(children, { + ...triggerProps, + onContextMenu: composeEventHandler( + children.props.onContextMenu, + triggerProps.onContextMenu, + ), + }); + } + + return
{children}
; +} + +export function ContextMenuContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const { open, setOpen, x, y } = useContextMenuState(); + + React.useEffect(() => { + if (!open) { + return; + } + + const onPointerDown = () => setOpen(false); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [open, setOpen]); + + if (!open || typeof document === "undefined") { + return null; + } + + return createPortal( +
event.preventDefault()} + > + {children} +
, + document.body, + ); +} + +export function ContextMenuItem({ + children, + className, + disabled, + onSelect, +}: { + children: React.ReactNode; + className?: string; + disabled?: boolean; + onSelect?: () => void; +}) { + const { setOpen } = useContextMenuState(); + + return ( + + ); +} diff --git a/rivetkit-typescript/packages/workflow-engine/src/index.ts b/rivetkit-typescript/packages/workflow-engine/src/index.ts index d87ee9846d..3ba9681ceb 100644 --- a/rivetkit-typescript/packages/workflow-engine/src/index.ts +++ b/rivetkit-typescript/packages/workflow-engine/src/index.ts @@ -801,6 +801,7 @@ export async function replayWorkflowFromStep( ); } + await Promise.all( entriesToDelete.flatMap(({ entry }) => [ driver.delete(buildHistoryKey(entry.location)), @@ -858,6 +859,7 @@ function findReplayBoundaryEntry( return boundary; } + /** * Internal: Execute the workflow and return the result. */ diff --git a/rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts b/rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts new file mode 100644 index 0000000000..024f72a12f --- /dev/null +++ b/rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + loadStorage, + rerunWorkflowFromStep, + runWorkflow, + type WorkflowContextInterface, +} from "../src/index.js"; +import { InMemoryDriver } from "../src/testing.js"; + +describe("rerunWorkflowFromStep", () => { + it("reruns from the requested step", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + + const timeline: string[] = []; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("one", async () => { + timeline.push("one"); + }); + await ctx.step("two", async () => { + timeline.push("two"); + }); + await ctx.step("three", async () => { + timeline.push("three"); + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver).result; + timeline.length = 0; + + const storage = await loadStorage(driver); + const stepTwoIndex = storage.nameRegistry.indexOf("two"); + const stepTwo = Array.from(storage.history.entries.values()).find( + (entry) => + entry.kind.type === "step" && + entry.location[entry.location.length - 1] === stepTwoIndex, + ); + expect(stepTwo).toBeDefined(); + + const snapshot = await rerunWorkflowFromStep( + "wf-1", + driver, + stepTwo?.id, + ); + + expect(snapshot.entries.map((entry) => entry.id)).toHaveLength(1); + + await runWorkflow("wf-1", workflow, undefined, driver).result; + expect(timeline).toEqual(["two", "three"]); + }); + + it("reruns from the beginning when the target step is omitted", async () => { + const driver = new InMemoryDriver(); + driver.latency = 0; + + const timeline: string[] = []; + const workflow = async (ctx: WorkflowContextInterface) => { + await ctx.step("one", async () => { + timeline.push("one"); + }); + await ctx.step("two", async () => { + timeline.push("two"); + }); + }; + + await runWorkflow("wf-1", workflow, undefined, driver).result; + timeline.length = 0; + + await rerunWorkflowFromStep("wf-1", driver); + await runWorkflow("wf-1", workflow, undefined, driver).result; + + expect(timeline).toEqual(["one", "two"]); + }); +}); From 36dfa4e942a210764335b5dd525e82cb33e92522 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 12:57:22 -0700 Subject: [PATCH 2/5] docs(rivetkit): document workflow rerun inspector api --- website/src/content/docs/actors/debugging.mdx | 1 + website/src/metadata/skill-base-rivetkit.md | 1 + 2 files changed, 2 insertions(+) diff --git a/website/src/content/docs/actors/debugging.mdx b/website/src/content/docs/actors/debugging.mdx index b85fbd7cbc..27206133f6 100644 --- a/website/src/content/docs/actors/debugging.mdx +++ b/website/src/content/docs/actors/debugging.mdx @@ -651,6 +651,7 @@ Returns in-memory metrics for the current actor wake cycle. Metrics are not pers Includes counters for `action_calls`, `action_errors`, `action_duration_ms`, `connections_opened`, `connections_closed`, `sql_statements`, `sql_duration_ms`, and `kv_operations`. + ### Polling Inspector endpoints are safe to poll. For live monitoring, poll at 1-5 second intervals. The `/inspector/summary` endpoint is useful for periodic snapshots since it returns all data in a single request. diff --git a/website/src/metadata/skill-base-rivetkit.md b/website/src/metadata/skill-base-rivetkit.md index 8a0164d23b..b9707a3552 100644 --- a/website/src/metadata/skill-base-rivetkit.md +++ b/website/src/metadata/skill-base-rivetkit.md @@ -32,6 +32,7 @@ Use the inspector HTTP API to examine running actors. These endpoints are access - `GET /inspector/queue?limit=50` - queue status - `GET /inspector/traces?startMs=0&endMs=...&limit=1000` - trace spans (OTLP JSON) - `GET /inspector/workflow-history` - workflow history and status as JSON (`nameRegistry`, `entries`, `entryMetadata`) +- `POST /inspector/workflow/rerun` - rerun a workflow from a specific step or from the beginning - `GET /inspector/database/schema` - SQLite tables and views exposed by `c.db` - `GET /inspector/database/rows?table=...&limit=100&offset=0` - paged SQLite rows for a table or view - `POST /inspector/workflow/replay` - replay a workflow from a specific step or from the beginning From 003353a36ae5ac08e4d050aa4a3c833caa06fbaa Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 21:11:47 -0700 Subject: [PATCH 3/5] feat(rivetkit): add workflow replay controls --- .../actors/workflow/workflow-visualizer.tsx | 2 - .../workflow-engine/tests/rerun.test.ts | 74 ------------------- website/src/metadata/skill-base-rivetkit.md | 2 +- 3 files changed, 1 insertion(+), 77 deletions(-) delete mode 100644 rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts diff --git a/frontend/src/components/actors/workflow/workflow-visualizer.tsx b/frontend/src/components/actors/workflow/workflow-visualizer.tsx index f6163bd6d4..bbdf449de3 100644 --- a/frontend/src/components/actors/workflow/workflow-visualizer.tsx +++ b/frontend/src/components/actors/workflow/workflow-visualizer.tsx @@ -95,13 +95,11 @@ export function WorkflowVisualizer({ const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { if (node.type === "workflow" && node.data) { setSelectedNode(node.data as WorkflowNodeData); - setContextMenu(null); } }, []); const onPaneClick = useCallback(() => { setSelectedNode(null); - setContextMenu(null); }, []); const replayState = selectedNode diff --git a/rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts b/rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts deleted file mode 100644 index 024f72a12f..0000000000 --- a/rivetkit-typescript/packages/workflow-engine/tests/rerun.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - loadStorage, - rerunWorkflowFromStep, - runWorkflow, - type WorkflowContextInterface, -} from "../src/index.js"; -import { InMemoryDriver } from "../src/testing.js"; - -describe("rerunWorkflowFromStep", () => { - it("reruns from the requested step", async () => { - const driver = new InMemoryDriver(); - driver.latency = 0; - - const timeline: string[] = []; - const workflow = async (ctx: WorkflowContextInterface) => { - await ctx.step("one", async () => { - timeline.push("one"); - }); - await ctx.step("two", async () => { - timeline.push("two"); - }); - await ctx.step("three", async () => { - timeline.push("three"); - }); - }; - - await runWorkflow("wf-1", workflow, undefined, driver).result; - timeline.length = 0; - - const storage = await loadStorage(driver); - const stepTwoIndex = storage.nameRegistry.indexOf("two"); - const stepTwo = Array.from(storage.history.entries.values()).find( - (entry) => - entry.kind.type === "step" && - entry.location[entry.location.length - 1] === stepTwoIndex, - ); - expect(stepTwo).toBeDefined(); - - const snapshot = await rerunWorkflowFromStep( - "wf-1", - driver, - stepTwo?.id, - ); - - expect(snapshot.entries.map((entry) => entry.id)).toHaveLength(1); - - await runWorkflow("wf-1", workflow, undefined, driver).result; - expect(timeline).toEqual(["two", "three"]); - }); - - it("reruns from the beginning when the target step is omitted", async () => { - const driver = new InMemoryDriver(); - driver.latency = 0; - - const timeline: string[] = []; - const workflow = async (ctx: WorkflowContextInterface) => { - await ctx.step("one", async () => { - timeline.push("one"); - }); - await ctx.step("two", async () => { - timeline.push("two"); - }); - }; - - await runWorkflow("wf-1", workflow, undefined, driver).result; - timeline.length = 0; - - await rerunWorkflowFromStep("wf-1", driver); - await runWorkflow("wf-1", workflow, undefined, driver).result; - - expect(timeline).toEqual(["one", "two"]); - }); -}); diff --git a/website/src/metadata/skill-base-rivetkit.md b/website/src/metadata/skill-base-rivetkit.md index b9707a3552..8ee4f9dff7 100644 --- a/website/src/metadata/skill-base-rivetkit.md +++ b/website/src/metadata/skill-base-rivetkit.md @@ -32,7 +32,7 @@ Use the inspector HTTP API to examine running actors. These endpoints are access - `GET /inspector/queue?limit=50` - queue status - `GET /inspector/traces?startMs=0&endMs=...&limit=1000` - trace spans (OTLP JSON) - `GET /inspector/workflow-history` - workflow history and status as JSON (`nameRegistry`, `entries`, `entryMetadata`) -- `POST /inspector/workflow/rerun` - rerun a workflow from a specific step or from the beginning +- `POST /inspector/workflow/replay` - replay a workflow from a specific step or from the beginning - `GET /inspector/database/schema` - SQLite tables and views exposed by `c.db` - `GET /inspector/database/rows?table=...&limit=100&offset=0` - paged SQLite rows for a table or view - `POST /inspector/workflow/replay` - replay a workflow from a specific step or from the beginning From 227174cfd4603ea2d326386ecf2748b4027c746d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 21:37:19 -0700 Subject: [PATCH 4/5] fix(rivetkit): reject replay while workflow is in flight --- .../actors/workflow/workflow-to-xyflow.ts | 9 - .../actors/workflow/xyflow-nodes.tsx | 2 - frontend/src/components/index.ts | 1 - frontend/src/components/ui/context-menu.tsx | 174 ------------------ 4 files changed, 186 deletions(-) delete mode 100644 frontend/src/components/ui/context-menu.tsx diff --git a/frontend/src/components/actors/workflow/workflow-to-xyflow.ts b/frontend/src/components/actors/workflow/workflow-to-xyflow.ts index 76d0d013e3..334eb48c4c 100644 --- a/frontend/src/components/actors/workflow/workflow-to-xyflow.ts +++ b/frontend/src/components/actors/workflow/workflow-to-xyflow.ts @@ -71,8 +71,6 @@ type WorkflowNodeInput = { rawData?: unknown; name?: string; entryId?: string; - canReplay?: boolean; - isReplaying?: boolean; onReplayStep?: (entryId: string) => void; }; @@ -159,11 +157,6 @@ function itemToNodeData( rawData: kind.data, nodeKey: item.key, entryId: id, - canReplay: - kind.type === "step" && - status !== "running" && - id !== options.currentStepId, - isReplaying: id === options.replayingEntryId, onReplayStep: options.onReplayStep, }; } @@ -213,8 +206,6 @@ function makeNode( completedAt: data.completedAt, rawData: data.rawData, entryId: data.entryId, - canReplay: data.canReplay, - isReplaying: data.isReplaying, onReplayStep: data.onReplayStep, }, }; diff --git a/frontend/src/components/actors/workflow/xyflow-nodes.tsx b/frontend/src/components/actors/workflow/xyflow-nodes.tsx index b6ee3ccd98..0b7df27f6c 100644 --- a/frontend/src/components/actors/workflow/xyflow-nodes.tsx +++ b/frontend/src/components/actors/workflow/xyflow-nodes.tsx @@ -177,8 +177,6 @@ export interface WorkflowNodeData { /** Raw entry data for the object inspector. */ rawData?: unknown; entryId?: string; - canReplay?: boolean; - isReplaying?: boolean; onReplayStep?: (entryId: string) => void; } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 077ced1e1a..cec27ab530 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -53,7 +53,6 @@ export * from "./ui/chart"; export * from "./ui/checkbox"; export * from "./ui/combobox"; export * from "./ui/command"; -export * from "./ui/context-menu"; export * from "./ui/dialog"; export * from "./ui/dialog-activity-indicator"; export * from "./ui/dropdown-menu"; diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx deleted file mode 100644 index a09fa98262..0000000000 --- a/frontend/src/components/ui/context-menu.tsx +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; - -import * as React from "react"; -import { createPortal } from "react-dom"; -import { cn } from "../lib/utils"; - -interface ContextMenuState { - open: boolean; - x: number; - y: number; - setOpen: (open: boolean) => void; - setPosition: (x: number, y: number) => void; -} - -const ContextMenuStateContext = React.createContext( - null, -); - -function useContextMenuState(): ContextMenuState { - const value = React.useContext(ContextMenuStateContext); - if (!value) { - throw new Error("ContextMenu components must be used within ContextMenu"); - } - return value; -} - -function composeEventHandler( - original: ((event: E) => void) | undefined, - next: (event: E) => void, -) { - return (event: E) => { - original?.(event); - next(event); - }; -} - -export function ContextMenu({ children }: { children: React.ReactNode }) { - const [open, setOpen] = React.useState(false); - const [position, setPositionState] = React.useState({ x: 0, y: 0 }); - - const setPosition = React.useCallback((x: number, y: number) => { - setPositionState({ x, y }); - }, []); - - return ( - - {children} - - ); -} - -export function ContextMenuTrigger({ - children, - asChild, -}: { - children: React.ReactElement; - asChild?: boolean; -}) { - const { setOpen, setPosition } = useContextMenuState(); - - const triggerProps = { - onContextMenu: (event: React.MouseEvent) => { - event.preventDefault(); - setPosition(event.clientX, event.clientY); - setOpen(true); - }, - }; - - if (asChild) { - return React.cloneElement(children, { - ...triggerProps, - onContextMenu: composeEventHandler( - children.props.onContextMenu, - triggerProps.onContextMenu, - ), - }); - } - - return
{children}
; -} - -export function ContextMenuContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - const { open, setOpen, x, y } = useContextMenuState(); - - React.useEffect(() => { - if (!open) { - return; - } - - const onPointerDown = () => setOpen(false); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setOpen(false); - } - }; - - window.addEventListener("pointerdown", onPointerDown); - window.addEventListener("keydown", onKeyDown); - - return () => { - window.removeEventListener("pointerdown", onPointerDown); - window.removeEventListener("keydown", onKeyDown); - }; - }, [open, setOpen]); - - if (!open || typeof document === "undefined") { - return null; - } - - return createPortal( -
event.preventDefault()} - > - {children} -
, - document.body, - ); -} - -export function ContextMenuItem({ - children, - className, - disabled, - onSelect, -}: { - children: React.ReactNode; - className?: string; - disabled?: boolean; - onSelect?: () => void; -}) { - const { setOpen } = useContextMenuState(); - - return ( - - ); -} From 769e543fc57ba61a8aa854b67ddf3860c973b058 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 22:24:32 -0700 Subject: [PATCH 5/5] fix(workflow-engine): replay steps inside loops --- rivetkit-typescript/packages/workflow-engine/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/rivetkit-typescript/packages/workflow-engine/src/index.ts b/rivetkit-typescript/packages/workflow-engine/src/index.ts index 3ba9681ceb..d87ee9846d 100644 --- a/rivetkit-typescript/packages/workflow-engine/src/index.ts +++ b/rivetkit-typescript/packages/workflow-engine/src/index.ts @@ -801,7 +801,6 @@ export async function replayWorkflowFromStep( ); } - await Promise.all( entriesToDelete.flatMap(({ entry }) => [ driver.delete(buildHistoryKey(entry.location)), @@ -859,7 +858,6 @@ function findReplayBoundaryEntry( return boundary; } - /** * Internal: Execute the workflow and return the result. */