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 ? (
+
+ ) : (
+
+ )}
+
+ >
)}