diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 3349ee74..fa1e5fec 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -7,7 +7,7 @@ from typing import Any, Optional, TypeVar import uvicorn -from fastapi import Body, Depends, FastAPI, Request +from fastapi import Body, Depends, FastAPI, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi.staticfiles import StaticFiles @@ -402,11 +402,18 @@ async def run_scenario(scenario: ScenarioDefinition, runner: SessionManager = De @app.post("/run-scenario-async") -async def run_scenario_async(scenario: ScenarioDefinition, runner: SessionManager = Depends(get_session_manager)): - run_id = await runner.start_scenario_task(scenario) +async def run_scenario_async(scenario: ScenarioDefinition, auto_pause: bool = Query(False), runner: SessionManager = Depends(get_session_manager)): + run_id = await runner.start_scenario_task(scenario, auto_pause=auto_pause) return {"run_id": run_id} +@app.post("/scenario/advance-step") +async def advance_step(runner: SessionManager = Depends(get_session_manager)): + """Advance execution by one step when in step-by-step (auto-pause) mode.""" + runner._step_resume_event.set() + return {"advanced": True} + + @app.post("/stop-scenario") async def stop_scenario(runner: SessionManager = Depends(get_session_manager)): """Stop the currently running scenario.""" @@ -419,6 +426,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 + paused_event_sent = False while True: status_payload = runner.get_run_status() @@ -432,6 +440,14 @@ async def event_stream(): had_results = True idle_iterations = 0 + # Emit paused event once per pause boundary + if runner._is_paused and not paused_event_sent: + yield "event: paused\ndata: {}\n\n" + paused_event_sent = True + idle_iterations = 0 + elif not runner._is_paused: + paused_event_sent = False + 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..3e8ca370 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -56,15 +56,22 @@ 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._auto_pause: bool = False + self._is_paused: bool = False + self._step_resume_event: asyncio.Event = asyncio.Event() self._initialized = True - async def start_scenario_task(self, scenario: ScenarioDefinition): + async def start_scenario_task(self, scenario: ScenarioDefinition, auto_pause: bool = False): if not self.session_resolver: await self.initialize_session() if self.current_run_task and not self.current_run_task.done(): self.current_run_task.cancel() + self._auto_pause = auto_pause + self._is_paused = False + self._step_resume_event = asyncio.Event() # starts CLEARED → first step pauses immediately + # Each server-driven scenario must produce its own isolated run # directory so reports (including ``allure-results``) are not shared # across invocations. ``run_scenario`` only allocates a new timestamp @@ -107,6 +114,10 @@ async def stop_scenario(self) -> bool: stopped = False tasks_to_cancel = [] + # Unblock a paused step-by-step run so the task can be cancelled + self._is_paused = False + self._step_resume_event.set() + # Cancel the main scenario task if self.current_run_task and not self.current_run_task.done(): self.current_run_task.cancel() @@ -810,6 +821,19 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: self.session_context.update_result(skipped_result) continue + # Pause before executing this step if step-by-step mode is active. + # For normal runs, explicitly clear any stale step-by-step state so a + # previous paused execution cannot leak into this run and block it. + if self._auto_pause: + self._is_paused = True + await self._step_resume_event.wait() + self._step_resume_event.clear() + self._is_paused = False + else: + self._auto_pause = False + self._is_paused = False + self._step_resume_event.set() + # Wait for declared dependencies (useful for background steps) await self._wait_for_dependencies(step) diff --git a/tests/test_continue_on_error_integration.py b/tests/test_continue_on_error_integration.py index 707904d8..1882d068 100644 --- a/tests/test_continue_on_error_integration.py +++ b/tests/test_continue_on_error_integration.py @@ -44,6 +44,11 @@ def _make_runner() -> SessionManager: runner.current_run_error = None runner.data_files = None runner._stop_event = None + runner._auto_pause = False + runner._is_paused = False + import asyncio + + runner._step_resume_event = asyncio.Event() return runner diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index a8d43a6c..2e212656 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -183,7 +183,8 @@ const ScenarioEditorContent = () => { edgesRef.current = edges; }, [nodes, edges]); - const { isRunning, runScenario, stopScenario } = useScenarioRunner(); + const { isRunning, isPaused, runScenario, stopScenario, advanceStep } = useScenarioRunner(); + const [runMode, setRunMode] = useState<'all' | 'step' | null>(null); const { handleSaveToServer, handleSaveAs } = useScenarioFile( nodes, edges, @@ -862,6 +863,7 @@ const ScenarioEditorContent = () => { }, []); const handleRun = useCallback(async () => { + setRunMode('all'); // Clear previous results/errors from the UI immediately setNodes((nds) => nds.map(node => ({ ...node, @@ -907,6 +909,58 @@ const ScenarioEditorContent = () => { currentScenarioGroups, currentScenarioDescription ); + setRunMode(null); + }, [ + runScenario, + currentScenarioName, + currentScenarioGroups, + currentScenarioDescription, + operations, + setNodes, + updateNodesWithResults, + reactFlowInstance + ]); + + const handleStepByStep = useCallback(async () => { + setRunMode('step'); + // Clear previous results/errors from the UI immediately + 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 => { + if (node.id === nodeId) { + return { ...node, data: { ...node.data, status: 'waiting' } }; + } + return node; + })); + }; + + await runScenario( + currentNodes, + currentEdges, + currentScenarioName || "Interactive Session", + onStepComplete, + onStepStart, + operations, + currentScenarioGroups, + currentScenarioDescription, + true + ); + setRunMode(null); }, [ runScenario, currentScenarioName, @@ -919,6 +973,7 @@ const ScenarioEditorContent = () => { ]); const handleStop = useCallback(async () => { + setRunMode(null); await stopScenario(); // Clear running/waiting status from all nodes setNodes((nds) => nds.map(node => { @@ -1185,7 +1240,11 @@ const ScenarioEditorContent = () => { onSaveAs={handleSaveAs} onRun={handleRun} onStop={handleStop} + onRunStepByStep={handleStepByStep} + onAdvanceStep={advanceStep} isRunning={isRunning} + isPaused={isPaused} + isStepMode={runMode === 'step'} groupedByPhase={groupedByPhase} onToggleGroupByPhase={toggleGroupByPhase} /> diff --git a/web-editor/src/components/ScenarioEditor/Header.tsx b/web-editor/src/components/ScenarioEditor/Header.tsx index 147ef5a2..f5ec7be9 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, SkipForward, StepForward } from 'lucide-react'; import styles from '../../styles/Header.module.css'; import btnStyles from '../../styles/Button.module.css'; @@ -11,7 +11,11 @@ interface HeaderProps { onSaveAs: () => void; onRun: () => void; onStop: () => void; + onRunStepByStep: () => void; + onAdvanceStep: () => void; isRunning: boolean; + isPaused: boolean; + isStepMode: boolean; groupedByPhase: boolean; onToggleGroupByPhase: () => void; } @@ -25,7 +29,11 @@ export const Header = ({ onSaveAs, onRun, onStop, + onRunStepByStep, + onAdvanceStep, isRunning, + isPaused, + isStepMode, groupedByPhase, onToggleGroupByPhase, }: HeaderProps) => { @@ -64,11 +72,25 @@ export const Header = ({ Save As - - {isRunning && ( + {(!isRunning || !isStepMode) && ( + + )} + {(!isRunning || isStepMode) && ( + + )} + {isRunning && isStepMode && isPaused && ( + + )} + {isRunning && isStepMode && (