From 61d586c0cd633d51521c820277a0eaa6aa9f9d88 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 05:37:19 +0800 Subject: [PATCH] Add popup/interrupt watchdog for unattended automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a concurrent guard thread that watches for registered popups and dismisses them independently of the main flow — the #1 unattended-run failure cause per the practitioner-pain research. Generic matcher+action rules (window-title convenience builders + injectable custom rules); a failing rule is logged and skipped so the loop never dies. Wired through facade (PopupWatchdog / WatchdogRule / default_popup_watchdog), AC_watchdog_add/start/stop/list executor commands, ac_watchdog_* MCP tools, Script Builder entries, fake-driven headless tests, and v8 docs / README sections. --- README.md | 8 + README/README_zh-CN.md | 8 + README/README_zh-TW.md | 8 + .../Eng/doc/new_features/v8_features_doc.rst | 68 ++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v8_features_doc.rst | 65 ++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 18 ++ .../utils/executor/action_executor.py | 36 ++++ .../utils/mcp_server/tools/_factories.py | 47 +++++- .../utils/mcp_server/tools/_handlers.py | 25 +++ je_auto_control/utils/watchdog/__init__.py | 6 + .../utils/watchdog/popup_watchdog.py | 156 ++++++++++++++++++ .../unit_test/headless/test_popup_watchdog.py | 85 ++++++++++ 15 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v8_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v8_features_doc.rst create mode 100644 je_auto_control/utils/watchdog/__init__.py create mode 100644 je_auto_control/utils/watchdog/popup_watchdog.py create mode 100644 test/unit_test/headless/test_popup_watchdog.py diff --git a/README.md b/README.md index 4f109776..75a28076 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-19) — Popup Watchdog](#whats-new-2026-06-19--popup-watchdog) - [What's new (2026-06-19) — Native UI Control](#whats-new-2026-06-19--native-ui-control) - [What's new (2026-06-19)](#whats-new-2026-06-19) - [What's new (2026-06-18)](#whats-new-2026-06-18) @@ -60,6 +61,13 @@ --- +## What's new (2026-06-19) — Popup Watchdog + +The #1 cause of unattended-automation failure is an unexpected dialog the script never coded for (UAC, "session expiring", Windows Update, a modal). The popup watchdog runs a concurrent guard thread that watches for registered patterns and dismisses them independently of the main flow. Surfaced by the practitioner pain-point research as the top unattended failure cause; full stack (facade, `AC_*`, MCP, Script Builder), fully headless. Full reference: [`docs/source/Eng/doc/new_features/v8_features_doc.rst`](docs/source/Eng/doc/new_features/v8_features_doc.rst). + +- **Auto-dismiss popups** — `default_popup_watchdog.add_window_rule(title, action="close")` then `.start()` (`AC_watchdog_add` / `AC_watchdog_start` / `AC_watchdog_stop` / `AC_watchdog_list`): closes a matching window or presses a key (`enter`/`esc`) when it appears. +- **Custom rules** — `PopupWatchdog` / `WatchdogRule` pair any detector (image/a11y/text) with a dismisser; a failing rule is logged and skipped, never killing the guard loop. + ## What's new (2026-06-19) — Native UI Control Object-level desktop automation: read and drive native controls through the OS accessibility API (by name / role / app / **AutomationId**) instead of clicking pixels or OCR-ing text — far more reliable for native apps. The accessibility layer previously only listed/found/clicked; it now also acts. Ships through the full stack (facade, `AC_*`, MCP, Script Builder) with a Windows UIAutomation backend; unsupported backends raise a clear error. Full reference: [`docs/source/Eng/doc/new_features/v7_features_doc.rst`](docs/source/Eng/doc/new_features/v7_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index c9b77da3..43ae3234 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-19) — 弹窗看门狗](#本次更新-2026-06-19--弹窗看门狗) - [本次更新 (2026-06-19) — 原生 UI 控制](#本次更新-2026-06-19--原生-ui-控制) - [本次更新 (2026-06-19)](#本次更新-2026-06-19) - [本次更新 (2026-06-18)](#本次更新-2026-06-18) @@ -59,6 +60,13 @@ --- +## 本次更新 (2026-06-19) — 弹窗看门狗 + +无人值守自动化失败的第一大主因,是脚本没写到的意外对话框(UAC、"会话过期"、Windows Update、modal)。弹窗看门狗以并行守卫线程监看注册 pattern,独立于主流程把它们关掉。由社区痛点研究指出为无人值守头号失败主因;走完整五层(facade、`AC_*`、MCP、Script Builder),完全 headless。完整参考:[`docs/source/Eng/doc/new_features/v8_features_doc.rst`](../docs/source/Eng/doc/new_features/v8_features_doc.rst)。 + +- **自动关闭弹窗** — `default_popup_watchdog.add_window_rule(title, action="close")` 后 `.start()`(`AC_watchdog_add` / `AC_watchdog_start` / `AC_watchdog_stop` / `AC_watchdog_list`):窗口出现时关闭它或按键(`enter`/`esc`)。 +- **自定义规则** — `PopupWatchdog` / `WatchdogRule` 把任意检测器(图/a11y/文本)配对关闭器;坏规则只记录并跳过,绝不让守卫循环停摆。 + ## 本次更新 (2026-06-19) — 原生 UI 控制 对象级桌面自动化:通过 OS 无障碍 API(以 name / role / app / **AutomationId** 定位)读取与操作原生控件,而非点像素或 OCR——对原生 app 可靠得多。无障碍层先前只能 list/find/click,现在还能操作。走完整五层(facade、`AC_*`、MCP、Script Builder),提供 Windows UIAutomation 后端;不支持的后端会抛清楚错误。完整参考:[`docs/source/Eng/doc/new_features/v7_features_doc.rst`](../docs/source/Eng/doc/new_features/v7_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 6d885604..7a6643c0 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-19) — 彈窗看門狗](#本次更新-2026-06-19--彈窗看門狗) - [本次更新 (2026-06-19) — 原生 UI 控制](#本次更新-2026-06-19--原生-ui-控制) - [本次更新 (2026-06-19)](#本次更新-2026-06-19) - [本次更新 (2026-06-18)](#本次更新-2026-06-18) @@ -59,6 +60,13 @@ --- +## 本次更新 (2026-06-19) — 彈窗看門狗 + +無人值守自動化失敗的第一大主因,是腳本沒寫到的未預期對話框(UAC、「工作階段過期」、Windows Update、modal)。彈窗看門狗以並行守衛執行緒監看註冊 pattern,獨立於主流程把它們關掉。由社群痛點研究指出為無人值守頭號失敗主因;走完整五層(facade、`AC_*`、MCP、Script Builder),完全 headless。完整參考:[`docs/source/Zh/doc/new_features/v8_features_doc.rst`](../docs/source/Zh/doc/new_features/v8_features_doc.rst)。 + +- **自動關閉彈窗** — `default_popup_watchdog.add_window_rule(title, action="close")` 後 `.start()`(`AC_watchdog_add` / `AC_watchdog_start` / `AC_watchdog_stop` / `AC_watchdog_list`):視窗出現時關閉它或按鍵(`enter`/`esc`)。 +- **自訂規則** — `PopupWatchdog` / `WatchdogRule` 把任意偵測器(圖/a11y/文字)配對關閉器;壞規則只記錄並略過,絕不讓守衛迴圈停擺。 + ## 本次更新 (2026-06-19) — 原生 UI 控制 物件級桌面自動化:透過 OS 無障礙 API(以 name / role / app / **AutomationId** 定位)讀取與操作原生控制項,而非點像素或 OCR——對原生 app 可靠得多。無障礙層先前只能 list/find/click,現在還能操作。走完整五層(facade、`AC_*`、MCP、Script Builder),提供 Windows UIAutomation 後端;不支援的後端會拋清楚錯誤。完整參考:[`docs/source/Zh/doc/new_features/v7_features_doc.rst`](../docs/source/Zh/doc/new_features/v7_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v8_features_doc.rst b/docs/source/Eng/doc/new_features/v8_features_doc.rst new file mode 100644 index 00000000..41f74076 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v8_features_doc.rst @@ -0,0 +1,68 @@ +============================================= +New Features (2026-06-19) — Popup Watchdog +============================================= + +The #1 reason unattended automation fails is an unexpected dialog the +script was never coded for — a UAC prompt, a "session expiring" banner, a +Windows Update toast, a newsletter modal. The popup watchdog runs a +concurrent guard thread that watches for registered patterns and dismisses +them *independently* of the main step sequence, so a long run keeps going. + +Surfaced by the practitioner pain-point research as the top unattended +failure cause. Ships through the full stack (facade, ``AC_*`` executor +commands, MCP tools, Script Builder) and is fully headless — matchers and +actions are injectable, so it's unit-tested without a real desktop. + +.. contents:: + :local: + :depth: 2 + + +Quick start +=========== + +:: + + from je_auto_control import default_popup_watchdog + + # Auto-close any window whose title contains "Update Available". + default_popup_watchdog.add_window_rule("Update Available", action="close") + # Press Esc on a "Session expiring" dialog instead of closing it. + default_popup_watchdog.add_window_rule("Session expiring", action="esc") + default_popup_watchdog.start() + ... # run your main flow + default_popup_watchdog.stop() + +``action`` is ``"close"`` (close the matching window) or a key name to +press (``"enter"`` / ``"esc"`` / ...). The guard polls on a background +thread and records every dismissal in ``default_popup_watchdog.hits``. + + +Custom rules +============ + +For non-window popups, register a generic rule pairing a *detector* with a +*dismisser*:: + + from je_auto_control import PopupWatchdog, WatchdogRule + + watchdog = PopupWatchdog(poll_interval_s=0.5) + watchdog.add_rule(WatchdogRule( + name="cookie-banner", + matcher=lambda: locate_image_center("cookie.png") is not None, + action=lambda: click_text("Accept"), + )) + +A rule whose ``matcher``/``action`` raises is logged and skipped — one bad +rule never kills the guard loop. + + +Executor commands +================= + +* ``AC_watchdog_add`` — register a window rule (``title`` + ``action``). +* ``AC_watchdog_start`` / ``AC_watchdog_stop`` — control the guard thread. +* ``AC_watchdog_list`` — report run state, rules, and dismissals. + +A typical unattended script adds its rules and starts the watchdog before +the main work, so any stray dialog is cleared automatically. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 88c5ae08..8df2c67e 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -30,6 +30,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v5_features_doc doc/new_features/v6_features_doc doc/new_features/v7_features_doc + doc/new_features/v8_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v8_features_doc.rst b/docs/source/Zh/doc/new_features/v8_features_doc.rst new file mode 100644 index 00000000..93315840 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v8_features_doc.rst @@ -0,0 +1,65 @@ +==================================== +新功能 (2026-06-19) — 彈窗看門狗 +==================================== + +無人值守自動化失敗的第一大主因,是腳本沒寫到的未預期對話框——UAC 提示、 +「工作階段即將過期」橫幅、Windows Update 通知、電子報彈窗。彈窗看門狗以 +並行的守衛執行緒監看註冊的 pattern,並在**獨立於主步驟序列**之外將其關閉, +讓長時間執行得以持續。 + +這由社群痛點研究指出為無人值守的頭號失敗主因。走完整五層(facade、 +``AC_*`` 執行器指令、MCP 工具、Script Builder),且完全 headless——matcher +與 action 皆可注入,因此不需真實桌面即可單元測試。 + +.. contents:: + :local: + :depth: 2 + + +快速開始 +======== + +:: + + from je_auto_control import default_popup_watchdog + + # 自動關閉任何標題含「Update Available」的視窗。 + default_popup_watchdog.add_window_rule("Update Available", action="close") + # 對「Session expiring」對話框改按 Esc,而非關閉。 + default_popup_watchdog.add_window_rule("Session expiring", action="esc") + default_popup_watchdog.start() + ... # 執行你的主流程 + default_popup_watchdog.stop() + +``action`` 為 ``"close"``(關閉相符視窗)或要按的鍵名(``"enter"`` / +``"esc"`` / ...)。守衛在背景執行緒輪詢,並把每次關閉記錄在 +``default_popup_watchdog.hits``。 + + +自訂規則 +======== + +對於非視窗的彈窗,註冊一條把*偵測器*與*關閉器*配對的通用規則:: + + from je_auto_control import PopupWatchdog, WatchdogRule + + watchdog = PopupWatchdog(poll_interval_s=0.5) + watchdog.add_rule(WatchdogRule( + name="cookie-banner", + matcher=lambda: locate_image_center("cookie.png") is not None, + action=lambda: click_text("Accept"), + )) + +matcher/action 拋出例外的規則會被記錄並略過——單一壞規則絕不會讓守衛迴圈 +停擺。 + + +執行器指令 +========== + +* ``AC_watchdog_add`` — 註冊視窗規則(``title`` + ``action``)。 +* ``AC_watchdog_start`` / ``AC_watchdog_stop`` — 控制守衛執行緒。 +* ``AC_watchdog_list`` — 回報執行狀態、規則與關閉紀錄。 + +典型的無人值守腳本會在主工作之前先加規則並啟動看門狗,讓任何雜散對話框 +被自動清除。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 5d5a335c..b9250a7c 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -30,6 +30,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v5_features_doc doc/new_features/v6_features_doc doc/new_features/v7_features_doc + doc/new_features/v8_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 0c53342b..35136b90 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -97,6 +97,10 @@ from je_auto_control.utils.hotkey.hotkey_daemon import ( HotkeyBinding, HotkeyDaemon, default_hotkey_daemon, ) +# Background popup/interrupt watchdog (unattended automation) +from je_auto_control.utils.watchdog import ( + PopupWatchdog, WatchdogRule, default_popup_watchdog, +) # OCR (headless) from je_auto_control.utils.ocr.ocr_engine import ( TextMatch, click_text, find_text_matches, find_text_regex, @@ -489,6 +493,7 @@ def start_autocontrol_gui(*args, **kwargs): "get_clipboard", "set_clipboard", # Hotkey daemon "HotkeyDaemon", "HotkeyBinding", "default_hotkey_daemon", + "PopupWatchdog", "WatchdogRule", "default_popup_watchdog", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index f68b0ab0..279e3a4f 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -605,6 +605,24 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: def _add_misc_specs(specs: List[CommandSpec]) -> None: _add_native_control_specs(specs) + specs.append(CommandSpec( + "AC_watchdog_add", "Flow", "Watchdog: Add Popup Rule", + fields=( + FieldSpec("title", FieldType.STRING), + FieldSpec("action", FieldType.STRING, optional=True, + default="close", placeholder="close / enter / esc"), + FieldSpec("case_sensitive", FieldType.BOOL, optional=True, + default=False), + FieldSpec("name", FieldType.STRING, optional=True), + ), + description="Auto-dismiss an unexpected window when it appears.", + )) + specs.append(CommandSpec( + "AC_watchdog_start", "Flow", "Watchdog: Start")) + specs.append(CommandSpec( + "AC_watchdog_stop", "Flow", "Watchdog: Stop")) + specs.append(CommandSpec( + "AC_watchdog_list", "Flow", "Watchdog: List Rules / Hits")) specs.append(CommandSpec( "AC_shell_command", "Shell", "Shell Command", fields=(FieldSpec("shell_command", FieldType.STRING),), diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 80ca461b..e71d2151 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2257,6 +2257,38 @@ def _read_table(name: Optional[str] = None, role: Optional[str] = None, automation_id=automation_id) +def _watchdog_add(title: str, action: str = "close", + case_sensitive: bool = False, + name: Optional[str] = None) -> Dict[str, Any]: + """Adapter: register a popup-dismissal rule on the default watchdog.""" + from je_auto_control.utils.watchdog import default_popup_watchdog + default_popup_watchdog.add_window_rule( + title, action=str(action), case_sensitive=bool(case_sensitive), + name=name) + return {"rules": default_popup_watchdog.rule_names()} + + +def _watchdog_start() -> Dict[str, Any]: + """Adapter: start the background popup watchdog.""" + from je_auto_control.utils.watchdog import default_popup_watchdog + default_popup_watchdog.start() + return {"running": True} + + +def _watchdog_stop() -> Dict[str, Any]: + """Adapter: stop the background popup watchdog.""" + from je_auto_control.utils.watchdog import default_popup_watchdog + default_popup_watchdog.stop() + return {"running": False} + + +def _watchdog_list() -> Dict[str, Any]: + """Adapter: report the watchdog's rules, run state and dismissals.""" + from je_auto_control.utils.watchdog import default_popup_watchdog + w = default_popup_watchdog + return {"running": w.running, "rules": w.rule_names(), "hits": w.hits} + + class Executor: """ Executor @@ -2409,6 +2441,10 @@ def __init__(self): "AC_control_invoke": _control_invoke, "AC_control_toggle": _control_toggle, "AC_read_table": _read_table, + "AC_watchdog_add": _watchdog_add, + "AC_watchdog_start": _watchdog_start, + "AC_watchdog_stop": _watchdog_stop, + "AC_watchdog_list": _watchdog_list, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index f095f898..e07d3fb9 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1669,6 +1669,50 @@ def process_and_shell_tools() -> List[MCPTool]: ] +def watchdog_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_watchdog_add", + description=("Register a background popup-dismissal rule: when a " + "window whose title contains 'title' appears, the " + "watchdog closes it (action='close') or presses a key " + "(action='enter'/'esc'). Guards unattended runs " + "against unexpected dialogs (UAC, update prompts)."), + input_schema=schema({ + "title": {"type": "string"}, + "action": {"type": "string"}, + "case_sensitive": {"type": "boolean"}, + "name": {"type": "string"}, + }, required=["title"]), + handler=h.watchdog_add, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_watchdog_start", + description=("Start the background popup watchdog (concurrent guard " + "thread that dismisses registered popups)."), + input_schema=schema({}), + handler=h.watchdog_start, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_watchdog_stop", + description="Stop the background popup watchdog.", + input_schema=schema({}), + handler=h.watchdog_stop, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_watchdog_list", + description=("Report the watchdog's run state, registered rules, " + "and the popups it has dismissed."), + input_schema=schema({}), + handler=h.watchdog_list, + annotations=READ_ONLY, + ), + ] + + def hotkey_tools() -> List[MCPTool]: return [ MCPTool( @@ -2609,7 +2653,8 @@ def media_assert_tools() -> List[MCPTool]: smart_wait_tools, cost_telemetry_tools, failure_hook_tools, computer_use_tools, dag_tools, presence_tools, chatops_tools, redaction_tools, android_widget_tools, ios_tools, webrunner_tools, - scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools, + scheduler_tools, trigger_tools, hotkey_tools, watchdog_tools, + screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, sql_tools, http_tools, email_tools, pdf_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 4a3963d1..0eff75fa 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -751,6 +751,31 @@ def read_table(name=None, role=None, app_name=None, automation_id=None): automation_id=automation_id) +def watchdog_add(title, action="close", case_sensitive=False, name=None): + from je_auto_control.utils.watchdog import default_popup_watchdog + default_popup_watchdog.add_window_rule( + title, action=action, case_sensitive=bool(case_sensitive), name=name) + return {"rules": default_popup_watchdog.rule_names()} + + +def watchdog_start(): + from je_auto_control.utils.watchdog import default_popup_watchdog + default_popup_watchdog.start() + return {"running": True} + + +def watchdog_stop(): + from je_auto_control.utils.watchdog import default_popup_watchdog + default_popup_watchdog.stop() + return {"running": False} + + +def watchdog_list(): + from je_auto_control.utils.watchdog import default_popup_watchdog + w = default_popup_watchdog + return {"running": w.running, "rules": w.rule_names(), "hits": w.hits} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/je_auto_control/utils/watchdog/__init__.py b/je_auto_control/utils/watchdog/__init__.py new file mode 100644 index 00000000..b5b34a01 --- /dev/null +++ b/je_auto_control/utils/watchdog/__init__.py @@ -0,0 +1,6 @@ +"""Background popup/interrupt watchdog for unattended automation.""" +from je_auto_control.utils.watchdog.popup_watchdog import ( + PopupWatchdog, WatchdogRule, default_popup_watchdog, +) + +__all__ = ["PopupWatchdog", "WatchdogRule", "default_popup_watchdog"] diff --git a/je_auto_control/utils/watchdog/popup_watchdog.py b/je_auto_control/utils/watchdog/popup_watchdog.py new file mode 100644 index 00000000..b6c9bd25 --- /dev/null +++ b/je_auto_control/utils/watchdog/popup_watchdog.py @@ -0,0 +1,156 @@ +"""Background popup/interrupt watchdog. + +The #1 reason unattended automation fails is an unexpected dialog the +script was never coded for — a UAC prompt, "session expiring", a Windows +Update toast, a newsletter modal. This watchdog runs a concurrent guard +thread that polls for registered patterns and dismisses them (or runs a +recovery handler) *independently* of the main step sequence, so the flow +keeps going. + +It is deliberately generic: a :class:`WatchdogRule` pairs a ``matcher`` +(is the popup present?) with an ``action`` (dismiss it). Convenience +constructors build window-title rules from the window wrapper; both are +injectable so the logic is unit-tested without a real desktop. Imports no +``PySide6`` — fully headless. +""" +import threading +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.logging.logging_instance import autocontrol_logger + +# Errors a rule's matcher/action may raise that must not kill the guard loop +# (e.g. find_window raising AutoControlException off Windows). +_RULE_ERRORS = (OSError, RuntimeError, ValueError, AttributeError, TypeError, + AutoControlException) + + +@dataclass +class WatchdogRule: + """Pair a popup detector with the action that dismisses it.""" + name: str + matcher: Callable[[], bool] + action: Callable[[], None] + + +class PopupWatchdog: + """Poll for registered popups on a background thread and dismiss them.""" + + def __init__(self, poll_interval_s: float = 1.0) -> None: + self._poll = max(0.05, float(poll_interval_s)) + self._rules: List[WatchdogRule] = [] + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._hits: List[Dict[str, Any]] = [] + + def add_rule(self, rule: WatchdogRule) -> None: + """Register a generic detector/dismisser rule.""" + with self._lock: + self._rules.append(rule) + + def add_window_rule(self, title: str, *, action: str = "close", + case_sensitive: bool = False, + name: Optional[str] = None) -> None: + """Register a rule that dismisses a window matching ``title``. + + ``action`` is ``"close"`` (close the window) or a key name to press + (e.g. ``"enter"`` / ``"esc"``). + """ + rule = WatchdogRule( + name=name or f"window:{title}", + matcher=_window_matcher(title, case_sensitive), + action=_window_action(title, action, case_sensitive), + ) + self.add_rule(rule) + + def clear(self) -> None: + """Remove all rules.""" + with self._lock: + self._rules.clear() + + @property + def running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def hits(self) -> List[Dict[str, Any]]: + with self._lock: + return list(self._hits) + + def rule_names(self) -> List[str]: + with self._lock: + return [rule.name for rule in self._rules] + + def start(self) -> None: + """Start the guard thread (idempotent).""" + if self.running: + return + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, name="rd-popup-watchdog", daemon=True) + self._thread.start() + + def stop(self, timeout: float = 2.0) -> None: + """Signal the guard thread to stop and join it.""" + self._stop.set() + thread = self._thread + if thread is not None: + thread.join(timeout=float(timeout)) + self._thread = None + + def check_once(self) -> int: + """Run one detection pass; return the number of popups dismissed.""" + dismissed = 0 + with self._lock: + rules = list(self._rules) + for rule in rules: + if self._apply(rule): + dismissed += 1 + return dismissed + + def _apply(self, rule: WatchdogRule) -> bool: + try: + if not rule.matcher(): + return False + rule.action() + except _RULE_ERRORS as error: + autocontrol_logger.info( + "popup watchdog rule %r error: %r", rule.name, error) + return False + with self._lock: + self._hits.append({"rule": rule.name, "time": time.time()}) + return True + + def _loop(self) -> None: + while not self._stop.is_set(): + self.check_once() + self._stop.wait(self._poll) + + +def _window_matcher(title: str, case_sensitive: bool) -> Callable[[], bool]: + def present() -> bool: + from je_auto_control.wrapper.auto_control_window import find_window + return find_window(title, case_sensitive=case_sensitive) is not None + return present + + +def _window_action(title: str, action: str, + case_sensitive: bool) -> Callable[[], None]: + if action == "close": + def close() -> None: + from je_auto_control.wrapper.auto_control_window import ( + close_window_by_title, + ) + close_window_by_title(title, case_sensitive=case_sensitive) + return close + + def press_key() -> None: + from je_auto_control.wrapper.auto_control_keyboard import type_keyboard + type_keyboard(action) + return press_key + + +default_popup_watchdog = PopupWatchdog() diff --git a/test/unit_test/headless/test_popup_watchdog.py b/test/unit_test/headless/test_popup_watchdog.py new file mode 100644 index 00000000..a4739548 --- /dev/null +++ b/test/unit_test/headless/test_popup_watchdog.py @@ -0,0 +1,85 @@ +"""Headless tests for the background popup/interrupt watchdog. + +Rules use injected matcher/action callables, so no real windows or GUI are +needed; the executor-command path uses a window rule whose matcher only +*queries* windows (and whose errors are swallowed off-Windows). +""" +import time + +import je_auto_control as ac +from je_auto_control.utils.watchdog.popup_watchdog import ( + PopupWatchdog, WatchdogRule, default_popup_watchdog, +) + + +def test_check_once_dismisses_present_popup(): + state = {"present": True, "dismissed": 0} + + def action(): + state["dismissed"] += 1 + state["present"] = False + + w = PopupWatchdog() + w.add_rule(WatchdogRule("dialog", lambda: state["present"], action)) + assert w.check_once() == 1 + assert state["dismissed"] == 1 + assert w.check_once() == 0 # already gone + assert len(w.hits) == 1 + assert w.hits[0]["rule"] == "dialog" + + +def test_rule_error_is_swallowed(): + def boom(): + raise RuntimeError("matcher failed") + + w = PopupWatchdog() + w.add_rule(WatchdogRule("bad", boom, lambda: None)) + assert w.check_once() == 0 # error swallowed, loop survives + + +def test_start_stop_lifecycle(): + state = {"n": 0} + w = PopupWatchdog(poll_interval_s=0.02) + w.add_rule(WatchdogRule( + "tick", lambda: True, lambda: state.__setitem__("n", state["n"] + 1))) + w.start() + assert w.running is True + time.sleep(0.15) + w.stop() + assert w.running is False + assert state["n"] >= 1 + + +def test_executor_commands(): + default_popup_watchdog.clear() + default_popup_watchdog.stop() + try: + rec = ac.execute_action( + [["AC_watchdog_add", {"title": "no-such-window-xyz", + "action": "close"}]]) + assert any("rules" in str(v) for v in rec.values()) + ac.execute_action([["AC_watchdog_start"]]) + assert default_popup_watchdog.running is True + listed = ac.execute_action([["AC_watchdog_list"]]) + assert any("running" in str(v) for v in listed.values()) + finally: + ac.execute_action([["AC_watchdog_stop"]]) + default_popup_watchdog.clear() + assert default_popup_watchdog.running is False + + +def test_facade_and_mcp_wiring(): + assert ac.default_popup_watchdog is default_popup_watchdog + assert {"AC_watchdog_add", "AC_watchdog_start", "AC_watchdog_stop", + "AC_watchdog_list"} <= ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_watchdog_add", "ac_watchdog_start", "ac_watchdog_stop", + "ac_watchdog_list"} <= names + + +def test_builder_specs_registered(): + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_watchdog_add", "AC_watchdog_start", "AC_watchdog_stop", + "AC_watchdog_list"} <= cmds