22
33import asyncio
44import json
5- import traceback
65from datetime import datetime
76from pathlib import Path
8- from typing import Any , Optional
7+ from typing import Any
98
109import pyperclip # type: ignore[import-untyped]
11- from pydantic import BaseModel
12- from rich .traceback import Traceback
1310from textual import on
1411from textual .app import App , ComposeResult
1512from textual .binding import Binding
1613from textual .containers import Container , Horizontal
1714from textual .widgets import Button , Footer , Input , ListView , RichLog
1815from uipath .core .tracing import UiPathTraceManager
19- from uipath .runtime import (
20- UiPathExecuteOptions ,
21- UiPathExecutionRuntime ,
22- UiPathRuntimeFactoryProtocol ,
23- UiPathRuntimeStatus ,
24- )
25- from uipath .runtime .errors import UiPathErrorContract , UiPathRuntimeError
16+ from uipath .runtime import UiPathRuntimeFactoryProtocol
2617
2718from uipath .dev .infrastructure import (
28- RunContextExporter ,
29- RunContextLogHandler ,
3019 patch_textual_stderr ,
3120)
3221from uipath .dev .models import ExecutionRun , LogMessage , TraceMessage
22+ from uipath .dev .services import RunService
3323from uipath .dev .ui .panels import NewRunPanel , RunDetailsPanel , RunHistoryPanel
3424
3525
3626class UiPathDeveloperConsole (App [Any ]):
3727 """UiPath developer console interface."""
3828
3929 TITLE = "UiPath Developer Console"
40- SUB_TITLE = "Interactive terminal application for building, testing, and debugging UiPath Python runtimes, agents, and automation scripts."
30+ SUB_TITLE = (
31+ "Interactive terminal application for building, testing, and debugging "
32+ "UiPath Python runtimes, agents, and automation scripts."
33+ )
4134 CSS_PATH = Path (__file__ ).parent / "ui" / "styles" / "terminal.tcss"
4235
4336 BINDINGS = [
@@ -56,23 +49,27 @@ def __init__(
5649 ** kwargs ,
5750 ):
5851 """Initialize the UiPath Dev Terminal App."""
52+ # Capture subprocess stderr lines and route to our log handler
5953 self ._stderr_write_fd : int = patch_textual_stderr (self ._add_subprocess_log )
6054
6155 super ().__init__ (** kwargs )
6256
63- self .initial_entrypoint : str = "main.py"
64- self .initial_input : str = '{\n "message": "Hello World"\n }'
65- self .runs : dict [str , ExecutionRun ] = {}
6657 self .runtime_factory = runtime_factory
6758 self .trace_manager = trace_manager
68- self .trace_manager .add_span_exporter (
69- RunContextExporter (
70- on_trace = self ._handle_trace_message ,
71- on_log = self ._handle_log_message ,
72- ),
73- batch = False ,
59+
60+ # Core service: owns run state, logs, traces
61+ self .run_service = RunService (
62+ runtime_factory = self .runtime_factory ,
63+ trace_manager = self .trace_manager ,
64+ on_run_updated = self ._on_run_updated ,
65+ on_log = self ._on_log_for_ui ,
66+ on_trace = self ._on_trace_for_ui ,
7467 )
7568
69+ # Just defaults for convenience
70+ self .initial_entrypoint : str = "main.py"
71+ self .initial_input : str = '{\n "message": "Hello World"\n }'
72+
7673 def compose (self ) -> ComposeResult :
7774 """Compose the UI layout."""
7875 with Horizontal ():
@@ -127,8 +124,10 @@ async def handle_chat_input(self, event: Input.Submitted) -> None:
127124 "Wait for agent response..." , timeout = 1.5 , severity = "warning"
128125 )
129126 return
127+
130128 if details_panel .current_run .status == "suspended" :
131129 details_panel .current_run .resume_data = {"message" : user_text }
130+
132131 asyncio .create_task (self ._execute_runtime (details_panel .current_run ))
133132 event .input .clear ()
134133
@@ -145,24 +144,24 @@ async def action_cancel(self) -> None:
145144 await self .action_new_run ()
146145
147146 async def action_execute_run (self ) -> None :
148- """Execute a new run with UiPath runtime ."""
147+ """Execute a new run based on NewRunPanel inputs ."""
149148 new_run_panel = self .query_one ("#new-run-panel" , NewRunPanel )
150149 entrypoint , input_data , conversational = new_run_panel .get_input_values ()
151150
152151 if not entrypoint :
153152 return
154153
155- input : dict [str , Any ] = {}
156154 try :
157- input = json .loads (input_data )
155+ input_payload : dict [ str , Any ] = json .loads (input_data )
158156 except json .JSONDecodeError :
159157 return
160158
161- run = ExecutionRun (entrypoint , input , conversational )
159+ run = ExecutionRun (entrypoint , input_payload , conversational )
162160
163- self .runs [run .id ] = run
161+ history_panel = self .query_one ("#history-panel" , RunHistoryPanel )
162+ history_panel .add_run (run )
164163
165- self ._add_run_in_history (run )
164+ self .run_service . register_run (run )
166165
167166 self ._show_run_details (run )
168167
@@ -187,140 +186,48 @@ def action_copy(self) -> None:
187186 else :
188187 self .app .notify ("Nothing to copy here." , timeout = 1.5 , severity = "warning" )
189188
190- async def _execute_runtime (self , run : ExecutionRun ):
191- """Execute the script using UiPath runtime."""
192- try :
193- execution_input : Optional [dict [str , Any ]] = {}
194- execution_options : UiPathExecuteOptions = UiPathExecuteOptions ()
195- if run .status == "suspended" :
196- execution_input = run .resume_data
197- execution_options .resume = True
198- self ._add_info_log (run , f"Resuming execution: { run .entrypoint } " )
199- else :
200- execution_input = run .input_data
201- self ._add_info_log (run , f"Starting execution: { run .entrypoint } " )
202-
203- run .status = "running"
204- run .start_time = datetime .now ()
205- log_handler = RunContextLogHandler (
206- run_id = run .id ,
207- callback = self ._handle_log_message ,
208- )
209- runtime = await self .runtime_factory .new_runtime (entrypoint = run .entrypoint )
210- execution_runtime = UiPathExecutionRuntime (
211- delegate = runtime ,
212- trace_manager = self .trace_manager ,
213- log_handler = log_handler ,
214- execution_id = run .id ,
215- )
216- result = await execution_runtime .execute (execution_input , execution_options )
217-
218- if result is not None :
219- if (
220- result .status == UiPathRuntimeStatus .SUSPENDED .value
221- and result .resume
222- ):
223- run .status = "suspended"
224- else :
225- if result .output is None :
226- run .output_data = {}
227- elif isinstance (result .output , BaseModel ):
228- run .output_data = result .output .model_dump ()
229- else :
230- run .output_data = result .output
231- run .status = "completed"
232- if run .output_data :
233- self ._add_info_log (run , f"Execution result: { run .output_data } " )
234-
235- self ._add_info_log (run , "✅ Execution completed successfully" )
236- run .end_time = datetime .now ()
237-
238- except UiPathRuntimeError as e :
239- self ._add_error_log (run )
240- run .status = "failed"
241- run .end_time = datetime .now ()
242- run .error = e .error_info
243-
244- except Exception as e :
245- self ._add_error_log (run )
246- run .status = "failed"
247- run .end_time = datetime .now ()
248- run .error = UiPathErrorContract (
249- code = "Unknown" , title = str (e ), detail = traceback .format_exc ()
250- )
251-
252- self ._update_run_in_history (run )
253- self ._update_run_details (run )
254-
255- def _show_run_details (self , run : ExecutionRun ):
189+ async def _execute_runtime (self , run : ExecutionRun ) -> None :
190+ """Wrapper that delegates execution to RunService."""
191+ await self .run_service .execute (run )
192+
193+ def _on_run_updated (self , run : ExecutionRun ) -> None :
194+ """Called whenever a run changes (status, times, logs, traces)."""
195+ # Update the run in history
196+ history_panel = self .query_one ("#history-panel" , RunHistoryPanel )
197+ history_panel .update_run (run )
198+
199+ # If this run is currently shown, refresh details
200+ details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
201+ if details_panel .current_run and details_panel .current_run .id == run .id :
202+ details_panel .update_run_details (run )
203+
204+ def _on_log_for_ui (self , log_msg : LogMessage ) -> None :
205+ """Append a log message to the logs UI."""
206+ details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
207+ details_panel .add_log (log_msg )
208+
209+ def _on_trace_for_ui (self , trace_msg : TraceMessage ) -> None :
210+ """Append/refresh traces in the UI."""
211+ details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
212+ details_panel .add_trace (trace_msg )
213+
214+ def _show_run_details (self , run : ExecutionRun ) -> None :
256215 """Show details panel for a specific run."""
257- # Hide new run panel, show details panel
258216 new_panel = self .query_one ("#new-run-panel" )
259217 details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
260218
261219 new_panel .add_class ("hidden" )
262220 details_panel .remove_class ("hidden" )
263221
264- # Populate the details panel with run data
265222 details_panel .update_run (run )
266223
267- def _focus_chat_input (self ):
224+ def _focus_chat_input (self ) -> None :
268225 """Focus the chat input box."""
269226 details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
270227 details_panel .switch_tab ("chat-tab" )
271228 chat_input = details_panel .query_one ("#chat-input" , Input )
272229 chat_input .focus ()
273230
274- def _add_run_in_history (self , run : ExecutionRun ):
275- """Add run to history panel."""
276- history_panel = self .query_one ("#history-panel" , RunHistoryPanel )
277- history_panel .add_run (run )
278-
279- def _update_run_in_history (self , run : ExecutionRun ):
280- """Update run display in history panel."""
281- history_panel = self .query_one ("#history-panel" , RunHistoryPanel )
282- history_panel .update_run (run )
283-
284- def _update_run_details (self , run : ExecutionRun ):
285- """Update the displayed run information."""
286- details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
287- details_panel .update_run_details (run )
288-
289- def _handle_trace_message (self , trace_msg : TraceMessage ):
290- """Handle trace message from exporter."""
291- run = self .runs [trace_msg .run_id ]
292- for i , existing_trace in enumerate (run .traces ):
293- if existing_trace .span_id == trace_msg .span_id :
294- run .traces [i ] = trace_msg
295- break
296- else :
297- run .traces .append (trace_msg )
298-
299- details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
300- details_panel .add_trace (trace_msg )
301-
302- def _handle_log_message (self , log_msg : LogMessage ):
303- """Handle log message from exporter."""
304- self .runs [log_msg .run_id ].logs .append (log_msg )
305- details_panel = self .query_one ("#details-panel" , RunDetailsPanel )
306- details_panel .add_log (log_msg )
307-
308- def _add_info_log (self , run : ExecutionRun , message : str ):
309- """Add info log to run."""
310- timestamp = datetime .now ()
311- log_msg = LogMessage (run .id , "INFO" , message , timestamp )
312- self ._handle_log_message (log_msg )
313-
314- def _add_error_log (self , run : ExecutionRun ):
315- """Add error log to run."""
316- timestamp = datetime .now ()
317- tb = Traceback (
318- show_locals = False ,
319- max_frames = 4 ,
320- )
321- log_msg = LogMessage (run .id , "ERROR" , tb , timestamp )
322- self ._handle_log_message (log_msg )
323-
324231 def _add_subprocess_log (self , level : str , message : str ) -> None :
325232 """Handle a stderr line coming from subprocesses."""
326233
@@ -329,6 +236,7 @@ def add_log() -> None:
329236 run = getattr (details_panel , "current_run" , None )
330237 if run :
331238 log_msg = LogMessage (run .id , level , message , datetime .now ())
332- self ._handle_log_message (log_msg )
239+ # Route through RunService so state + UI stay in sync
240+ self .run_service .handle_log (log_msg )
333241
334242 self .call_from_thread (add_log )
0 commit comments