Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/openutm_verification/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Comment on lines +410 to +414


@app.post("/resume-step")
async def resume_step(runner: SessionManager = Depends(get_session_manager)):
runner.resume_step()
return {"resumed": True}

Comment on lines +410 to +421

@app.post("/stop-scenario")
async def stop_scenario(runner: SessionManager = Depends(get_session_manager)):
"""Stop the currently running scenario."""
Expand All @@ -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()
Expand All @@ -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"),
Expand Down
26 changes: 26 additions & 0 deletions src/openutm_verification/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
Comment on lines +118 to +127
self._step_resume_event.set()
stopped = False
tasks_to_cancel = []

Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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.
Expand Down
51 changes: 50 additions & 1 deletion web-editor/src/components/ScenarioEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
);
Comment on lines +938 to +967
}, [
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) => {
Expand Down Expand Up @@ -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}
/>
Expand Down
45 changes: 35 additions & 10 deletions web-editor/src/components/ScenarioEditor/Header.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
}
Expand All @@ -24,8 +28,12 @@ export const Header = ({
onSave,
onSaveAs,
onRun,
onStepRun,
onStop,
onResume,
isRunning,
isPaused,
isStepMode,
groupedByPhase,
onToggleGroupByPhase,
}: HeaderProps) => {
Expand Down Expand Up @@ -64,15 +72,32 @@ export const Header = ({
<Copy size={16} />
Save As
</button>
<button className={`${btnStyles.button} ${btnStyles.primary}`} onClick={onRun} disabled={isRunning} title="Run scenario" type="button">
{isRunning ? <Loader2 size={16} className={styles.spin} /> : <Play size={16} />}
Run Scenario
</button>
{isRunning && (
<button className={`${btnStyles.button} ${btnStyles.danger}`} onClick={onStop} title="Stop scenario" type="button">
<Square size={16} />
Stop
</button>
{!isRunning ? (
<>
<button className={`${btnStyles.button} ${btnStyles.primary}`} onClick={onRun} title="Run all steps at once" type="button">
<FastForward size={16} />
Run All
</button>
<button className={`${btnStyles.button} ${btnStyles.secondary}`} onClick={onStepRun} title="Run one step at a time" type="button">
<StepForward size={16} />
Step Through
</button>
</>
) : (
<>
{isStepMode && isPaused ? (
<button className={`${btnStyles.button} ${btnStyles.success}`} onClick={onResume} title="Execute next step" type="button">
<Play size={16} />
Next Step
</button>
) : (
<Loader2 size={16} className={styles.spin} />
)}
<button className={`${btnStyles.button} ${btnStyles.danger}`} onClick={onStop} title="Stop scenario" type="button">
<Square size={16} />
Stop
</button>
</>
)}
<button
className={btnStyles.button}
Expand Down
34 changes: 29 additions & 5 deletions web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ describe('Header', () => {
onSave: vi.fn(),
onSaveAs: vi.fn(),
onRun: vi.fn(),
onStepRun: vi.fn(),
onStop: vi.fn(),
onResume: vi.fn(),
isRunning: false,
isPaused: false,
isStepMode: false,
groupedByPhase: false,
onToggleGroupByPhase: vi.fn(),
};
Expand Down Expand Up @@ -57,16 +61,36 @@ describe('Header', () => {
expect(defaultProps.onSaveAs).toHaveBeenCalled();
});

it('calls onRun when Run button is clicked', () => {
it('calls onRun when Run All button is clicked', () => {
render(<Header {...defaultProps} />);
const runButton = screen.getByText('Run Scenario');
const runButton = screen.getByText('Run All');
fireEvent.click(runButton);
expect(defaultProps.onRun).toHaveBeenCalled();
});

it('shows loading state when isRunning is true', () => {
it('calls onStepRun when Step Through button is clicked', () => {
render(<Header {...defaultProps} />);
const stepButton = screen.getByText('Step Through');
fireEvent.click(stepButton);
expect(defaultProps.onStepRun).toHaveBeenCalled();
});

it('hides run buttons and shows Stop when running', () => {
render(<Header {...defaultProps} isRunning={true} />);
const runButton = screen.getByText('Run Scenario');
expect(runButton).toBeDisabled();
expect(screen.queryByText('Run All')).not.toBeInTheDocument();
expect(screen.queryByText('Step Through')).not.toBeInTheDocument();
expect(screen.getByText('Stop')).toBeInTheDocument();
});

it('shows Next Step button when paused in step mode', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} isPaused={true} />);
expect(screen.getByText('Next Step')).toBeInTheDocument();
expect(screen.getByText('Stop')).toBeInTheDocument();
});

it('calls onResume when Next Step button is clicked', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} isPaused={true} />);
fireEvent.click(screen.getByText('Next Step'));
expect(defaultProps.onResume).toHaveBeenCalled();
});
});
Loading
Loading