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.40"
version = "0.0.41"
description = "UiPath Developer Console"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
4 changes: 4 additions & 0 deletions src/uipath/dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ def __init__(
self.initial_entrypoint: str = "main.py"
self.initial_input: str = '{\n "message": "Hello World"\n}'

async def on_mount(self) -> None:
"""Apply factory settings on mount."""
await self.run_service.apply_factory_settings()

def compose(self) -> ComposeResult:
"""Compose the UI layout."""
with Horizontal():
Expand Down
3 changes: 3 additions & 0 deletions src/uipath/dev/infrastructure/tracing_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ def __init__(
"""Initialize RunContextExporter with callbacks for trace and log messages."""
self.on_trace = on_trace
self.on_log = on_log
self.span_filter: Callable[[ReadableSpan], bool] | None = None
self.logger = logging.getLogger(__name__)

def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
"""Export spans to CLI UI."""
try:
for span in spans:
if self.span_filter and not self.span_filter(span):
continue
self._export_span(span)
return SpanExportResult.SUCCESS
except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions src/uipath/dev/models/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def __init__(
self.error: UiPathErrorContract | None = None
self.breakpoints: list[str] = []
self.breakpoint_node: str | None = None
self.graph_data: dict[str, Any] | None = None
self.chat_events = ChatEvents()

@property
Expand Down
78 changes: 78 additions & 0 deletions src/uipath/dev/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

import asyncio
import logging
import os
import socket
import sys
import threading
import time
import webbrowser
from collections.abc import Callable
from typing import Any

from uipath.core.tracing import UiPathTraceManager
Expand Down Expand Up @@ -57,13 +60,19 @@ def __init__(
host: str = "localhost",
port: int = 8000,
open_browser: bool = True,
factory_creator: Callable[[], UiPathRuntimeFactoryProtocol] | None = None,
) -> None:
"""Initialize the developer server."""
self.runtime_factory = runtime_factory
self.trace_manager = trace_manager
self.host = host
self.port = port
self.open_browser = open_browser
self.factory_creator = factory_creator

self._watcher_task: asyncio.Task[None] | None = None
self._watcher_stop: asyncio.Event | None = None
self.reload_pending = False

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

Expand Down Expand Up @@ -98,6 +107,7 @@ async def run_async(self) -> None:
if not HAS_EXTRAS:
raise ImportError(_MISSING_EXTRAS_MSG)

await self.run_service.apply_factory_settings()
self.port = self._find_free_port(self.host, self.port)
app = self.create_app()

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

# Start file watcher if factory_creator is available
if self.factory_creator is not None:
self._start_watcher()

config = uvicorn.Config(
app,
host=self.host,
Expand All @@ -122,6 +136,7 @@ async def run_async(self) -> None:
async def shutdown(self) -> None:
"""Clean up resources before shutting down."""
logger.info("Shutting down server resources...")
self._stop_watcher()
# Close any active WebSocket connections
await self.connection_manager.disconnect_all()
# Give threads time to finish
Expand All @@ -134,6 +149,69 @@ def run(self) -> None:
except KeyboardInterrupt:
pass

# ------------------------------------------------------------------
# Hot-reload support
# ------------------------------------------------------------------

async def reload_factory(self) -> None:
"""Dispose old factory, flush user modules, and recreate."""
if self.factory_creator is None:
return

# Dispose old factory if it supports it
if hasattr(self.runtime_factory, "dispose"):
try:
await self.runtime_factory.dispose()
except Exception:
logger.debug("Error disposing old factory", exc_info=True)

# Flush user modules (files under cwd, excluding venvs/site-packages)
cwd = os.getcwd()
to_remove = [
name
for name, mod in sys.modules.items()
if hasattr(mod, "__file__")
and mod.__file__ is not None
and os.path.abspath(mod.__file__).startswith(cwd)
and ".venv" not in mod.__file__
and "site-packages" not in mod.__file__
]
for name in to_remove:
del sys.modules[name]
logger.debug("Flushed %d user modules", len(to_remove))

# Recreate factory
self.runtime_factory = self.factory_creator()
self.run_service.runtime_factory = self.runtime_factory
await self.run_service.apply_factory_settings()
self.reload_pending = False
logger.debug("Factory reloaded successfully")

def _start_watcher(self) -> None:
"""Start the file watcher background task."""
from uipath.dev.server.watcher import watch_python_files

self._watcher_stop = asyncio.Event()
self._watcher_task = asyncio.create_task(
watch_python_files(
on_change=self._on_files_changed,
stop_event=self._watcher_stop,
)
)

def _stop_watcher(self) -> None:
"""Stop the file watcher background task."""
if self._watcher_stop is not None:
self._watcher_stop.set()
if self._watcher_task is not None:
self._watcher_task.cancel()
self._watcher_task = None

def _on_files_changed(self, changed_files: list[str]) -> None:
"""Handle file change events from the watcher."""
self.reload_pending = True
self.connection_manager.broadcast_reload(changed_files)

# ------------------------------------------------------------------
# Internal callbacks
# ------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/uipath/dev/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,14 @@ async def _favicon_svg_route():
# Register routes
from uipath.dev.server.routes.entrypoints import router as entrypoints_router
from uipath.dev.server.routes.graph import router as graph_router
from uipath.dev.server.routes.reload import router as reload_router
from uipath.dev.server.routes.runs import router as runs_router
from uipath.dev.server.ws.handler import router as ws_router

app.include_router(entrypoints_router, prefix="/api")
app.include_router(runs_router, prefix="/api")
app.include_router(graph_router, prefix="/api")
app.include_router(reload_router, prefix="/api")
app.include_router(ws_router)

# Auto-build frontend if source is available and build is stale
Expand Down
9 changes: 8 additions & 1 deletion src/uipath/dev/server/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Sidebar from "./components/layout/Sidebar";
import NewRunPanel from "./components/runs/NewRunPanel";
import SetupView from "./components/runs/SetupView";
import RunDetailsPanel from "./components/runs/RunDetailsPanel";
import ReloadToast from "./components/shared/ReloadToast";

export default function App() {
const ws = useWebSocket();
Expand All @@ -21,6 +22,7 @@ export default function App() {
setChatMessages,
setEntrypoints,
setStateEvents,
setGraphCache,
} = useRunStore();
const { view, runId: routeRunId, setupEntrypoint, setupMode, navigate } = useHashRoute();

Expand Down Expand Up @@ -76,6 +78,10 @@ export default function App() {
};
});
setChatMessages(selectedRunId, chatMsgs);
// Cache graph data per run (persists across reloads)
if (detail.graph && detail.graph.nodes.length > 0) {
setGraphCache(selectedRunId, detail.graph);
}
// Load persisted state events
if (detail.states && detail.states.length > 0) {
setStateEvents(
Expand Down Expand Up @@ -105,7 +111,7 @@ export default function App() {
clearTimeout(retryTimer);
ws.unsubscribe(selectedRunId);
};
}, [selectedRunId, ws, upsertRun, setTraces, setLogs, setChatMessages, setStateEvents]);
}, [selectedRunId, ws, upsertRun, setTraces, setLogs, setChatMessages, setStateEvents, setGraphCache]);

const handleRunCreated = (runId: string) => {
navigate(`#/runs/${runId}/traces`);
Expand Down Expand Up @@ -149,6 +155,7 @@ export default function App() {
</div>
)}
</main>
<ReloadToast />
</div>
);
}
4 changes: 4 additions & 0 deletions src/uipath/dev/server/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ export async function listRuns(): Promise<RunSummary[]> {
export async function getRun(runId: string): Promise<RunDetail> {
return fetchJson(`${BASE}/runs/${runId}`);
}

export async function reloadFactory(): Promise<{ status: string }> {
return fetchJson(`${BASE}/reload`, { method: "POST" });
}
16 changes: 13 additions & 3 deletions src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,13 +523,23 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
return map;
}, [traces]);

// Subscribe to cached graph reactively (populated async from run detail)
const cachedGraph = useRunStore((s) => s.graphCache[runId]);

// Fetch graph data and run ELK layout
useEffect(() => {
// For non-setup runs, wait for cache to be populated from run detail
if (!cachedGraph && runId !== "__setup__") return;

const graphPromise = cachedGraph
? Promise.resolve(cachedGraph)
: getEntrypointGraph(entrypoint);

const layoutId = ++layoutRef.current;
setLoading(true);

setGraphUnavailable(false);
getEntrypointGraph(entrypoint)

graphPromise
.then(async (graphData) => {
if (layoutRef.current !== layoutId) return;
if (!graphData.nodes.length) {
Expand Down Expand Up @@ -561,7 +571,7 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
.finally(() => {
if (layoutRef.current === layoutId) setLoading(false);
});
}, [entrypoint, setNodes, setEdges]);
}, [entrypoint, runId, cachedGraph, setNodes, setEdges]);

// Fit view when switching runs (even if entrypoint is the same)
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useState } from "react";
import { useRunStore } from "../../store/useRunStore";
import { reloadFactory, listEntrypoints } from "../../api/client";

export default function ReloadToast() {
const { reloadPending, setReloadPending, setEntrypoints } = useRunStore();
const [loading, setLoading] = useState(false);

if (!reloadPending) return null;

const handleReload = async () => {
setLoading(true);
try {
await reloadFactory();
const eps = await listEntrypoints();
setEntrypoints(eps.map((e) => e.name));
setReloadPending(false);
} catch (err) {
console.error("Reload failed:", err);
} finally {
setLoading(false);
}
};

return (
<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]"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--bg-tertiary)" }}>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
Files changed — reload to apply
</span>
<div className="flex items-center gap-2">
<button
onClick={handleReload}
disabled={loading}
className="px-3 py-1 text-sm font-medium rounded cursor-pointer"
style={{
background: "var(--accent)",
color: "#fff",
opacity: loading ? 0.6 : 1,
}}
>
{loading ? "Reloading..." : "Reload"}
</button>
<button
onClick={() => setReloadPending(false)}
className="text-sm cursor-pointer px-1"
style={{ color: "var(--text-muted)", background: "none", border: "none" }}
>
</button>
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions src/uipath/dev/server/frontend/src/store/useRunStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { RunSummary, TraceSpan, LogEntry } from "../types/run";
import type { GraphData } from "../types/graph";

interface ChatMsg {
message_id: string;
Expand Down Expand Up @@ -45,6 +46,12 @@ interface RunStore {

focusedSpan: { name: string; index: number } | null;
setFocusedSpan: (span: { name: string; index: number } | null) => void;

reloadPending: boolean;
setReloadPending: (val: boolean) => void;

graphCache: Record<string, GraphData>;
setGraphCache: (runId: string, data: GraphData) => void;
}

export const useRunStore = create<RunStore>((set) => ({
Expand Down Expand Up @@ -233,4 +240,11 @@ export const useRunStore = create<RunStore>((set) => ({

focusedSpan: null,
setFocusedSpan: (span) => set({ focusedSpan: span }),

reloadPending: false,
setReloadPending: (val) => set({ reloadPending: val }),

graphCache: {},
setGraphCache: (runId, data) =>
set((state) => ({ graphCache: { ...state.graphCache, [runId]: data } })),
}));
7 changes: 5 additions & 2 deletions src/uipath/dev/server/frontend/src/store/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function getWs(): WsClient {

export function useWebSocket() {
const ws = useRef(getWs());
const { upsertRun, addTrace, addLog, addChatEvent, setActiveNode, addStateEvent } = useRunStore();
const { upsertRun, addTrace, addLog, addChatEvent, setActiveNode, addStateEvent, setReloadPending } = useRunStore();

useEffect(() => {
const client = ws.current;
Expand Down Expand Up @@ -44,11 +44,14 @@ export function useWebSocket() {
addStateEvent(runId, nodeName, payload);
break;
}
case "reload":
setReloadPending(true);
break;
}
});

return unsub;
}, [upsertRun, addTrace, addLog, addChatEvent, setActiveNode, addStateEvent]);
}, [upsertRun, addTrace, addLog, addChatEvent, setActiveNode, addStateEvent, setReloadPending]);

return ws.current;
}
Loading