diff --git a/pyproject.toml b/pyproject.toml index 0666ce5..6018e61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-dev" -version = "0.0.34" +version = "0.0.35" description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/dev/models/execution.py b/src/uipath/dev/models/execution.py index 8682688..3253ab5 100644 --- a/src/uipath/dev/models/execution.py +++ b/src/uipath/dev/models/execution.py @@ -44,6 +44,8 @@ def __init__( self.traces: list[TraceData] = [] self.logs: list[LogData] = [] self.error: UiPathErrorContract | None = None + self.breakpoints: list[str] = [] + self.breakpoint_node: str | None = None self.chat_events = ChatEvents() @property diff --git a/src/uipath/dev/server/debug_bridge.py b/src/uipath/dev/server/debug_bridge.py index 6c5eb39..4e9055e 100644 --- a/src/uipath/dev/server/debug_bridge.py +++ b/src/uipath/dev/server/debug_bridge.py @@ -24,13 +24,11 @@ class WebDebugBridge: def __init__(self, mode: ExecutionMode = ExecutionMode.RUN): """Initialize the web debug bridge.""" self._mode = mode - self._auto_resume = mode == ExecutionMode.RUN + self._should_wait = False self._resume_event = asyncio.Event() self._resume_data: dict[str, Any] | None = None self._terminate_event = asyncio.Event() - self._breakpoints: list[str] | Literal["*"] = ( - [] if mode == ExecutionMode.RUN else "*" - ) + self._breakpoints: list[str] | Literal["*"] = [] # Callbacks (wired by RunService) self.on_execution_started: Callable[[], None] | None = None @@ -70,6 +68,7 @@ async def emit_breakpoint_hit( ) -> None: """Notify debugger that a breakpoint was hit.""" logger.debug("Breakpoint hit: %s", breakpoint_result) + self._should_wait = True if self.on_breakpoint_hit: self.on_breakpoint_hit(breakpoint_result) @@ -82,6 +81,7 @@ async def emit_execution_suspended( return if runtime_result.trigger.trigger_type == UiPathResumeTriggerType.API: + self._should_wait = True if self.on_breakpoint_hit: self.on_breakpoint_hit( UiPathBreakpointResult( @@ -112,10 +112,10 @@ async def emit_execution_error(self, error: str) -> None: async def wait_for_resume(self) -> Any: """Wait for resume command from debugger.""" - if self._auto_resume: - self._auto_resume = False # Only auto-resume the first (initial) pause + if not self._should_wait: return {} + self._should_wait = False self._resume_event.clear() await self._resume_event.wait() diff --git a/src/uipath/dev/server/frontend/src/App.tsx b/src/uipath/dev/server/frontend/src/App.tsx index 60dab86..069e78d 100644 --- a/src/uipath/dev/server/frontend/src/App.tsx +++ b/src/uipath/dev/server/frontend/src/App.tsx @@ -13,6 +13,7 @@ export default function App() { runs, selectedRunId, setRuns, + upsertRun, selectRun, setTraces, setLogs, @@ -41,8 +42,8 @@ export default function App() { if (!selectedRunId) return; ws.subscribe(selectedRunId); - // Fetch full run details - getRun(selectedRunId).then((detail) => { + const applyRunDetail = (detail: Awaited>) => { + upsertRun(detail); setTraces(selectedRunId, detail.traces); setLogs(selectedRunId, detail.logs); // Convert messages to chat format (server uses camelCase aliases) @@ -73,10 +74,25 @@ export default function App() { }; }); setChatMessages(selectedRunId, chatMsgs); - }).catch(console.error); + }; - return () => ws.unsubscribe(selectedRunId); - }, [selectedRunId, ws, setTraces, setLogs, setChatMessages]); + // Fetch full run details (includes fresh status in case we missed run.updated events) + getRun(selectedRunId).then(applyRunDetail).catch(console.error); + + // Safety net: re-fetch if run is still in progress after WS subscribe + initial fetch. + // Covers the race where the run completes before WS subscription is processed. + const retryTimer = setTimeout(() => { + const run = useRunStore.getState().runs[selectedRunId]; + if (run && (run.status === "pending" || run.status === "running")) { + getRun(selectedRunId).then(applyRunDetail).catch(console.error); + } + }, 2000); + + return () => { + clearTimeout(retryTimer); + ws.unsubscribe(selectedRunId); + }; + }, [selectedRunId, ws, upsertRun, setTraces, setLogs, setChatMessages]); const handleRunCreated = (runId: string) => { navigate(`#/runs/${runId}/traces`); diff --git a/src/uipath/dev/server/frontend/src/api/client.ts b/src/uipath/dev/server/frontend/src/api/client.ts index 92bf797..e5731fe 100644 --- a/src/uipath/dev/server/frontend/src/api/client.ts +++ b/src/uipath/dev/server/frontend/src/api/client.ts @@ -48,11 +48,12 @@ export async function createRun( entrypoint: string, inputData: Record, mode: string = "run", + breakpoints: string[] = [], ): Promise { return fetchJson(`${BASE}/runs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ entrypoint, input_data: inputData, mode }), + body: JSON.stringify({ entrypoint, input_data: inputData, mode, breakpoints }), }); } diff --git a/src/uipath/dev/server/frontend/src/api/websocket.ts b/src/uipath/dev/server/frontend/src/api/websocket.ts index 932f819..58d3e53 100644 --- a/src/uipath/dev/server/frontend/src/api/websocket.ts +++ b/src/uipath/dev/server/frontend/src/api/websocket.ts @@ -107,4 +107,8 @@ export class WsClient { debugStop(runId: string): void { this.send("debug.stop", { run_id: runId }); } + + setBreakpoints(runId: string, breakpoints: string[]): void { + this.send("debug.set_breakpoints", { run_id: runId, breakpoints }); + } } diff --git a/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx b/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx index df02c05..1df59f6 100644 --- a/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx +++ b/src/uipath/dev/server/frontend/src/components/debug/DebugControls.tsx @@ -1,46 +1,67 @@ +import { useRunStore } from "../../store/useRunStore"; import type { WsClient } from "../../api/websocket"; interface Props { runId: string; status: string; ws: WsClient; + breakpointNode?: string | null; } -export default function DebugControls({ runId, status, ws }: Props) { +export default function DebugControls({ runId, status, ws, breakpointNode }: Props) { const isSuspended = status === "suspended"; + // Sync breakpoints to server before sending a debug command to avoid race conditions + const syncBreakpointsThenSend = (command: "step" | "continue" | "stop") => { + const bpMap = useRunStore.getState().breakpoints[runId] ?? {}; + ws.setBreakpoints(runId, Object.keys(bpMap)); + if (command === "step") ws.debugStep(runId); + else if (command === "continue") ws.debugContinue(runId); + else ws.debugStop(runId); + }; + return (
- - - - - {isSuspended ? "Paused at breakpoint" : `Status: ${status}`} + + Debug + + syncBreakpointsThenSend("step")} disabled={!isSuspended} color="var(--info)" active={isSuspended} /> + syncBreakpointsThenSend("continue")} disabled={!isSuspended} color="var(--success)" active={isSuspended} /> + syncBreakpointsThenSend("stop")} disabled={!isSuspended} color="var(--error)" active={isSuspended} /> + + {isSuspended + ? breakpointNode + ? `Paused at ${breakpointNode}` + : "Paused" + : status}
); } + +function DebugBtn({ label, onClick, disabled, color, active }: { + label: string; onClick: () => void; disabled: boolean; color: string; active: boolean; +}) { + return ( + + ); +} diff --git a/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx b/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx index 9a71bc8..8ee49ff 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx @@ -15,6 +15,7 @@ import ELK, { type ElkNode, type ElkExtendedEdge } from "elkjs/lib/elk.bundled.j import type { TraceSpan } from "../../types/run"; import type { GraphData } from "../../types/graph"; import { getEntrypointGraph } from "../../api/client"; +import { useRunStore } from "../../store/useRunStore"; import StartNode from "./nodes/StartNode"; import EndNode from "./nodes/EndNode"; import ModelNode from "./nodes/ModelNode"; @@ -339,15 +340,62 @@ interface Props { entrypoint: string; traces: TraceSpan[]; runId: string; + breakpointNode?: string | null; + onBreakpointChange?: (breakpoints: string[]) => void; } -export default function GraphPanel({ entrypoint, traces, runId }: Props) { +export default function GraphPanel({ entrypoint, traces, runId, breakpointNode, onBreakpointChange }: Props) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [loading, setLoading] = useState(true); const layoutRef = useRef(0); const rfInstance = useRef(null); + const bpMap = useRunStore((s) => s.breakpoints[runId]); + const toggleBreakpoint = useRunStore((s) => s.toggleBreakpoint); + + const onNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + if (node.type === "groupNode") return; + // For compound children, extract the plain ID from "parentId/childId" + const plainId = node.id.includes("/") ? node.id.split("/").pop()! : node.id; + toggleBreakpoint(runId, plainId); + // Immediately notify parent with the updated breakpoints + const updated = useRunStore.getState().breakpoints[runId] ?? {}; + onBreakpointChange?.(Object.keys(updated)); + }, + [runId, toggleBreakpoint, onBreakpointChange], + ); + + // Inject hasBreakpoint into node data when breakpoints change + useEffect(() => { + setNodes((nds) => + nds.map((n) => { + if (n.type === "groupNode") return n; + const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id; + const has = !!(bpMap && bpMap[plainId]); + return has !== !!n.data?.hasBreakpoint + ? { ...n, data: { ...n.data, hasBreakpoint: has } } + : n; + }), + ); + }, [bpMap, setNodes]); + + // Highlight the node where execution is paused at a breakpoint + useEffect(() => { + setNodes((nds) => + nds.map((n) => { + if (n.type === "groupNode") return n; + const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id; + const label = n.data?.label as string | undefined; + const paused = breakpointNode != null && (plainId === breakpointNode || label === breakpointNode); + return paused !== !!n.data?.isPausedHere + ? { ...n, data: { ...n.data, isPausedHere: paused } } + : n; + }), + ); + }, [breakpointNode, setNodes]); + const nodeStatusMap = useCallback(() => { const map: Record = {}; traces.forEach((t) => { @@ -374,7 +422,16 @@ export default function GraphPanel({ entrypoint, traces, runId }: Props) { const { nodes: laidNodes, edges: laidEdges } = await runElkLayout(graphData); if (layoutRef.current !== layoutId) return; - setNodes(laidNodes); + // Inject persisted breakpoints into freshly laid-out nodes + const curBp = useRunStore.getState().breakpoints[runId]; + const nodesWithBp = curBp + ? laidNodes.map((n) => { + if (n.type === "groupNode") return n; + const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id; + return curBp[plainId] ? { ...n, data: { ...n.data, hasBreakpoint: true } } : n; + }) + : laidNodes; + setNodes(nodesWithBp); setEdges(laidEdges); // Fit view after nodes are rendered setTimeout(() => { @@ -454,6 +511,7 @@ export default function GraphPanel({ entrypoint, traces, runId }: Props) { nodeTypes={nodeTypes} edgeTypes={edgeTypes} onInit={(instance) => { rfInstance.current = instance; }} + onNodeClick={onNodeClick} fitView proOptions={{ hideAttribution: true }} nodesDraggable={false} diff --git a/src/uipath/dev/server/frontend/src/components/graph/nodes/DefaultNode.tsx b/src/uipath/dev/server/frontend/src/components/graph/nodes/DefaultNode.tsx index 55b3e10..982d771 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/nodes/DefaultNode.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/nodes/DefaultNode.tsx @@ -4,9 +4,12 @@ export default function DefaultNode({ data }: NodeProps) { const status = data.status as string | undefined; const w = data.nodeWidth as number | undefined; const label = (data.label as string) ?? ""; + const hasBreakpoint = data.hasBreakpoint as boolean | undefined; + const isPausedHere = data.isPausedHere as boolean | undefined; - const borderColor = - status === "completed" + const borderColor = isPausedHere + ? "var(--accent)" + : status === "completed" ? "var(--success)" : status === "running" ? "var(--warning)" @@ -16,15 +19,31 @@ export default function DefaultNode({ data }: NodeProps) { return (
+ {hasBreakpoint && ( +
+ )}
{label} diff --git a/src/uipath/dev/server/frontend/src/components/graph/nodes/EndNode.tsx b/src/uipath/dev/server/frontend/src/components/graph/nodes/EndNode.tsx index d68d6aa..94c18f3 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/nodes/EndNode.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/nodes/EndNode.tsx @@ -14,9 +14,12 @@ export default function EndNode({ data }: NodeProps) { const status = data.status as string | undefined; const w = data.nodeWidth as number | undefined; const label = (data.label as string) ?? "End"; + const hasBreakpoint = data.hasBreakpoint as boolean | undefined; + const isPausedHere = data.isPausedHere as boolean | undefined; - const borderColor = - status === "completed" + const borderColor = isPausedHere + ? "var(--accent)" + : status === "completed" ? "var(--success)" : status === "failed" ? "var(--error)" @@ -24,15 +27,31 @@ export default function EndNode({ data }: NodeProps) { return (
+ {hasBreakpoint && ( +
+ )} {label}
diff --git a/src/uipath/dev/server/frontend/src/components/graph/nodes/ModelNode.tsx b/src/uipath/dev/server/frontend/src/components/graph/nodes/ModelNode.tsx index 1c28aa0..a3d5370 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/nodes/ModelNode.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/nodes/ModelNode.tsx @@ -15,9 +15,12 @@ export default function ModelNode({ data }: NodeProps) { const w = data.nodeWidth as number | undefined; const modelName = data.model_name as string | undefined; const label = (data.label as string) ?? "Model"; + const hasBreakpoint = data.hasBreakpoint as boolean | undefined; + const isPausedHere = data.isPausedHere as boolean | undefined; - const borderColor = - status === "completed" + const borderColor = isPausedHere + ? "var(--accent)" + : status === "completed" ? "var(--success)" : status === "running" ? "var(--warning)" @@ -27,15 +30,31 @@ export default function ModelNode({ data }: NodeProps) { return (
+ {hasBreakpoint && ( +
+ )}
model
{label}
diff --git a/src/uipath/dev/server/frontend/src/components/graph/nodes/StartNode.tsx b/src/uipath/dev/server/frontend/src/components/graph/nodes/StartNode.tsx index e23a7eb..daadfcc 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/nodes/StartNode.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/nodes/StartNode.tsx @@ -14,9 +14,12 @@ export default function StartNode({ data }: NodeProps) { const status = data.status as string | undefined; const w = data.nodeWidth as number | undefined; const label = (data.label as string) ?? "Start"; + const hasBreakpoint = data.hasBreakpoint as boolean | undefined; + const isPausedHere = data.isPausedHere as boolean | undefined; - const borderColor = - status === "completed" + const borderColor = isPausedHere + ? "var(--accent)" + : status === "completed" ? "var(--success)" : status === "running" ? "var(--warning)" @@ -24,15 +27,31 @@ export default function StartNode({ data }: NodeProps) { return (
+ {hasBreakpoint && ( +
+ )} {label}
diff --git a/src/uipath/dev/server/frontend/src/components/graph/nodes/ToolNode.tsx b/src/uipath/dev/server/frontend/src/components/graph/nodes/ToolNode.tsx index ba86e85..09c02d8 100644 --- a/src/uipath/dev/server/frontend/src/components/graph/nodes/ToolNode.tsx +++ b/src/uipath/dev/server/frontend/src/components/graph/nodes/ToolNode.tsx @@ -18,9 +18,12 @@ export default function ToolNode({ data }: NodeProps) { const toolNames = data.tool_names as string[] | undefined; const toolCount = data.tool_count as number | undefined; const label = (data.label as string) ?? "Tool"; + const hasBreakpoint = data.hasBreakpoint as boolean | undefined; + const isPausedHere = data.isPausedHere as boolean | undefined; - const borderColor = - status === "completed" + const borderColor = isPausedHere + ? "var(--accent)" + : status === "completed" ? "var(--success)" : status === "running" ? "var(--warning)" @@ -33,15 +36,31 @@ export default function ToolNode({ data }: NodeProps) { return (
+ {hasBreakpoint && ( +
+ )}
tools{toolCount ? ` (${toolCount})` : ""} diff --git a/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx b/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx index e90c422..cb4f614 100644 --- a/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx @@ -87,7 +87,7 @@ export default function NewRunPanel({ onRunCreated }: Props) { setLoading(mode); try { - const run = await createRun(selectedEp, parsed, mode); + const run = await createRun(selectedEp, parsed, mode, []); // Immediately add the run to the store so it's available when switching views useRunStore.getState().upsertRun(run); onRunCreated(run.id); diff --git a/src/uipath/dev/server/frontend/src/components/runs/RunDetailsPanel.tsx b/src/uipath/dev/server/frontend/src/components/runs/RunDetailsPanel.tsx index 85b27b0..21fec06 100644 --- a/src/uipath/dev/server/frontend/src/components/runs/RunDetailsPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/runs/RunDetailsPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { RunSummary } from "../../types/run"; import type { WsClient } from "../../api/websocket"; import { useRunStore } from "../../store/useRunStore"; @@ -7,6 +7,7 @@ import TraceTree from "../traces/TraceTree"; import LogPanel from "../logs/LogPanel"; import ChatPanel from "../chat/ChatPanel"; import JsonHighlight from "../shared/JsonHighlight"; +import DebugControls from "../debug/DebugControls"; type Tab = "traces" | "output"; @@ -40,6 +41,21 @@ export default function RunDetailsPanel({ run, ws, activeTab, onTabChange }: Pro const traces = useRunStore((s) => s.traces[run.id] || EMPTY_TRACES); const logs = useRunStore((s) => s.logs[run.id] || EMPTY_LOGS); const chatMessages = useRunStore((s) => s.chatMessages[run.id] || EMPTY_CHAT); + const bpMap = useRunStore((s) => s.breakpoints[run.id]); + + // Sync breakpoints to server when switching to this run + useEffect(() => { + ws.setBreakpoints(run.id, bpMap ? Object.keys(bpMap) : []); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only on run switch + }, [run.id]); + + // Send breakpoints to server immediately when toggled on graph nodes + const handleBreakpointChange = useCallback( + (breakpoints: string[]) => { + ws.setBreakpoints(run.id, breakpoints); + }, + [run.id, ws], + ); const onResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -141,7 +157,7 @@ export default function RunDetailsPanel({ run, ws, activeTab, onTabChange }: Pro