From a5db86b0e03c440a83a3c1e6190aa57c58e8b59c Mon Sep 17 00:00:00 2001 From: Hrishikesh Ballal Date: Tue, 5 May 2026 18:08:01 +0100 Subject: [PATCH] Added a run Step-by-step button for #129 --- src/openutm_verification/server/main.py | 20 ++++++++ src/openutm_verification/server/runner.py | 26 ++++++++++ web-editor/src/components/ScenarioEditor.tsx | 51 ++++++++++++++++++- .../src/components/ScenarioEditor/Header.tsx | 45 ++++++++++++---- .../ScenarioEditor/__tests__/Header.test.tsx | 34 +++++++++++-- web-editor/src/hooks/useScenarioRunner.ts | 29 +++++++++-- web-editor/src/styles/Button.module.css | 22 ++++++++ 7 files changed, 208 insertions(+), 19 deletions(-) diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 3349ee74..6118b74b 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -407,6 +407,19 @@ async def run_scenario_async(scenario: ScenarioDefinition, runner: SessionManage return {"run_id": run_id} +@app.post("/run-scenario-step-mode") +async def run_scenario_step_mode(scenario: ScenarioDefinition, runner: SessionManager = Depends(get_session_manager)): + runner._step_mode = True + await runner.start_scenario_task(scenario) + return {"run_id": "step-mode"} + + +@app.post("/resume-step") +async def resume_step(runner: SessionManager = Depends(get_session_manager)): + runner.resume_step() + return {"resumed": True} + + @app.post("/stop-scenario") async def stop_scenario(runner: SessionManager = Depends(get_session_manager)): """Stop the currently running scenario.""" @@ -419,6 +432,7 @@ async def run_scenario_events(runner: SessionManager = Depends(get_session_manag async def event_stream(): # Track consecutive idle iterations for adaptive sleep idle_iterations = 0 + last_paused = False while True: status_payload = runner.get_run_status() @@ -432,6 +446,12 @@ async def event_stream(): had_results = True idle_iterations = 0 + # Emit paused event exactly once when execution pauses between steps + current_paused = runner._is_paused + if current_paused and not last_paused: + yield f"event: paused\ndata: {json.dumps({'step_id': runner._current_paused_step_id})}\n\n" + last_paused = current_paused + if status_payload.get("status") != "running" and not runner.has_pending_tasks(): done_payload = { "status": status_payload.get("status"), diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index cfc62290..9e6a2660 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -56,6 +56,11 @@ def __init__(self, config_path: str = "config/default.yaml"): self.current_output_dir: Path | None = None self.current_timestamp_str: str | None = None self.current_start_time: datetime | None = None + self._step_mode: bool = False + self._is_paused: bool = False + self._current_paused_step_id: str | None = None + self._step_resume_event: asyncio.Event = asyncio.Event() + self._step_resume_event.set() self._initialized = True async def start_scenario_task(self, scenario: ScenarioDefinition): @@ -76,6 +81,9 @@ async def start_scenario_task(self, scenario: ScenarioDefinition): self.current_timestamp_str = None self.current_start_time = None self.current_run_error = None + self._is_paused = False + self._current_paused_step_id = None + self._step_resume_event.set() task = asyncio.create_task(self.run_scenario(scenario)) self.current_run_task = task @@ -101,9 +109,23 @@ def get_run_status(self) -> Dict[str, Any]: "error": self.current_run_error, } + async def _pause_if_step_mode(self, step_id: str) -> None: + if not getattr(self, "_step_mode", False): + return + self._current_paused_step_id = step_id + self._is_paused = True + self._step_resume_event.clear() + await self._step_resume_event.wait() + self._is_paused = False + + def resume_step(self) -> None: + self._step_resume_event.set() + async def stop_scenario(self) -> bool: """Stop the currently running scenario and all background tasks.""" logger.info("Stopping scenario...") + self._step_mode = False + self._step_resume_event.set() stopped = False tasks_to_cancel = [] @@ -827,6 +849,7 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: else: logger.error(f"Loop for group '{step.id}' failed, breaking scenario") break + await self._pause_if_step_mode(step.id or step.step) else: group_results = await self._execute_group(step, scenario) if any(r.status == Status.FAIL for r in group_results): @@ -836,6 +859,7 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: else: logger.error(f"Group '{step.id}' failed, breaking scenario") break + await self._pause_if_step_mode(step.id or step.step) else: # Regular step execution # Handle loop execution @@ -849,6 +873,7 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: else: logger.error(f"Loop for step '{step.id}' failed, breaking scenario") break + await self._pause_if_step_mode(step.id or step.step) else: result = await self.execute_single_step(step) if result.status == Status.FAIL and should_continue_on_error: @@ -857,6 +882,7 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: if self.session_context: with self.session_context: self.session_context.update_result(result) + await self._pause_if_step_mode(step.id or step.step) # An empty scenario (or one whose every step was skipped before any # `with self.session_context:` block ran) leaves session_context.state # unset; return an empty list rather than crashing. diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index a8d43a6c..eda90dd1 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -183,7 +183,7 @@ const ScenarioEditorContent = () => { edgesRef.current = edges; }, [nodes, edges]); - const { isRunning, runScenario, stopScenario } = useScenarioRunner(); + const { isRunning, isPaused, isStepMode, runScenario, resumeStep, stopScenario } = useScenarioRunner(); const { handleSaveToServer, handleSaveAs } = useScenarioFile( nodes, edges, @@ -935,6 +935,51 @@ const ScenarioEditorContent = () => { })); }, [stopScenario, setNodes]); + const handleStepRun = useCallback(async () => { + setNodes((nds) => nds.map(node => ({ + ...node, + data: { ...node.data, status: undefined, result: undefined } + }))); + + const currentNodes = reactFlowInstance ? reactFlowInstance.getNodes() : nodesRef.current; + const currentEdges = reactFlowInstance ? reactFlowInstance.getEdges() : edgesRef.current; + + const onStepComplete = (stepResult: { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown }) => { + setNodes((nds) => updateNodesWithResults(nds, [stepResult])); + }; + + const onStepStart = (nodeId: string) => { + setNodes((nds) => nds.map(node => + node.id === nodeId ? { ...node, data: { ...node.data, status: 'waiting' } } : node + )); + }; + + await runScenario( + currentNodes, + currentEdges, + currentScenarioName || "Interactive Session", + onStepComplete, + onStepStart, + operations, + currentScenarioGroups, + currentScenarioDescription, + true + ); + }, [ + runScenario, + currentScenarioName, + currentScenarioGroups, + currentScenarioDescription, + operations, + setNodes, + updateNodesWithResults, + reactFlowInstance + ]); + + const handleResume = useCallback(async () => { + await resumeStep(); + }, [resumeStep]); + const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { setIsDirty(true); setNodes((nds) => { @@ -1184,8 +1229,12 @@ const ScenarioEditorContent = () => { onSave={handleSaveToServer} onSaveAs={handleSaveAs} onRun={handleRun} + onStepRun={handleStepRun} onStop={handleStop} + onResume={handleResume} isRunning={isRunning} + isPaused={isPaused} + isStepMode={isStepMode} groupedByPhase={groupedByPhase} onToggleGroupByPhase={toggleGroupByPhase} /> diff --git a/web-editor/src/components/ScenarioEditor/Header.tsx b/web-editor/src/components/ScenarioEditor/Header.tsx index 147ef5a2..289bee8a 100644 --- a/web-editor/src/components/ScenarioEditor/Header.tsx +++ b/web-editor/src/components/ScenarioEditor/Header.tsx @@ -1,4 +1,4 @@ -import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy, Square, Layers, Settings } from 'lucide-react'; +import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy, Square, Layers, Settings, FastForward, StepForward } from 'lucide-react'; import styles from '../../styles/Header.module.css'; import btnStyles from '../../styles/Button.module.css'; @@ -10,8 +10,12 @@ interface HeaderProps { onSave: () => void; onSaveAs: () => void; onRun: () => void; + onStepRun: () => void; onStop: () => void; + onResume: () => void; isRunning: boolean; + isPaused: boolean; + isStepMode: boolean; groupedByPhase: boolean; onToggleGroupByPhase: () => void; } @@ -24,8 +28,12 @@ export const Header = ({ onSave, onSaveAs, onRun, + onStepRun, onStop, + onResume, isRunning, + isPaused, + isStepMode, groupedByPhase, onToggleGroupByPhase, }: HeaderProps) => { @@ -64,15 +72,32 @@ export const Header = ({ Save As - - {isRunning && ( - + {!isRunning ? ( + <> + + + + ) : ( + <> + {isStepMode && isPaused ? ( + + ) : ( + + )} + + )}