feat(vwb): execute wait for state
This commit is contained in:
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))
|
||||
Reference in New Issue
Block a user