feat(vwb): execute wait for state
This commit is contained in:
96
tests/unit/test_vwb_wait_for_state.py
Normal file
96
tests/unit/test_vwb_wait_for_state.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from visual_workflow_builder.backend.actions.base_action import VWBActionStatus
|
||||||
|
from visual_workflow_builder.backend.actions.control.wait_for_state import (
|
||||||
|
VWBWaitForStateAction,
|
||||||
|
)
|
||||||
|
from visual_workflow_builder.backend.services.wait_for_state import (
|
||||||
|
match_expected_state,
|
||||||
|
wait_for_expected_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_expected_state_accepts_window_title_and_process_alias():
|
||||||
|
match = match_expected_state(
|
||||||
|
expected_state={
|
||||||
|
"window_title_contains": ["Executer"],
|
||||||
|
"process_active": "explorer.exe",
|
||||||
|
},
|
||||||
|
observed_state={
|
||||||
|
"window_title": "Executer",
|
||||||
|
"process_active": "explorer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert match.matched is True
|
||||||
|
assert match.matched_signals == ["window_title_contains", "process_active"]
|
||||||
|
assert match.failed_signals == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_expected_state_polls_until_match():
|
||||||
|
states = iter(
|
||||||
|
[
|
||||||
|
{"window_title": "Visual Workflow Builder", "process_active": "chrome"},
|
||||||
|
{"window_title": "Executer", "process_active": "explorer.exe"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = wait_for_expected_state(
|
||||||
|
expected_state={"window_title_in": ["Executer"]},
|
||||||
|
timeout_ms=500,
|
||||||
|
poll_interval_ms=50,
|
||||||
|
state_provider=lambda: next(states),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.matched is True
|
||||||
|
assert result.timed_out is False
|
||||||
|
assert result.polls == 2
|
||||||
|
assert result.match["matched_signals"] == ["window_title_in"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_state_action_returns_success_evidence_contract():
|
||||||
|
action = VWBWaitForStateAction(
|
||||||
|
action_id="wait_state_001",
|
||||||
|
parameters={
|
||||||
|
"expected_state": {
|
||||||
|
"window_title_in": ["Executer"],
|
||||||
|
"process_active": "explorer.exe",
|
||||||
|
},
|
||||||
|
"timeout_ms": 500,
|
||||||
|
"poll_interval_ms": 50,
|
||||||
|
"_state_provider": lambda: {
|
||||||
|
"window_title": "Executer",
|
||||||
|
"process_active": "explorer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = action.execute("step_001")
|
||||||
|
|
||||||
|
assert result.status == VWBActionStatus.SUCCESS
|
||||||
|
assert result.output_data["matched"] is True
|
||||||
|
assert result.output_data["match"]["matched_signals"] == [
|
||||||
|
"window_title_in",
|
||||||
|
"process_active",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_state_action_returns_timeout_with_observed_state():
|
||||||
|
action = VWBWaitForStateAction(
|
||||||
|
action_id="wait_state_002",
|
||||||
|
parameters={
|
||||||
|
"expected_state": {"window_title_contains": ["Enregistrer sous"]},
|
||||||
|
"timeout_ms": 100,
|
||||||
|
"poll_interval_ms": 50,
|
||||||
|
"_state_provider": lambda: {
|
||||||
|
"window_title": "Executer",
|
||||||
|
"process_active": "explorer.exe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = action.execute("step_002")
|
||||||
|
|
||||||
|
assert result.status == VWBActionStatus.TIMEOUT
|
||||||
|
assert result.error is not None
|
||||||
|
assert result.output_data["matched"] is False
|
||||||
|
assert result.output_data["match"]["failed_signals"] == ["window_title_contains"]
|
||||||
|
assert result.output_data["match"]["observed_state"]["window_title"] == "Executer"
|
||||||
@@ -28,6 +28,7 @@ from .vision_ui.focus_anchor import VWBFocusAnchorAction
|
|||||||
from .vision_ui.type_secret import VWBTypeSecretAction
|
from .vision_ui.type_secret import VWBTypeSecretAction
|
||||||
from .vision_ui.scroll_to_anchor import VWBScrollToAnchorAction
|
from .vision_ui.scroll_to_anchor import VWBScrollToAnchorAction
|
||||||
from .vision_ui.extract_text import VWBExtractTextAction
|
from .vision_ui.extract_text import VWBExtractTextAction
|
||||||
|
from .control.wait_for_state import VWBWaitForStateAction
|
||||||
from .registry import (
|
from .registry import (
|
||||||
VWBActionRegistry,
|
VWBActionRegistry,
|
||||||
get_global_registry,
|
get_global_registry,
|
||||||
@@ -50,6 +51,7 @@ __all__ = [
|
|||||||
'VWBTypeSecretAction',
|
'VWBTypeSecretAction',
|
||||||
'VWBScrollToAnchorAction',
|
'VWBScrollToAnchorAction',
|
||||||
'VWBExtractTextAction',
|
'VWBExtractTextAction',
|
||||||
|
'VWBWaitForStateAction',
|
||||||
'VWBActionRegistry',
|
'VWBActionRegistry',
|
||||||
'get_global_registry',
|
'get_global_registry',
|
||||||
'register_action',
|
'register_action',
|
||||||
@@ -62,4 +64,4 @@ __all__ = [
|
|||||||
|
|
||||||
__version__ = '1.1.0'
|
__version__ = '1.1.0'
|
||||||
__author__ = 'Dom, Alice, Kiro'
|
__author__ = 'Dom, Alice, Kiro'
|
||||||
__date__ = '10 janvier 2026'
|
__date__ = '10 janvier 2026'
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ Auteur : Dom, Claude - 14 janvier 2026
|
|||||||
|
|
||||||
Ce module contient les actions de contrôle de flux :
|
Ce module contient les actions de contrôle de flux :
|
||||||
- keyboard_shortcut : Raccourcis clavier
|
- keyboard_shortcut : Raccourcis clavier
|
||||||
|
- wait_for_state : Attente d'etat semantique
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .keyboard_shortcut import VWBKeyboardShortcutAction
|
from .keyboard_shortcut import VWBKeyboardShortcutAction
|
||||||
|
from .wait_for_state import VWBWaitForStateAction
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'VWBKeyboardShortcutAction',
|
'VWBKeyboardShortcutAction',
|
||||||
|
'VWBWaitForStateAction',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""VWB action that waits for a semantic screen state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ..base_action import BaseVWBAction, VWBActionResult, VWBActionStatus
|
||||||
|
from ...contracts.error import VWBErrorType, create_vwb_error
|
||||||
|
from ...services.wait_for_state import wait_for_expected_state, StateProvider
|
||||||
|
|
||||||
|
|
||||||
|
class VWBWaitForStateAction(BaseVWBAction):
|
||||||
|
"""Wait until the foreground window/process matches an expected state."""
|
||||||
|
|
||||||
|
SUPPORTED_EVIDENCE = {"window_or_process"}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
action_id: str,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
screen_capturer=None,
|
||||||
|
state_provider: Optional[StateProvider] = None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
action_id=action_id,
|
||||||
|
name="Attendre un etat",
|
||||||
|
description="Attend un etat semantique d'ecran",
|
||||||
|
parameters=parameters,
|
||||||
|
screen_capturer=screen_capturer,
|
||||||
|
)
|
||||||
|
self.expected_state = parameters.get("expected_state") or {}
|
||||||
|
self.timeout_ms = int(parameters.get("timeout_ms", 5000))
|
||||||
|
self.poll_interval_ms = int(parameters.get("poll_interval_ms", 250))
|
||||||
|
self.evidence_required = parameters.get(
|
||||||
|
"evidence_required", "window_or_process"
|
||||||
|
)
|
||||||
|
self.state_provider = state_provider or parameters.get("_state_provider")
|
||||||
|
|
||||||
|
def validate_parameters(self) -> List[str]:
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
if not isinstance(self.expected_state, dict) or not self.expected_state:
|
||||||
|
errors.append("expected_state requis")
|
||||||
|
|
||||||
|
criteria = (
|
||||||
|
"window_title_in",
|
||||||
|
"window_title_contains",
|
||||||
|
"process_active",
|
||||||
|
)
|
||||||
|
if isinstance(self.expected_state, dict) and not any(
|
||||||
|
self.expected_state.get(key) for key in criteria
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"expected_state doit definir window_title_in, "
|
||||||
|
"window_title_contains ou process_active"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.timeout_ms < 100:
|
||||||
|
errors.append("timeout_ms doit etre >= 100")
|
||||||
|
if self.poll_interval_ms < 50:
|
||||||
|
errors.append("poll_interval_ms doit etre >= 50")
|
||||||
|
if self.evidence_required not in self.SUPPORTED_EVIDENCE:
|
||||||
|
errors.append(
|
||||||
|
f"evidence_required non supporte: {self.evidence_required}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def execute_core(self, step_id: str) -> VWBActionResult:
|
||||||
|
start_time = datetime.now()
|
||||||
|
wait_result = wait_for_expected_state(
|
||||||
|
expected_state=self.expected_state,
|
||||||
|
timeout_ms=self.timeout_ms,
|
||||||
|
poll_interval_ms=self.poll_interval_ms,
|
||||||
|
evidence_required=self.evidence_required,
|
||||||
|
state_provider=self.state_provider,
|
||||||
|
)
|
||||||
|
end_time = datetime.now()
|
||||||
|
execution_time = (end_time - start_time).total_seconds() * 1000
|
||||||
|
output = wait_result.to_dict()
|
||||||
|
|
||||||
|
if wait_result.matched:
|
||||||
|
return VWBActionResult(
|
||||||
|
action_id=self.action_id,
|
||||||
|
step_id=step_id,
|
||||||
|
status=VWBActionStatus.SUCCESS,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
execution_time_ms=execution_time,
|
||||||
|
output_data=output,
|
||||||
|
evidence_list=self.evidence_list.copy(),
|
||||||
|
)
|
||||||
|
|
||||||
|
error = create_vwb_error(
|
||||||
|
error_type=VWBErrorType.WAIT_TIMEOUT,
|
||||||
|
message="Etat attendu non observe avant timeout",
|
||||||
|
action_id=self.action_id,
|
||||||
|
step_id=step_id,
|
||||||
|
technical_details=output,
|
||||||
|
execution_time_ms=execution_time,
|
||||||
|
)
|
||||||
|
return VWBActionResult(
|
||||||
|
action_id=self.action_id,
|
||||||
|
step_id=step_id,
|
||||||
|
status=VWBActionStatus.TIMEOUT,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
execution_time_ms=execution_time,
|
||||||
|
output_data=output,
|
||||||
|
evidence_list=self.evidence_list.copy(),
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
@@ -69,7 +69,7 @@ _LLM_ACTION_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Actions VWB de type attente
|
# Actions VWB de type attente
|
||||||
_WAIT_ACTION_TYPES = {"wait_for_anchor"}
|
_WAIT_ACTION_TYPES = {"wait_for_anchor", "wait_for_state"}
|
||||||
|
|
||||||
# Actions VWB de type condition
|
# Actions VWB de type condition
|
||||||
_CONDITION_ACTION_TYPES = {"visual_condition"}
|
_CONDITION_ACTION_TYPES = {"visual_condition"}
|
||||||
@@ -1179,6 +1179,7 @@ def execute_windows():
|
|||||||
'hotkey': 'key_combo',
|
'hotkey': 'key_combo',
|
||||||
'scroll_to_anchor': 'scroll',
|
'scroll_to_anchor': 'scroll',
|
||||||
'wait_for_anchor': 'wait',
|
'wait_for_anchor': 'wait',
|
||||||
|
'wait_for_state': 'wait_for_state',
|
||||||
'visual_condition': 'wait',
|
'visual_condition': 'wait',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,6 +1246,10 @@ def execute_windows():
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
if vwb_type in ('wait', 'wait_for_anchor', 'visual_condition') and 'duration_ms' in params:
|
if vwb_type in ('wait', 'wait_for_anchor', 'visual_condition') and 'duration_ms' in params:
|
||||||
action['duration_ms'] = params['duration_ms']
|
action['duration_ms'] = params['duration_ms']
|
||||||
|
if vwb_type == 'wait_for_state':
|
||||||
|
for key in ('expected_state', 'timeout_ms', 'poll_interval_ms', 'evidence_required'):
|
||||||
|
if key in params:
|
||||||
|
action[key] = params[key]
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Actions fichiers → proxy vers /file-action de l'agent (port 5006)
|
# Actions fichiers → proxy vers /file-action de l'agent (port 5006)
|
||||||
|
|||||||
@@ -80,6 +80,36 @@ _check_screen_for_patterns = _shared_check_patterns
|
|||||||
_handle_detected_pattern = _shared_handle_pattern
|
_handle_detected_pattern = _shared_handle_pattern
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_wait_for_state(params: Dict[str, Any]) -> dict:
|
||||||
|
from visual_workflow_builder.backend.services.wait_for_state import (
|
||||||
|
wait_for_expected_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_state = params.get("expected_state") or {}
|
||||||
|
timeout_ms = int(params.get("timeout_ms", 5000))
|
||||||
|
poll_interval_ms = int(params.get("poll_interval_ms", 250))
|
||||||
|
evidence_required = params.get("evidence_required", "window_or_process")
|
||||||
|
|
||||||
|
print(
|
||||||
|
"⏱️ [Action] Attente etat "
|
||||||
|
f"{expected_state} timeout={timeout_ms}ms"
|
||||||
|
)
|
||||||
|
result = wait_for_expected_state(
|
||||||
|
expected_state=expected_state,
|
||||||
|
timeout_ms=timeout_ms,
|
||||||
|
poll_interval_ms=poll_interval_ms,
|
||||||
|
evidence_required=evidence_required,
|
||||||
|
)
|
||||||
|
output = result.to_dict()
|
||||||
|
if result.matched:
|
||||||
|
return {"success": True, "output": output}
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Etat attendu non observe avant timeout",
|
||||||
|
"output": output,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def minimize_active_window():
|
def minimize_active_window():
|
||||||
"""Minimise le navigateur VWB et active la fenêtre suivante (VM, app cible)."""
|
"""Minimise le navigateur VWB et active la fenêtre suivante (VM, app cible)."""
|
||||||
try:
|
try:
|
||||||
@@ -1165,6 +1195,9 @@ def execute_action(action_type: str, params: dict) -> dict:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elif action_type == 'wait_for_state':
|
||||||
|
return _execute_wait_for_state(params)
|
||||||
|
|
||||||
elif action_type == 'keyboard_shortcut':
|
elif action_type == 'keyboard_shortcut':
|
||||||
keys = params.get('keys', [])
|
keys = params.get('keys', [])
|
||||||
if not keys:
|
if not keys:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ try:
|
|||||||
BaseVWBAction,
|
BaseVWBAction,
|
||||||
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
|
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
|
||||||
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
|
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
|
||||||
VWBExtractTextAction
|
VWBExtractTextAction, VWBWaitForStateAction
|
||||||
)
|
)
|
||||||
from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor
|
from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor
|
||||||
ACTIONS_AVAILABLE = True
|
ACTIONS_AVAILABLE = True
|
||||||
@@ -41,7 +41,7 @@ except ImportError as e:
|
|||||||
BaseVWBAction,
|
BaseVWBAction,
|
||||||
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
|
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
|
||||||
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
|
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
|
||||||
VWBExtractTextAction
|
VWBExtractTextAction, VWBWaitForStateAction
|
||||||
)
|
)
|
||||||
from .contracts.visual_anchor import VWBVisualAnchor
|
from .contracts.visual_anchor import VWBVisualAnchor
|
||||||
ACTIONS_AVAILABLE = True
|
ACTIONS_AVAILABLE = True
|
||||||
@@ -1278,6 +1278,12 @@ def create_action_from_config(action_config: Dict[str, Any]) -> Optional[BaseVWB
|
|||||||
parameters=parameters,
|
parameters=parameters,
|
||||||
screen_capturer=screen_capturer
|
screen_capturer=screen_capturer
|
||||||
)
|
)
|
||||||
|
elif action_type == 'wait_for_state':
|
||||||
|
return VWBWaitForStateAction(
|
||||||
|
action_id=action_id,
|
||||||
|
parameters=parameters,
|
||||||
|
screen_capturer=screen_capturer
|
||||||
|
)
|
||||||
elif action_type == 'focus_anchor':
|
elif action_type == 'focus_anchor':
|
||||||
return VWBFocusAnchorAction(
|
return VWBFocusAnchorAction(
|
||||||
action_id=action_id,
|
action_id=action_id,
|
||||||
@@ -1556,7 +1562,7 @@ def list_actions():
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"required": False,
|
"required": False,
|
||||||
"default": "window_or_process",
|
"default": "window_or_process",
|
||||||
"options": ["window_or_process", "uia", "ocr", "screenshot_diff"],
|
"options": ["window_or_process"],
|
||||||
"description": "Niveau de preuve requis"
|
"description": "Niveau de preuve requis"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2069,6 +2075,7 @@ def execute_action():
|
|||||||
|
|
||||||
result_message = ''
|
result_message = ''
|
||||||
execution_success = True
|
execution_success = True
|
||||||
|
direct_output_data = None
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
if action_type in ['click', 'click_anchor']:
|
if action_type in ['click', 'click_anchor']:
|
||||||
@@ -2696,6 +2703,29 @@ def execute_action():
|
|||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
result_message = f'Attente de {wait_time}s terminée'
|
result_message = f'Attente de {wait_time}s terminée'
|
||||||
|
|
||||||
|
elif action_type == 'wait_for_state':
|
||||||
|
from visual_workflow_builder.backend.services.wait_for_state import (
|
||||||
|
wait_for_expected_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_state = parameters.get('expected_state') or {}
|
||||||
|
timeout_ms = int(parameters.get('timeout_ms', 5000))
|
||||||
|
poll_interval_ms = int(parameters.get('poll_interval_ms', 250))
|
||||||
|
evidence_required = parameters.get('evidence_required', 'window_or_process')
|
||||||
|
wait_result = wait_for_expected_state(
|
||||||
|
expected_state=expected_state,
|
||||||
|
timeout_ms=timeout_ms,
|
||||||
|
poll_interval_ms=poll_interval_ms,
|
||||||
|
evidence_required=evidence_required,
|
||||||
|
)
|
||||||
|
direct_output_data = wait_result.to_dict()
|
||||||
|
execution_success = wait_result.matched
|
||||||
|
result_message = (
|
||||||
|
'Etat attendu observe'
|
||||||
|
if wait_result.matched
|
||||||
|
else 'Etat attendu non observe avant timeout'
|
||||||
|
)
|
||||||
|
|
||||||
elif action_type in ['focus', 'focus_anchor', 'focaliser']:
|
elif action_type in ['focus', 'focus_anchor', 'focaliser']:
|
||||||
# focus_anchor: chercher l'ancre et cliquer dessus pour donner le focus
|
# focus_anchor: chercher l'ancre et cliquer dessus pour donner le focus
|
||||||
visual_anchor = parameters.get('visual_anchor', {})
|
visual_anchor = parameters.get('visual_anchor', {})
|
||||||
@@ -2803,7 +2833,7 @@ def execute_action():
|
|||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"status": "success" if execution_success else "failed",
|
"status": "success" if execution_success else "failed",
|
||||||
"execution_time_ms": execution_time_ms,
|
"execution_time_ms": execution_time_ms,
|
||||||
"output_data": {"message": result_message},
|
"output_data": direct_output_data or {"message": result_message},
|
||||||
"evidence_list": [],
|
"evidence_list": [],
|
||||||
"error": None if execution_success else {"message": result_message}
|
"error": None if execution_success else {"message": result_message}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,22 @@ VWB_ACTION_CONTRACTS: Dict[str, ActionContract] = {
|
|||||||
optional_params=["wait_mode", "max_wait_time_ms", "check_interval_ms"],
|
optional_params=["wait_mode", "max_wait_time_ms", "check_interval_ms"],
|
||||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||||
),
|
),
|
||||||
|
"wait_for_state": ActionContract(
|
||||||
|
action_type="wait_for_state",
|
||||||
|
description="Attendre qu'un etat semantique d'ecran soit observe",
|
||||||
|
required_params=["expected_state"],
|
||||||
|
optional_params=["timeout_ms", "poll_interval_ms", "evidence_required"],
|
||||||
|
param_validators={
|
||||||
|
"expected_state": lambda p: isinstance(p, dict) and any(
|
||||||
|
p.get(key)
|
||||||
|
for key in (
|
||||||
|
"window_title_in",
|
||||||
|
"window_title_contains",
|
||||||
|
"process_active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
# --- ACTIONS DE SCROLL ---
|
# --- ACTIONS DE SCROLL ---
|
||||||
"scroll_to_anchor": ActionContract(
|
"scroll_to_anchor": ActionContract(
|
||||||
|
|||||||
187
visual_workflow_builder/backend/services/wait_for_state.py
Normal file
187
visual_workflow_builder/backend/services/wait_for_state.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Runtime helpers for VWB wait_for_state actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from time import monotonic, sleep
|
||||||
|
from typing import Any, Callable, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
|
||||||
|
StateProvider = Callable[[], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StateMatch:
|
||||||
|
matched: bool
|
||||||
|
matched_signals: List[str]
|
||||||
|
failed_signals: List[str]
|
||||||
|
observed_state: Dict[str, Any]
|
||||||
|
expected_state: Dict[str, Any]
|
||||||
|
evidence_required: str = "window_or_process"
|
||||||
|
unsupported_evidence: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WaitForStateResult:
|
||||||
|
matched: bool
|
||||||
|
timed_out: bool
|
||||||
|
elapsed_ms: int
|
||||||
|
polls: int
|
||||||
|
match: Dict[str, Any]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_string_list(value: Any) -> List[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value] if value.strip() else []
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
return [str(value).strip()] if str(value).strip() else []
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: Any) -> str:
|
||||||
|
return str(value or "").strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_process(value: Any) -> str:
|
||||||
|
text = _normalize_text(value)
|
||||||
|
return text[:-4] if text.endswith(".exe") else text
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_window_state() -> Dict[str, Any]:
|
||||||
|
"""Return the current foreground window title and process/app name."""
|
||||||
|
try:
|
||||||
|
from agent_v0.agent_v1.window_info_crossplatform import get_active_window_info
|
||||||
|
|
||||||
|
info = get_active_window_info() or {}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"window_title": "",
|
||||||
|
"process_active": "",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
title = info.get("title") or info.get("window_title") or ""
|
||||||
|
process = (
|
||||||
|
info.get("process_active")
|
||||||
|
or info.get("process_name")
|
||||||
|
or info.get("app_name")
|
||||||
|
or info.get("application")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"window_title": title,
|
||||||
|
"process_active": process,
|
||||||
|
"raw": dict(info),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def match_expected_state(
|
||||||
|
expected_state: Mapping[str, Any],
|
||||||
|
observed_state: Mapping[str, Any],
|
||||||
|
evidence_required: str = "window_or_process",
|
||||||
|
) -> StateMatch:
|
||||||
|
expected = dict(expected_state or {})
|
||||||
|
observed = {
|
||||||
|
"window_title": observed_state.get("window_title")
|
||||||
|
or observed_state.get("title")
|
||||||
|
or "",
|
||||||
|
"process_active": observed_state.get("process_active")
|
||||||
|
or observed_state.get("process_name")
|
||||||
|
or observed_state.get("app_name")
|
||||||
|
or "",
|
||||||
|
}
|
||||||
|
if "raw" in observed_state:
|
||||||
|
observed["raw"] = observed_state["raw"]
|
||||||
|
if "error" in observed_state:
|
||||||
|
observed["error"] = observed_state["error"]
|
||||||
|
|
||||||
|
matched_signals: List[str] = []
|
||||||
|
failed_signals: List[str] = []
|
||||||
|
|
||||||
|
title = _normalize_text(observed["window_title"])
|
||||||
|
exact_titles = _as_string_list(expected.get("window_title_in"))
|
||||||
|
if exact_titles:
|
||||||
|
if any(title == _normalize_text(candidate) for candidate in exact_titles):
|
||||||
|
matched_signals.append("window_title_in")
|
||||||
|
else:
|
||||||
|
failed_signals.append("window_title_in")
|
||||||
|
|
||||||
|
contains_titles = _as_string_list(expected.get("window_title_contains"))
|
||||||
|
if contains_titles:
|
||||||
|
if any(_normalize_text(candidate) in title for candidate in contains_titles):
|
||||||
|
matched_signals.append("window_title_contains")
|
||||||
|
else:
|
||||||
|
failed_signals.append("window_title_contains")
|
||||||
|
|
||||||
|
process_expected = _as_string_list(expected.get("process_active"))
|
||||||
|
if process_expected:
|
||||||
|
process = _normalize_process(observed["process_active"])
|
||||||
|
if any(process == _normalize_process(candidate) for candidate in process_expected):
|
||||||
|
matched_signals.append("process_active")
|
||||||
|
else:
|
||||||
|
failed_signals.append("process_active")
|
||||||
|
|
||||||
|
unsupported = None
|
||||||
|
if evidence_required != "window_or_process":
|
||||||
|
unsupported = evidence_required
|
||||||
|
|
||||||
|
return StateMatch(
|
||||||
|
matched=bool(matched_signals) and not failed_signals and unsupported is None,
|
||||||
|
matched_signals=matched_signals,
|
||||||
|
failed_signals=failed_signals,
|
||||||
|
observed_state=observed,
|
||||||
|
expected_state=expected,
|
||||||
|
evidence_required=evidence_required,
|
||||||
|
unsupported_evidence=unsupported,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_expected_state(
|
||||||
|
expected_state: Mapping[str, Any],
|
||||||
|
timeout_ms: int = 5000,
|
||||||
|
poll_interval_ms: int = 250,
|
||||||
|
evidence_required: str = "window_or_process",
|
||||||
|
state_provider: Optional[StateProvider] = None,
|
||||||
|
) -> WaitForStateResult:
|
||||||
|
provider = state_provider or get_active_window_state
|
||||||
|
timeout_s = max(0, int(timeout_ms)) / 1000.0
|
||||||
|
poll_s = max(0.05, int(poll_interval_ms) / 1000.0)
|
||||||
|
started = monotonic()
|
||||||
|
deadline = started + timeout_s
|
||||||
|
polls = 0
|
||||||
|
last_match = match_expected_state(expected_state, {}, evidence_required)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
polls += 1
|
||||||
|
observed = provider() or {}
|
||||||
|
last_match = match_expected_state(expected_state, observed, evidence_required)
|
||||||
|
elapsed_ms = int((monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
if last_match.matched:
|
||||||
|
return WaitForStateResult(
|
||||||
|
matched=True,
|
||||||
|
timed_out=False,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
polls=polls,
|
||||||
|
match=last_match.to_dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = deadline - monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return WaitForStateResult(
|
||||||
|
matched=False,
|
||||||
|
timed_out=True,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
polls=polls,
|
||||||
|
match=last_match.to_dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sleep(min(poll_s, remaining))
|
||||||
@@ -426,9 +426,6 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
|||||||
onChange={(e) => updateParam('evidence_required', e.target.value)}
|
onChange={(e) => updateParam('evidence_required', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="window_or_process">Fenêtre ou process</option>
|
<option value="window_or_process">Fenêtre ou process</option>
|
||||||
<option value="uia">UIA</option>
|
|
||||||
<option value="ocr">OCR</option>
|
|
||||||
<option value="screenshot_diff">Différence écran</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user