Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/uipath/dev/models/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/uipath/dev/server/debug_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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(
Expand Down Expand Up @@ -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()

Expand Down
26 changes: 21 additions & 5 deletions src/uipath/dev/server/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function App() {
runs,
selectedRunId,
setRuns,
upsertRun,
selectRun,
setTraces,
setLogs,
Expand Down Expand Up @@ -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<ReturnType<typeof getRun>>) => {
upsertRun(detail);
setTraces(selectedRunId, detail.traces);
setLogs(selectedRunId, detail.logs);
// Convert messages to chat format (server uses camelCase aliases)
Expand Down Expand Up @@ -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`);
Expand Down
3 changes: 2 additions & 1 deletion src/uipath/dev/server/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ export async function createRun(
entrypoint: string,
inputData: Record<string, unknown>,
mode: string = "run",
breakpoints: string[] = [],
): Promise<RunSummary> {
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 }),
});
}

Expand Down
4 changes: 4 additions & 0 deletions src/uipath/dev/server/frontend/src/api/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="flex gap-2 p-2 border-b"
className="flex items-center gap-1 px-4 border-b shrink-0 h-[33px]"
style={{ borderColor: "var(--border)", background: "var(--bg-secondary)" }}
>
<button
onClick={() => ws.debugStep(runId)}
disabled={!isSuspended}
className="px-3 py-1 text-xs text-white rounded transition-colors disabled:opacity-50"
style={{ background: isSuspended ? "var(--info)" : "var(--bg-tertiary)" }}
>
Step
</button>
<button
onClick={() => ws.debugContinue(runId)}
disabled={!isSuspended}
className="px-3 py-1 text-xs text-white rounded transition-colors disabled:opacity-50"
style={{ background: isSuspended ? "var(--success)" : "var(--bg-tertiary)" }}
>
Continue
</button>
<button
onClick={() => ws.debugStop(runId)}
disabled={!isSuspended}
className="px-3 py-1 text-xs text-white rounded transition-colors disabled:opacity-50"
style={{ background: isSuspended ? "var(--error)" : "var(--bg-tertiary)" }}
>
Stop
</button>
<span className="text-xs self-center ml-2" style={{ color: "var(--text-muted)" }}>
{isSuspended ? "Paused at breakpoint" : `Status: ${status}`}
<span className="text-[10px] uppercase tracking-wider font-semibold mr-1" style={{ color: "var(--text-muted)" }}>
Debug
</span>
<DebugBtn label="Step" onClick={() => syncBreakpointsThenSend("step")} disabled={!isSuspended} color="var(--info)" active={isSuspended} />
<DebugBtn label="Continue" onClick={() => syncBreakpointsThenSend("continue")} disabled={!isSuspended} color="var(--success)" active={isSuspended} />
<DebugBtn label="Stop" onClick={() => syncBreakpointsThenSend("stop")} disabled={!isSuspended} color="var(--error)" active={isSuspended} />
<span className="text-[10px] ml-auto truncate" style={{ color: isSuspended ? "var(--accent)" : "var(--text-muted)" }}>
{isSuspended
? breakpointNode
? `Paused at ${breakpointNode}`
: "Paused"
: status}
</span>
</div>
);
}

function DebugBtn({ label, onClick, disabled, color, active }: {
label: string; onClick: () => void; disabled: boolean; color: string; active: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="px-2.5 py-0.5 h-5 text-[10px] uppercase tracking-wider font-semibold rounded transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
style={{
color: active ? color : "var(--text-muted)",
background: active ? `color-mix(in srgb, ${color} 10%, transparent)` : "transparent",
}}
onMouseEnter={(e) => {
if (!disabled) e.currentTarget.style.background = `color-mix(in srgb, ${color} 20%, transparent)`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = active ? `color-mix(in srgb, ${color} 10%, transparent)` : "transparent";
}}
>
{label}
</button>
);
}
62 changes: 60 additions & 2 deletions src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReactFlowInstance | null>(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<string, string> = {};
traces.forEach((t) => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -16,15 +19,31 @@ export default function DefaultNode({ data }: NodeProps) {

return (
<div
className="px-3 py-1.5 rounded-lg text-center text-xs overflow-hidden"
className="px-3 py-1.5 rounded-lg text-center text-xs overflow-hidden cursor-pointer relative"
style={{
width: w,
background: "var(--node-bg)",
color: "var(--text-primary)",
border: `1px solid ${borderColor}`,
border: `2px solid ${borderColor}`,
boxShadow: isPausedHere ? "0 0 4px var(--accent)" : undefined,
}}
title={label}
>
{hasBreakpoint && (
<div
className="absolute"
style={{
top: 2,
left: 2,
width: 12,
height: 12,
borderRadius: "50%",
background: "var(--error)",
border: "2px solid var(--node-bg)",
boxShadow: "0 0 4px var(--error)",
}}
/>
)}
<Handle type="target" position={Position.Top} />
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{label}
Expand Down
Loading