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 && ( + + )} +
( 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) { @@ -101,6 +110,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 +177,37 @@ export function WorkflowVisualizer({ nodes={nodes} edges={edges} onNodeClick={onNodeClick} + onNodeContextMenu={onNodeContextMenu} onPaneClick={onPaneClick} /> + {contextMenu && ( +
event.preventDefault()} + > + +
+ )}
@@ -328,11 +425,13 @@ function WorkflowGraph({ nodes, edges, onNodeClick, + onNodeContextMenu, onPaneClick, }: { nodes: Node[]; edges: ReturnType["edges"]; onNodeClick: NodeMouseHandler; + onNodeContextMenu: NodeMouseHandler; onPaneClick: () => void; }) { const { fitView } = useReactFlow(); @@ -361,6 +460,7 @@ function WorkflowGraph({ edgesFocusable={false} panActivationKeyCode={null} onNodeClick={onNodeClick} + onNodeContextMenu={onNodeContextMenu} onPaneClick={onPaneClick} nodesDraggable={false} nodesConnectable={false} 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..8ee4f9dff7 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/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