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 && (