Skip to content

Commit ef16278

Browse files
authored
Merge pull request #60 from UiPath/feat/trace-span-filter
feat: hot-reload dev server on Python file changes
2 parents e7721a3 + dc36e63 commit ef16278

29 files changed

Lines changed: 367 additions & 80 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-dev"
3-
version = "0.0.40"
3+
version = "0.0.41"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/dev/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ def __init__(
7979
self.initial_entrypoint: str = "main.py"
8080
self.initial_input: str = '{\n "message": "Hello World"\n}'
8181

82+
async def on_mount(self) -> None:
83+
"""Apply factory settings on mount."""
84+
await self.run_service.apply_factory_settings()
85+
8286
def compose(self) -> ComposeResult:
8387
"""Compose the UI layout."""
8488
with Horizontal():

src/uipath/dev/infrastructure/tracing_exporter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ def __init__(
2323
"""Initialize RunContextExporter with callbacks for trace and log messages."""
2424
self.on_trace = on_trace
2525
self.on_log = on_log
26+
self.span_filter: Callable[[ReadableSpan], bool] | None = None
2627
self.logger = logging.getLogger(__name__)
2728

2829
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
2930
"""Export spans to CLI UI."""
3031
try:
3132
for span in spans:
33+
if self.span_filter and not self.span_filter(span):
34+
continue
3235
self._export_span(span)
3336
return SpanExportResult.SUCCESS
3437
except Exception as e:

src/uipath/dev/models/execution.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def __init__(
4747
self.error: UiPathErrorContract | None = None
4848
self.breakpoints: list[str] = []
4949
self.breakpoint_node: str | None = None
50+
self.graph_data: dict[str, Any] | None = None
5051
self.chat_events = ChatEvents()
5152

5253
@property

src/uipath/dev/server/__init__.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
import asyncio
66
import logging
7+
import os
78
import socket
9+
import sys
810
import threading
911
import time
1012
import webbrowser
13+
from collections.abc import Callable
1114
from typing import Any
1215

1316
from uipath.core.tracing import UiPathTraceManager
@@ -57,13 +60,19 @@ def __init__(
5760
host: str = "localhost",
5861
port: int = 8000,
5962
open_browser: bool = True,
63+
factory_creator: Callable[[], UiPathRuntimeFactoryProtocol] | None = None,
6064
) -> None:
6165
"""Initialize the developer server."""
6266
self.runtime_factory = runtime_factory
6367
self.trace_manager = trace_manager
6468
self.host = host
6569
self.port = port
6670
self.open_browser = open_browser
71+
self.factory_creator = factory_creator
72+
73+
self._watcher_task: asyncio.Task[None] | None = None
74+
self._watcher_stop: asyncio.Event | None = None
75+
self.reload_pending = False
6776

6877
from uipath.dev.server.ws.manager import ConnectionManager
6978

@@ -98,6 +107,7 @@ async def run_async(self) -> None:
98107
if not HAS_EXTRAS:
99108
raise ImportError(_MISSING_EXTRAS_MSG)
100109

110+
await self.run_service.apply_factory_settings()
101111
self.port = self._find_free_port(self.host, self.port)
102112
app = self.create_app()
103113

@@ -110,6 +120,10 @@ async def run_async(self) -> None:
110120
daemon=True,
111121
).start()
112122

123+
# Start file watcher if factory_creator is available
124+
if self.factory_creator is not None:
125+
self._start_watcher()
126+
113127
config = uvicorn.Config(
114128
app,
115129
host=self.host,
@@ -122,6 +136,7 @@ async def run_async(self) -> None:
122136
async def shutdown(self) -> None:
123137
"""Clean up resources before shutting down."""
124138
logger.info("Shutting down server resources...")
139+
self._stop_watcher()
125140
# Close any active WebSocket connections
126141
await self.connection_manager.disconnect_all()
127142
# Give threads time to finish
@@ -134,6 +149,69 @@ def run(self) -> None:
134149
except KeyboardInterrupt:
135150
pass
136151

152+
# ------------------------------------------------------------------
153+
# Hot-reload support
154+
# ------------------------------------------------------------------
155+
156+
async def reload_factory(self) -> None:
157+
"""Dispose old factory, flush user modules, and recreate."""
158+
if self.factory_creator is None:
159+
return
160+
161+
# Dispose old factory if it supports it
162+
if hasattr(self.runtime_factory, "dispose"):
163+
try:
164+
await self.runtime_factory.dispose()
165+
except Exception:
166+
logger.debug("Error disposing old factory", exc_info=True)
167+
168+
# Flush user modules (files under cwd, excluding venvs/site-packages)
169+
cwd = os.getcwd()
170+
to_remove = [
171+
name
172+
for name, mod in sys.modules.items()
173+
if hasattr(mod, "__file__")
174+
and mod.__file__ is not None
175+
and os.path.abspath(mod.__file__).startswith(cwd)
176+
and ".venv" not in mod.__file__
177+
and "site-packages" not in mod.__file__
178+
]
179+
for name in to_remove:
180+
del sys.modules[name]
181+
logger.debug("Flushed %d user modules", len(to_remove))
182+
183+
# Recreate factory
184+
self.runtime_factory = self.factory_creator()
185+
self.run_service.runtime_factory = self.runtime_factory
186+
await self.run_service.apply_factory_settings()
187+
self.reload_pending = False
188+
logger.debug("Factory reloaded successfully")
189+
190+
def _start_watcher(self) -> None:
191+
"""Start the file watcher background task."""
192+
from uipath.dev.server.watcher import watch_python_files
193+
194+
self._watcher_stop = asyncio.Event()
195+
self._watcher_task = asyncio.create_task(
196+
watch_python_files(
197+
on_change=self._on_files_changed,
198+
stop_event=self._watcher_stop,
199+
)
200+
)
201+
202+
def _stop_watcher(self) -> None:
203+
"""Stop the file watcher background task."""
204+
if self._watcher_stop is not None:
205+
self._watcher_stop.set()
206+
if self._watcher_task is not None:
207+
self._watcher_task.cancel()
208+
self._watcher_task = None
209+
210+
def _on_files_changed(self, changed_files: list[str]) -> None:
211+
"""Handle file change events from the watcher."""
212+
self.reload_pending = True
213+
self.connection_manager.broadcast_reload(changed_files)
214+
137215
# ------------------------------------------------------------------
138216
# Internal callbacks
139217
# ------------------------------------------------------------------

src/uipath/dev/server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,14 @@ async def _favicon_svg_route():
134134
# Register routes
135135
from uipath.dev.server.routes.entrypoints import router as entrypoints_router
136136
from uipath.dev.server.routes.graph import router as graph_router
137+
from uipath.dev.server.routes.reload import router as reload_router
137138
from uipath.dev.server.routes.runs import router as runs_router
138139
from uipath.dev.server.ws.handler import router as ws_router
139140

140141
app.include_router(entrypoints_router, prefix="/api")
141142
app.include_router(runs_router, prefix="/api")
142143
app.include_router(graph_router, prefix="/api")
144+
app.include_router(reload_router, prefix="/api")
143145
app.include_router(ws_router)
144146

145147
# Auto-build frontend if source is available and build is stale

src/uipath/dev/server/frontend/src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Sidebar from "./components/layout/Sidebar";
77
import NewRunPanel from "./components/runs/NewRunPanel";
88
import SetupView from "./components/runs/SetupView";
99
import RunDetailsPanel from "./components/runs/RunDetailsPanel";
10+
import ReloadToast from "./components/shared/ReloadToast";
1011

1112
export default function App() {
1213
const ws = useWebSocket();
@@ -21,6 +22,7 @@ export default function App() {
2122
setChatMessages,
2223
setEntrypoints,
2324
setStateEvents,
25+
setGraphCache,
2426
} = useRunStore();
2527
const { view, runId: routeRunId, setupEntrypoint, setupMode, navigate } = useHashRoute();
2628

@@ -76,6 +78,10 @@ export default function App() {
7678
};
7779
});
7880
setChatMessages(selectedRunId, chatMsgs);
81+
// Cache graph data per run (persists across reloads)
82+
if (detail.graph && detail.graph.nodes.length > 0) {
83+
setGraphCache(selectedRunId, detail.graph);
84+
}
7985
// Load persisted state events
8086
if (detail.states && detail.states.length > 0) {
8187
setStateEvents(
@@ -105,7 +111,7 @@ export default function App() {
105111
clearTimeout(retryTimer);
106112
ws.unsubscribe(selectedRunId);
107113
};
108-
}, [selectedRunId, ws, upsertRun, setTraces, setLogs, setChatMessages, setStateEvents]);
114+
}, [selectedRunId, ws, upsertRun, setTraces, setLogs, setChatMessages, setStateEvents, setGraphCache]);
109115

110116
const handleRunCreated = (runId: string) => {
111117
navigate(`#/runs/${runId}/traces`);
@@ -149,6 +155,7 @@ export default function App() {
149155
</div>
150156
)}
151157
</main>
158+
<ReloadToast />
152159
</div>
153160
);
154161
}

src/uipath/dev/server/frontend/src/api/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,7 @@ export async function listRuns(): Promise<RunSummary[]> {
6464
export async function getRun(runId: string): Promise<RunDetail> {
6565
return fetchJson(`${BASE}/runs/${runId}`);
6666
}
67+
68+
export async function reloadFactory(): Promise<{ status: string }> {
69+
return fetchJson(`${BASE}/reload`, { method: "POST" });
70+
}

src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,13 +523,23 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
523523
return map;
524524
}, [traces]);
525525

526+
// Subscribe to cached graph reactively (populated async from run detail)
527+
const cachedGraph = useRunStore((s) => s.graphCache[runId]);
528+
526529
// Fetch graph data and run ELK layout
527530
useEffect(() => {
531+
// For non-setup runs, wait for cache to be populated from run detail
532+
if (!cachedGraph && runId !== "__setup__") return;
533+
534+
const graphPromise = cachedGraph
535+
? Promise.resolve(cachedGraph)
536+
: getEntrypointGraph(entrypoint);
537+
528538
const layoutId = ++layoutRef.current;
529539
setLoading(true);
530-
531540
setGraphUnavailable(false);
532-
getEntrypointGraph(entrypoint)
541+
542+
graphPromise
533543
.then(async (graphData) => {
534544
if (layoutRef.current !== layoutId) return;
535545
if (!graphData.nodes.length) {
@@ -561,7 +571,7 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
561571
.finally(() => {
562572
if (layoutRef.current === layoutId) setLoading(false);
563573
});
564-
}, [entrypoint, setNodes, setEdges]);
574+
}, [entrypoint, runId, cachedGraph, setNodes, setEdges]);
565575

566576
// Fit view when switching runs (even if entrypoint is the same)
567577
useEffect(() => {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useState } from "react";
2+
import { useRunStore } from "../../store/useRunStore";
3+
import { reloadFactory, listEntrypoints } from "../../api/client";
4+
5+
export default function ReloadToast() {
6+
const { reloadPending, setReloadPending, setEntrypoints } = useRunStore();
7+
const [loading, setLoading] = useState(false);
8+
9+
if (!reloadPending) return null;
10+
11+
const handleReload = async () => {
12+
setLoading(true);
13+
try {
14+
await reloadFactory();
15+
const eps = await listEntrypoints();
16+
setEntrypoints(eps.map((e) => e.name));
17+
setReloadPending(false);
18+
} catch (err) {
19+
console.error("Reload failed:", err);
20+
} finally {
21+
setLoading(false);
22+
}
23+
};
24+
25+
return (
26+
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-center justify-between px-5 py-2.5 rounded-lg shadow-lg min-w-[400px]"
27+
style={{ background: "var(--bg-secondary)", border: "1px solid var(--bg-tertiary)" }}>
28+
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
29+
Files changed — reload to apply
30+
</span>
31+
<div className="flex items-center gap-2">
32+
<button
33+
onClick={handleReload}
34+
disabled={loading}
35+
className="px-3 py-1 text-sm font-medium rounded cursor-pointer"
36+
style={{
37+
background: "var(--accent)",
38+
color: "#fff",
39+
opacity: loading ? 0.6 : 1,
40+
}}
41+
>
42+
{loading ? "Reloading..." : "Reload"}
43+
</button>
44+
<button
45+
onClick={() => setReloadPending(false)}
46+
className="text-sm cursor-pointer px-1"
47+
style={{ color: "var(--text-muted)", background: "none", border: "none" }}
48+
>
49+
50+
</button>
51+
</div>
52+
</div>
53+
);
54+
}

0 commit comments

Comments
 (0)